552 lines
24 KiB
TypeScript
552 lines
24 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect, useRef } from "react"
|
||
import {
|
||
Users,
|
||
Database,
|
||
RefreshCw,
|
||
Activity,
|
||
Brain,
|
||
Tags,
|
||
Package,
|
||
ArrowUpRight,
|
||
Zap,
|
||
Send,
|
||
Loader2,
|
||
Search,
|
||
Phone,
|
||
User,
|
||
TrendingUp,
|
||
Settings,
|
||
ChevronRight,
|
||
Sparkles,
|
||
Target,
|
||
} from "lucide-react"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Input } from "@/components/ui/input"
|
||
import Link from "next/link"
|
||
|
||
interface ChatMessage {
|
||
role: "user" | "assistant" | "thinking"
|
||
content: string
|
||
timestamp: string
|
||
data?: any
|
||
thinking?: string
|
||
portrait?: UserPortrait
|
||
fullProfile?: { valuation?: any; qqPhone?: any; ckbAsset?: any }
|
||
aiAnalysis?: string
|
||
/** 多人列表(最多 10 条完整画像) */
|
||
profiles?: { fullProfile: any; aiAnalysis: string }[]
|
||
}
|
||
|
||
// 标准用户画像模板
|
||
interface UserPortrait {
|
||
phone?: string
|
||
qq?: string
|
||
name?: string
|
||
gender?: string
|
||
province?: string
|
||
city?: string
|
||
level?: string
|
||
rfmScore?: number
|
||
tags?: string[]
|
||
behavior?: {
|
||
lastActive?: string
|
||
frequency?: string
|
||
}
|
||
dataCompleteness?: number
|
||
}
|
||
|
||
interface AIStatus {
|
||
status: string
|
||
database?: {
|
||
connected: boolean
|
||
totalUsers: number
|
||
latency: number
|
||
}
|
||
}
|
||
|
||
export default function HomePage() {
|
||
// AI对话状态
|
||
const [query, setQuery] = useState("")
|
||
const [loading, setLoading] = useState(false)
|
||
const [aiStatus, setAiStatus] = useState<AIStatus | null>(null)
|
||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||
{
|
||
role: "assistant",
|
||
content: "你好!我是神射手AI助手。\n\n支持 **手机、QQ、身份证、姓名** 统一搜索,曼谷库内数据完整展示:\n• 可搜一人或多人,用逗号分隔,最多列出 **前 10 条** 完整画像\n• 每条含:手机、QQ、地址、统一标签、流量池、存客宝、AI 分析\n• 也可问系统状态、高价值用户、RFM 等",
|
||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||
}
|
||
])
|
||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 统计数据
|
||
const [stats, setStats] = useState({
|
||
totalUsers: 0,
|
||
totalDataSources: 0,
|
||
latency: 0,
|
||
})
|
||
|
||
// 获取AI状态
|
||
useEffect(() => {
|
||
fetch("/api/ai-chat")
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
setAiStatus(data)
|
||
if (data.database) {
|
||
setStats({
|
||
totalUsers: data.database.totalUsers || 0,
|
||
totalDataSources: 26,
|
||
latency: data.database.latency || 0,
|
||
})
|
||
}
|
||
})
|
||
.catch(console.error)
|
||
}, [])
|
||
|
||
// 自动滚动到底部
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||
}, [messages])
|
||
|
||
// 解析用户画像(支持 fullProfile 与旧格式)
|
||
const parsePortrait = (data: any): UserPortrait | undefined => {
|
||
if (!data) return undefined
|
||
const v = data.fullProfile?.valuation || data.valuation || data
|
||
const qq = data.fullProfile?.qqPhone || data.qqPhone
|
||
return {
|
||
phone: v.phone_masked || v.phone || qq?.phone || data.手机号,
|
||
qq: v.qq || qq?.qq || data.qq || data.QQ,
|
||
name: v.name || data.姓名,
|
||
gender: v.gender || data.性别,
|
||
province: v.province || qq?.省份 || data.省份,
|
||
city: v.city || qq?.地区 || data.城市,
|
||
level: v.user_level || data.user_level || data.等级,
|
||
rfmScore: v.user_evaluation_score ?? data.rfm_score ?? data.user_evaluation_score,
|
||
tags: v.unified_tags || v.tags || data.tags || [],
|
||
behavior: {
|
||
lastActive: v.last_active || data.最近活跃,
|
||
frequency: v.frequency || data.活跃频率,
|
||
},
|
||
dataCompleteness: v.data_completeness || data.数据完整度,
|
||
}
|
||
}
|
||
|
||
// 生成思考过程
|
||
const generateThinking = (query: string): string => {
|
||
const isPhone = /^1[3-9]\d{9}$/.test(query.replace(/\s/g, ''))
|
||
const isQQ = /^\d{5,11}$/.test(query.replace(/\s|qq/gi, ''))
|
||
if (isPhone) {
|
||
return `🔍 识别到手机号\n→ 意图: 完整用户画像\n→ 数据源: KR + KR_腾讯 + KR_存客宝\n→ 输出: 完整画像 + AI 分析`
|
||
}
|
||
if (isQQ) {
|
||
return `🔍 识别到 QQ 号\n→ 意图: 完整用户画像\n→ 数据源: KR_腾讯 → 手机号 → 跨库画像\n→ 输出: 完整画像 + AI 分析`
|
||
}
|
||
return `🔍 关键字搜索\n→ 意图: 任意关键词匹配\n→ 数据源: KR.用户估值(姓名/城市/省份)\n→ 输出: 第一条完整画像 + AI 分析`
|
||
}
|
||
|
||
// 发送消息
|
||
const handleSend = async () => {
|
||
if (!query.trim() || loading) return
|
||
|
||
const userMessage: ChatMessage = {
|
||
role: "user",
|
||
content: query,
|
||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||
}
|
||
|
||
// 添加思考过程消息
|
||
const thinkingMessage: ChatMessage = {
|
||
role: "thinking",
|
||
content: generateThinking(query),
|
||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||
}
|
||
|
||
setMessages(prev => [...prev, userMessage, thinkingMessage])
|
||
setQuery("")
|
||
setLoading(true)
|
||
|
||
try {
|
||
const response = await fetch("/api/ai-chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ message: query })
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
// 解析用户画像
|
||
const portrait = parsePortrait(data.response?.data)
|
||
|
||
setMessages(prev => {
|
||
const filtered = prev.filter(m => m.role !== "thinking")
|
||
const respData = data.response?.data
|
||
const profiles = respData?.profiles || respData?.list
|
||
const first = Array.isArray(profiles)?.[0]
|
||
return [...filtered, {
|
||
role: "assistant",
|
||
content: data.success ? data.response.content : `查询失败: ${data.error || "未知错误"}`,
|
||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||
data: respData,
|
||
portrait: portrait || (first ? parsePortrait(first) : undefined),
|
||
fullProfile: respData?.fullProfile || first?.fullProfile,
|
||
aiAnalysis: respData?.aiAnalysis || first?.aiAnalysis,
|
||
profiles: Array.isArray(profiles) ? profiles : undefined,
|
||
thinking: data.response?.thinking || generateThinking(query),
|
||
}]
|
||
})
|
||
} catch (error: any) {
|
||
setMessages(prev => {
|
||
const filtered = prev.filter(m => m.role !== "thinking")
|
||
return [...filtered, {
|
||
role: "assistant",
|
||
content: `网络错误: ${error.message}`,
|
||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||
}]
|
||
})
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// 快捷查询
|
||
const quickQuery = (q: string) => {
|
||
setQuery(q)
|
||
setTimeout(() => handleSend(), 100)
|
||
}
|
||
|
||
const formatNumber = (num: number): string => {
|
||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`
|
||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||
return num.toLocaleString()
|
||
}
|
||
|
||
// 核心功能模块
|
||
const modules = [
|
||
{
|
||
title: "数据接入",
|
||
icon: Database,
|
||
color: "from-blue-500 to-cyan-500",
|
||
href: "/data-ingestion/sources",
|
||
desc: "数据源 · 清洗 · 调度",
|
||
},
|
||
{
|
||
title: "标签画像",
|
||
icon: Tags,
|
||
color: "from-green-500 to-emerald-500",
|
||
href: "/tag-portrait",
|
||
desc: "标签 · 画像 · 流量池",
|
||
},
|
||
{
|
||
title: "数据市场",
|
||
icon: Package,
|
||
color: "from-orange-500 to-red-500",
|
||
href: "/data-market",
|
||
desc: "API · 订阅 · 变现",
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 p-4 md:p-6">
|
||
{/* 顶部状态栏 */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-gray-900">神射手</h1>
|
||
<p className="text-xs text-gray-500">用户资产数字化中台</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge className={`${aiStatus?.status === 'online' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'} text-xs`}>
|
||
<div className={`w-1.5 h-1.5 rounded-full ${aiStatus?.status === 'online' ? 'bg-green-500' : 'bg-yellow-500'} mr-1`}></div>
|
||
{stats.latency}ms
|
||
</Badge>
|
||
<Link href="/monitoring/health">
|
||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||
<Activity className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
<Link href="/settings">
|
||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||
<Settings className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||
<Card className="border-0 shadow-sm bg-white/70">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Users className="h-4 w-4 text-blue-500" />
|
||
<div>
|
||
<div className="text-lg font-bold">{formatNumber(stats.totalUsers)}</div>
|
||
<div className="text-xs text-gray-500">用户</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-0 shadow-sm bg-white/70">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Database className="h-4 w-4 text-green-500" />
|
||
<div>
|
||
<div className="text-lg font-bold">{stats.totalDataSources}</div>
|
||
<div className="text-xs text-gray-500">数据源</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-0 shadow-sm bg-white/70">
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Zap className="h-4 w-4 text-orange-500" />
|
||
<div>
|
||
<div className="text-lg font-bold">{stats.latency}ms</div>
|
||
<div className="text-xs text-gray-500">延迟</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* AI对话区域 - 核心功能 */}
|
||
<Card className="border-0 shadow-lg bg-white/90 backdrop-blur mb-4 flex flex-col" style={{ height: 'calc(100vh - 340px)', minHeight: '300px' }}>
|
||
<CardHeader className="pb-2 border-b flex-shrink-0">
|
||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Brain className="h-4 w-4 text-purple-500" />
|
||
AI 智能对话
|
||
</div>
|
||
<Badge variant="outline" className="text-xs">
|
||
{formatNumber(stats.totalUsers)} 可查询
|
||
</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
|
||
{/* 消息列表 */}
|
||
<CardContent className="flex-1 overflow-y-auto p-4 space-y-3">
|
||
{messages.map((msg, i) => (
|
||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||
{/* 思考过程 */}
|
||
{msg.role === 'thinking' && (
|
||
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 bg-yellow-50 border border-yellow-200">
|
||
<div className="flex items-center gap-2 text-yellow-700 text-xs mb-1">
|
||
<Sparkles className="h-3 w-3 animate-pulse" />
|
||
AI思考中...
|
||
</div>
|
||
<div className="text-xs text-yellow-800 whitespace-pre-wrap font-mono">{msg.content}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 用户消息 */}
|
||
{msg.role === 'user' && (
|
||
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white">
|
||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||
<div className="text-xs mt-1 text-blue-100">{msg.timestamp}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI回复 */}
|
||
{msg.role === 'assistant' && (
|
||
<div className="max-w-[85%] space-y-2">
|
||
{/* 思考过程折叠显示 */}
|
||
{msg.thinking && (
|
||
<div className="rounded-xl px-3 py-2 bg-yellow-50 border border-yellow-100 text-xs">
|
||
<div className="text-yellow-600 font-medium mb-1">💭 思考过程</div>
|
||
<div className="text-yellow-800 whitespace-pre-wrap font-mono">{msg.thinking}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 多人列表:前 10 条完整画像(手机、QQ、地址、统一标签、AI 分析) */}
|
||
{msg.profiles && msg.profiles.length > 0 && (
|
||
<div className="space-y-3">
|
||
{msg.profiles.map((item, idx) => {
|
||
const p = parsePortrait(item)
|
||
const fp = item.fullProfile
|
||
const addr = [fp?.valuation?.province, fp?.valuation?.city, fp?.qqPhone?.省份, fp?.qqPhone?.地区].filter(Boolean)
|
||
const tags = [...(fp?.valuation?.unified_tags || []), ...(fp?.valuation?.tags || []), ...(fp?.ckbAsset?.tags || [])]
|
||
const uniqTags = [...new Set(tags)]
|
||
return (
|
||
<div key={idx} className="rounded-xl p-3 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-9 h-9 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center text-white text-sm font-bold">
|
||
{(p?.name || '?')[0]}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-semibold text-gray-900">{p?.name || '未知用户'}</div>
|
||
<div className="text-xs text-gray-500 flex flex-wrap gap-x-2">
|
||
{p?.phone && <span>📱 {p.phone}</span>}
|
||
{p?.qq && <span>QQ: {p.qq}</span>}
|
||
{addr.length > 0 && <span>📍 {addr.join(' ')}</span>}
|
||
</div>
|
||
</div>
|
||
{p?.level && <Badge className="shrink-0 bg-purple-100 text-purple-700">{p.level}级</Badge>}
|
||
{p?.rfmScore != null && <span className="text-xs text-purple-600 font-medium">{p.rfmScore} 分</span>}
|
||
</div>
|
||
{fp?.valuation?.traffic_pool?.pool_name && (
|
||
<div className="text-xs text-gray-600">📦 流量池: {fp.valuation.traffic_pool.pool_name}</div>
|
||
)}
|
||
{uniqTags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{uniqTags.slice(0, 8).map((tag, ti) => (
|
||
<Badge key={ti} variant="secondary" className="text-xs">{tag}</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
{item.aiAnalysis && (
|
||
<div className="rounded-lg p-2 bg-white/90 border border-purple-100">
|
||
<div className="text-xs text-purple-600 font-medium mb-0.5">🤖 AI 分析</div>
|
||
<div className="text-xs text-gray-700">{item.aiAnalysis}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{/* 单人完整用户画像卡片(含 AI 分析) */}
|
||
{!msg.profiles?.length && msg.portrait && (msg.portrait.phone || msg.portrait.qq || msg.fullProfile) && (
|
||
<div className="rounded-xl p-3 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center text-white font-bold">
|
||
{(msg.portrait.name || '?')[0]}
|
||
</div>
|
||
<div>
|
||
<div className="font-semibold text-gray-900">{msg.portrait.name || '未知用户'}</div>
|
||
<div className="text-xs text-gray-500">
|
||
{msg.portrait.phone && <span className="mr-2">📱 {msg.portrait.phone}</span>}
|
||
{msg.portrait.qq && <span>QQ: {msg.portrait.qq}</span>}
|
||
</div>
|
||
</div>
|
||
{msg.portrait.level && (
|
||
<Badge className="ml-auto bg-purple-100 text-purple-700">{msg.portrait.level}级</Badge>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||
{msg.portrait.rfmScore !== undefined && (
|
||
<div className="p-2 rounded bg-white/80">
|
||
<div className="text-gray-500">估值分</div>
|
||
<div className="font-bold text-purple-600">{msg.portrait.rfmScore}</div>
|
||
</div>
|
||
)}
|
||
{msg.portrait.province && (
|
||
<div className="p-2 rounded bg-white/80">
|
||
<div className="text-gray-500">地区</div>
|
||
<div className="font-medium">{msg.portrait.province} {msg.portrait.city}</div>
|
||
</div>
|
||
)}
|
||
{msg.fullProfile?.qqPhone && (
|
||
<div className="p-2 rounded bg-white/80 col-span-1">
|
||
<div className="text-gray-500">运营商</div>
|
||
<div className="font-medium">{msg.fullProfile.qqPhone.运营商 || '—'}</div>
|
||
</div>
|
||
)}
|
||
{msg.fullProfile?.ckbAsset && (
|
||
<div className="p-2 rounded bg-white/80 col-span-3">
|
||
<div className="text-gray-500">存客宝</div>
|
||
<div className="font-medium">{msg.fullProfile.ckbAsset.nickname || '—'} · 资产 {msg.fullProfile.ckbAsset.total_assets ?? '—'}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{msg.portrait.tags && msg.portrait.tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
<span className="text-xs text-gray-500 mr-1">标签:</span>
|
||
{msg.portrait.tags.slice(0, 8).map((tag, ti) => (
|
||
<Badge key={ti} variant="secondary" className="text-xs">{tag}</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
{msg.aiAnalysis && (
|
||
<div className="rounded-lg p-2.5 bg-white/90 border border-purple-100">
|
||
<div className="text-xs text-purple-600 font-medium mb-1">🤖 AI 分析</div>
|
||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{msg.aiAnalysis.replace(/\*\*/g, '')}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 文本回复 */}
|
||
<div className="rounded-2xl px-4 py-2.5 bg-gray-100 text-gray-800">
|
||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||
<div className="text-xs mt-1 text-gray-400">{msg.timestamp}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{loading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-gray-100 rounded-2xl px-4 py-2.5 flex items-center gap-2 text-gray-500">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
<span className="text-sm">查询中...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</CardContent>
|
||
|
||
{/* 快捷查询 */}
|
||
<div className="px-4 py-2 border-t flex-shrink-0">
|
||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("13407000001")}>
|
||
<Phone className="w-3 h-3 mr-1" /> 查手机
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("28533368 qq")}>
|
||
<User className="w-3 h-3 mr-1" /> 查QQ
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("系统状态")}>
|
||
<Activity className="w-3 h-3 mr-1" /> 系统状态
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("深圳")}>
|
||
<Search className="w-3 h-3 mr-1" /> 关键词
|
||
</Button>
|
||
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("高价值用户")}>
|
||
<TrendingUp className="w-3 h-3 mr-1" /> 高价值
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 输入框 */}
|
||
<div className="p-3 border-t flex-shrink-0">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyPress={(e) => e.key === "Enter" && handleSend()}
|
||
placeholder="输入手机、QQ、身份证、姓名(可多条逗号分隔,最多前10条)..."
|
||
className="flex-1 h-10 bg-gray-50 border-0"
|
||
/>
|
||
<Button
|
||
onClick={handleSend}
|
||
disabled={loading || !query.trim()}
|
||
className="h-10 px-4 bg-gradient-to-r from-blue-500 to-purple-500"
|
||
>
|
||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 功能模块入口 */}
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{modules.map((module, i) => (
|
||
<Link key={i} href={module.href}>
|
||
<Card className="border-0 shadow-sm bg-white/70 cursor-pointer hover:shadow-md transition-all group">
|
||
<CardContent className="p-3">
|
||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${module.color} flex items-center justify-center mb-2 group-hover:scale-110 transition-transform`}>
|
||
<module.icon className="w-5 h-5 text-white" />
|
||
</div>
|
||
<h3 className="font-medium text-sm text-gray-900">{module.title}</h3>
|
||
<p className="text-xs text-gray-500 mt-0.5">{module.desc}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|