refactor: restructure project into 5 core modules
Organize project by 5 core modules based on requirement docs. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
BIN
app/.DS_Store
vendored
@@ -1,15 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import "./globals.css"
|
||||
|
||||
import { MainNav } from "@/components/main-nav"
|
||||
import { UserNav } from "@/components/user-nav"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { Search } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Inter } from "next/font/google"
|
||||
import { useState, useEffect } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Sidebar from "./components/Sidebar"
|
||||
import MobileHeader from "./components/MobileHeader"
|
||||
import MobileSidebar from "./components/MobileSidebar"
|
||||
import BottomNav from "./components/BottomNav"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
@@ -18,74 +17,37 @@ export default function ClientLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const isLoginPage = pathname === "/login"
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
if (isLoginPage) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>神射手 - 登录</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>用户数据资产中台</title>
|
||||
<meta name="description" content="基于苹果毛玻璃设计的用户数据资产中台" />
|
||||
<title>神射手数据资产中台</title>
|
||||
<meta name="description" content="基于IMEI、手机号的用户数据资产管理平台" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-purple-50" />
|
||||
<div className="absolute top-0 left-0 w-72 h-72 md:w-96 md:h-96 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse" />
|
||||
<div className="absolute top-0 right-0 w-72 h-72 md:w-96 md:h-96 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse animation-delay-2000" />
|
||||
<div className="absolute bottom-0 left-1/2 w-72 h-72 md:w-96 md:h-96 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse animation-delay-4000" />
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="mr-4 hidden md:flex">
|
||||
<a className="mr-6 flex items-center space-x-2" href="/">
|
||||
<span className="hidden font-bold sm:inline-block">神射手数据中台</span>
|
||||
</a>
|
||||
<MainNav />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="搜索..." className="pl-8 h-9 md:w-[300px] lg:w-[300px]" />
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 bg-background">{children}</main>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-screen">
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
{isMobile && <MobileSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
|
||||
{/* 移动端头部 */}
|
||||
{isMobile && <MobileHeader onMenuToggle={() => setSidebarOpen(true)} />}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={`glass-card min-h-full ${isMobile ? "mx-2 mb-4" : ""}`}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && <BottomNav />}
|
||||
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
175
app/ai-assistant/data-analysis/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { BarChart3, TrendingUp, Users, Download, RefreshCw } from 'lucide-react'
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
||||
|
||||
export default function DataAnalysis() {
|
||||
const userBehaviorData = [
|
||||
{ hour: '00:00', active: 1250, purchases: 85, interactions: 4200 },
|
||||
{ hour: '04:00', active: 850, purchases: 35, interactions: 1800 },
|
||||
{ hour: '08:00', active: 5600, purchases: 420, interactions: 18500 },
|
||||
{ hour: '12:00', active: 8900, purchases: 680, interactions: 28600 },
|
||||
{ hour: '16:00', active: 7200, purchases: 520, interactions: 22400 },
|
||||
{ hour: '20:00', active: 9500, purchases: 750, interactions: 32800 },
|
||||
]
|
||||
|
||||
const channelData = [
|
||||
{ name: '微信朋友圈', value: 35, color: '#8b5cf6' },
|
||||
{ name: '微信群', value: 28, color: '#3b82f6' },
|
||||
{ name: '一对一私聊', value: 22, color: '#10b981' },
|
||||
{ name: '小程序', value: 15, color: '#f59e0b' },
|
||||
]
|
||||
|
||||
const conversionData = [
|
||||
{ stage: '浏览', users: 100000, rate: 100 },
|
||||
{ stage: '兴趣', users: 45000, rate: 45 },
|
||||
{ stage: '咨询', users: 18000, rate: 18 },
|
||||
{ stage: '下单', users: 5400, rate: 5.4 },
|
||||
{ stage: '复购', users: 1620, rate: 1.6 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">数据分析</h1>
|
||||
<p className="text-gray-600">用户行为分析、渠道分析与转化漏斗</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新数据
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键指标 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">今日活跃用户</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">285.6K</div>
|
||||
<Badge variant="outline" className="border-green-500 text-green-600 bg-green-50">+12.5%</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">转化率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">5.4%</div>
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 bg-blue-50">+0.8%</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">人均互动次数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">8.6</div>
|
||||
<Badge variant="outline" className="border-purple-500 text-purple-600 bg-purple-50">+1.2</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">客单价</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">¥328</div>
|
||||
<Badge variant="outline" className="border-orange-500 text-orange-600 bg-orange-50">+15.2%</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 用户行为趋势 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
用户活跃时段分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={userBehaviorData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="hour" stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="active" fill="#8b5cf6" name="活跃用户" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
渠道分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={channelData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, value }) => `${name} ${value}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{channelData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 转化漏斗 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
用户转化漏斗
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{conversionData.map((stage, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-gray-700 font-medium">{stage.stage}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-900 font-semibold">{stage.users.toLocaleString()}人</span>
|
||||
<span className="text-blue-600 font-semibold w-16 text-right">{stage.rate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg flex items-center px-4 text-white font-semibold" style={{ width: `${stage.rate}%`, minWidth: '10%' }}>
|
||||
{stage.rate}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { BarChart3, Database, FileText } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import TaskList from "@/components/ai-assistant/task-list"
|
||||
import CreateTaskDialog from "@/components/ai-assistant/create-task-dialog"
|
||||
import ReportCards from "@/components/ai-assistant/report-cards"
|
||||
import { sanitizeText } from "@/lib/text-sanitize"
|
||||
import type { AnalysisTask, DatabaseInfo, ReportItem, ReportTemplate } from "@/types/ai-assistant"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Cpu, BarChart3, TrendingUp, Users, Target, Brain, ArrowRight, Send, Sparkles } from 'lucide-react'
|
||||
|
||||
const initialTasks: AnalysisTask[] = [
|
||||
{
|
||||
id: "task_001",
|
||||
name: "用户行为分析报告",
|
||||
database: "微信用户数据库",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
createdAt: "2025-01-15T09:00:00Z",
|
||||
completedAt: "2025-01-15T09:30:00Z",
|
||||
reportUrl: "/reports/user-behavior-analysis.pdf",
|
||||
description: "基于近30天行为的聚合与序列分析",
|
||||
},
|
||||
{
|
||||
id: "task_002",
|
||||
name: "流量关键词趋势分析",
|
||||
database: "流量关键词库",
|
||||
status: "running",
|
||||
progress: 65,
|
||||
createdAt: "2025-01-15T10:00:00Z",
|
||||
description: "关注核心流量词与曝光、点击、转化等指标",
|
||||
},
|
||||
{
|
||||
id: "task_003",
|
||||
name: "用户价值分层报告",
|
||||
database: "微信用户数据库",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
description: "RFM 分层、价值区间分布与运营建议",
|
||||
},
|
||||
]
|
||||
export default function AIAssistant() {
|
||||
const [message, setMessage] = useState("")
|
||||
const [chatHistory, setChatHistory] = useState([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "你好!我是神射手AI助手,可以帮助你分析用户数据、生成报告、预测趋势。有什么我可以帮助你的吗?",
|
||||
timestamp: "10:30",
|
||||
},
|
||||
])
|
||||
|
||||
const databases: DatabaseInfo[] = [
|
||||
{ id: "db_001", name: "微信用户数据库", type: "MySQL", tables: 25, records: 4000000000, lastUpdated: "2025-01-15T11:00:00Z" },
|
||||
{ id: "db_002", name: "流量关键词库", type: "PostgreSQL", tables: 8, records: 150000, lastUpdated: "2025-01-15T10:45:00Z" },
|
||||
{ id: "db_003", name: "用户行为日志", type: "MongoDB", tables: 12, records: 1500000000, lastUpdated: "2025-01-15T11:15:00Z" },
|
||||
]
|
||||
// AI功能卡片
|
||||
const aiFeatures = [
|
||||
{
|
||||
icon: <BarChart3 className="w-8 h-8" />,
|
||||
title: "数据分析",
|
||||
description: "自动分析用户行为,生成洞察报告",
|
||||
color: "from-blue-500 to-cyan-500",
|
||||
href: "/platform/ai-assistant/data-analysis",
|
||||
stats: { reports: 1258, insights: 856 }
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="w-8 h-8" />,
|
||||
title: "趋势预测",
|
||||
description: "预测用户行为和价值变化趋势",
|
||||
color: "from-purple-500 to-pink-500",
|
||||
href: "/platform/ai-assistant/trend-prediction",
|
||||
stats: { accuracy: 94.2, predictions: 2450 }
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-8 h-8" />,
|
||||
title: "用户画像",
|
||||
description: "智能生成用户群体画像",
|
||||
color: "from-green-500 to-emerald-500",
|
||||
href: "/platform/ai-assistant/user-profiling",
|
||||
stats: { profiles: 4285, segments: 128 }
|
||||
},
|
||||
{
|
||||
icon: <Target className="w-8 h-8" />,
|
||||
title: "精准推荐",
|
||||
description: "推荐最佳营销策略和用户群",
|
||||
color: "from-orange-500 to-red-500",
|
||||
href: "/platform/ai-assistant/recommendation",
|
||||
stats: { campaigns: 586, conversion: 18.5 }
|
||||
},
|
||||
]
|
||||
|
||||
const templates: ReportTemplate[] = [
|
||||
{ id: "template_001", name: "用户画像分析报告", description: "深度分析用户特征、行为模式和价值分层", category: "用户分析", fields: ["用户基本信息", "RFM分析", "行为轨迹", "价值评估", "推荐策略"] },
|
||||
{ id: "template_002", name: "流量趋势分析报告", description: "分析关键词搜索趋势和流量变化", category: "流量分析", fields: ["关键词热度", "搜索趋势", "竞争分析", "机会识别", "优化建议"] },
|
||||
{ id: "template_003", name: "业务运营报告", description: "综合业务数据分析和运营建议", category: "运营分析", fields: ["核心指标", "增长分析", "用户留存", "转化漏斗", "运营建议"] },
|
||||
{ id: "template_004", name: "数据质量报告", description: "评估数据完整性、准确性和一致性", category: "数据质量", fields: ["数据完整性", "准确性检查", "一致性验证", "异常检测", "改进建议"] },
|
||||
]
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
"分析今日新增用户特征",
|
||||
"预测本月用户增长趋势",
|
||||
"高价值用户流失预警",
|
||||
"生成本周运营报告",
|
||||
]
|
||||
|
||||
const reportItems: ReportItem[] = [
|
||||
{ id: "rpt-1", title: "用户行为分析报告", source: "数据源:微信用户数据库", description: '本报告基于近30天互动行为,输出用户行为模式与高频路径。\\n"]]}', updatedAt: new Date().toISOString() },
|
||||
{ id: "rpt-2", title: "流量关键词趋势分析", source: "数据源:流量关键词库", description: '追踪 000000 类流量词及其曝光、点击、转化趋势,适配日/周/月视角。\\n" ]]}', updatedAt: new Date().toISOString() },
|
||||
{ id: "rpt-3", title: "用户价值分层报告", source: "数据源:微信用户数据库", description: '结合 RFM 得分与标签,给出 S/A/B/C/D 分层与经营建议。\\n" ]] }', updatedAt: new Date().toISOString() },
|
||||
]
|
||||
const handleSend = () => {
|
||||
if (!message.trim()) return
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [tasks, setTasks] = useState<AnalysisTask[]>(initialTasks)
|
||||
|
||||
// 进度推进(仅演示用)
|
||||
const tick = useCallback(() => {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.status === "running" && t.progress < 100) {
|
||||
const p = Math.min(100, t.progress + Math.random() * 12)
|
||||
if (p >= 100) {
|
||||
return {
|
||||
...t,
|
||||
progress: 100,
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString(),
|
||||
reportUrl: `/reports/${t.id}.pdf`,
|
||||
}
|
||||
}
|
||||
return { ...t, progress: p }
|
||||
}
|
||||
return t
|
||||
}),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const onCreate = (task: AnalysisTask) => {
|
||||
setTasks((prev) => [task, ...prev])
|
||||
// 模拟启动
|
||||
setTimeout(() => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, status: "running" } : t)))
|
||||
}, 800)
|
||||
setChatHistory([
|
||||
...chatHistory,
|
||||
{
|
||||
role: "user",
|
||||
content: message,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "正在分析您的问题,请稍候...",
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
])
|
||||
setMessage("")
|
||||
}
|
||||
|
||||
const sanitizedItems = useMemo(
|
||||
() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">AI智能助手</h1>
|
||||
<p className="text-gray-600">数据库分析与智能报告生成</p>
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<Brain className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900">AI智能助手</h1>
|
||||
<p className="text-gray-600">数据分析、报告生成与智能预测</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* AI状态 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-green-600" />
|
||||
AI运行状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">系统状态</span>
|
||||
<Badge variant="outline" className="border-green-500 text-green-600 bg-green-50">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse mr-2"></div>
|
||||
运行中
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">模型版本</span>
|
||||
<span className="text-gray-900 font-semibold">v3.5</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">响应速度</span>
|
||||
<span className="text-gray-900 font-semibold">0.8s</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">准确率</span>
|
||||
<span className="text-green-600 font-semibold">94.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI功能卡片 */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{aiFeatures.map((feature, index) => (
|
||||
<Link key={index} href={feature.href}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className={`w-16 h-16 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center text-white`}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{feature.title}</h3>
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200">
|
||||
{Object.entries(feature.stats).map(([key, value], i) => (
|
||||
<div key={i}>
|
||||
<div className="text-xs text-gray-600 mb-1">{key === 'reports' ? '报告数' : key === 'insights' ? '洞察数' : key === 'accuracy' ? '准确率' : key === 'predictions' ? '预测数' : key === 'profiles' ? '画像数' : key === 'segments' ? '分组数' : key === 'campaigns' ? '活动数' : '转化率'}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{typeof value === 'number' && value % 1 !== 0 ? `${value}%` : value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="analysis" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="analysis" className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
数据分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="databases" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据库
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
报告模板
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analysis" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">数据分析任务</h2>
|
||||
<CreateTaskDialog databases={databases} templates={templates} onCreate={onCreate} />
|
||||
</div>
|
||||
|
||||
<TaskList tasks={tasks} onTick={tick} />
|
||||
|
||||
<ReportCards items={sanitizedItems} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="databases">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据库
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{databases.map((db) => (
|
||||
<Card key={db.id} className="border">
|
||||
<CardContent className="p-4 space-y-1">
|
||||
<div className="font-medium">{db.name}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{db.type} · {db.tables} 表
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
最近更新 {new Date(db.lastUpdated).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 对话区域 */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-white/5 border-white/10 h-[calc(100vh-200px)]">
|
||||
<CardHeader className="border-b border-white/10">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
AI对话
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 flex flex-col h-[calc(100%-80px)]">
|
||||
{/* 聊天历史 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 mb-6">
|
||||
{chatHistory.map((chat, index) => (
|
||||
<div key={index} className={`flex ${chat.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl p-4 ${
|
||||
chat.role === "user"
|
||||
? "bg-gradient-to-br from-blue-500 to-purple-500 text-white"
|
||||
: "bg-white/10 text-white"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1">{chat.content}</div>
|
||||
<div className="text-xs opacity-70">{chat.timestamp}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
报告模板
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map((t) => (
|
||||
<Card key={t.id} className="border">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-sm text-gray-600">{t.category}</div>
|
||||
<div className="text-xs text-gray-500">字段: {t.fields.join(" / ")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 快捷问题 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{quickQuestions.map((question, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMessage(question)}
|
||||
className="bg-white/5 border-white/10 hover:bg-white/10 text-xs"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="输入您的问题,按Enter发送..."
|
||||
className="bg-white/5 border-white/10 text-white resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<Button onClick={handleSend} className="bg-gradient-to-r from-blue-500 to-purple-500 h-[88px]">
|
||||
<Send className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
313
app/ai-assistant/recommendation/page.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Target, Users, TrendingUp, Zap, Download, RefreshCw, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function Recommendation() {
|
||||
const recommendedCampaigns = [
|
||||
{
|
||||
id: 1,
|
||||
title: '美妆新品推广',
|
||||
targetGroup: '高净值女性用户',
|
||||
userCount: 856780,
|
||||
expectedRevenue: 12850000,
|
||||
probability: 82,
|
||||
roi: 3.5,
|
||||
timeframe: '未来7天',
|
||||
actions: ['朋友圈广告', '私聊推送', '限时优惠'],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '会员升级计划',
|
||||
targetGroup: '活跃普通会员',
|
||||
userCount: 1285670,
|
||||
expectedRevenue: 8560000,
|
||||
probability: 75,
|
||||
roi: 4.2,
|
||||
timeframe: '未来15天',
|
||||
actions: ['会员权益推广', '专属优惠', 'VIP体验券'],
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '沉默用户唤醒',
|
||||
targetGroup: '30天未活跃用户',
|
||||
userCount: 2856780,
|
||||
expectedRevenue: 5680000,
|
||||
probability: 58,
|
||||
roi: 2.8,
|
||||
timeframe: '未来30天',
|
||||
actions: ['优惠券发放', '新品预告', '个性化推荐'],
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '交叉销售',
|
||||
targetGroup: '单品类购买用户',
|
||||
userCount: 1714283,
|
||||
expectedRevenue: 9280000,
|
||||
probability: 68,
|
||||
roi: 3.1,
|
||||
timeframe: '未来14天',
|
||||
actions: ['关联商品推荐', '搭配优惠', '场景营销'],
|
||||
priority: 'medium'
|
||||
},
|
||||
]
|
||||
|
||||
const recommendedSegments = [
|
||||
{
|
||||
name: '高转化潜力群体',
|
||||
count: 428567,
|
||||
conversion: 35.8,
|
||||
value: 580,
|
||||
characteristics: ['近期浏览频繁', '价格接受度高', '品牌认知强']
|
||||
},
|
||||
{
|
||||
name: '复购价值用户',
|
||||
count: 856780,
|
||||
conversion: 28.5,
|
||||
value: 420,
|
||||
characteristics: ['首购满意度高', '客单价适中', '活跃度稳定']
|
||||
},
|
||||
{
|
||||
name: '裂变传播节点',
|
||||
count: 285670,
|
||||
conversion: 22.3,
|
||||
value: 320,
|
||||
characteristics: ['社交影响力大', '分享意愿强', '粉丝基数高']
|
||||
},
|
||||
]
|
||||
|
||||
const contentRecommendations = [
|
||||
{
|
||||
type: '朋友圈内容',
|
||||
theme: '新品上市+限时优惠',
|
||||
targetUsers: 2856780,
|
||||
expectedEngagement: 18.5,
|
||||
bestTime: '20:00-22:00'
|
||||
},
|
||||
{
|
||||
type: '私聊话术',
|
||||
theme: '专属VIP权益介绍',
|
||||
targetUsers: 856780,
|
||||
expectedEngagement: 42.8,
|
||||
bestTime: '10:00-12:00'
|
||||
},
|
||||
{
|
||||
type: '社群活动',
|
||||
theme: '用户互动+抽奖',
|
||||
targetUsers: 1285670,
|
||||
expectedEngagement: 32.5,
|
||||
bestTime: '周末 15:00-17:00'
|
||||
},
|
||||
]
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
return priority === 'high' ? 'border-red-500 text-red-600 bg-red-50' : 'border-yellow-500 text-yellow-600 bg-yellow-50'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">精准推荐</h1>
|
||||
<p className="text-gray-600">AI智能推荐营销策略、目标用户群与内容方案</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新推荐
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出方案
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">推荐方案数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600">586</div>
|
||||
<div className="text-sm text-gray-600">本月生成</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">平均转化率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">18.5%</div>
|
||||
<Badge variant="outline" className="border-green-500 text-green-600 bg-green-50">+3.2%</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-green-50 to-green-100 border-green-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">预计收益</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">¥36.4M</div>
|
||||
<div className="text-sm text-gray-600">近期方案</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">平均ROI</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600">3.4x</div>
|
||||
<div className="text-sm text-gray-600">投入产出比</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 推荐营销方案 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Target className="w-5 h-5" />
|
||||
推荐营销方案
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recommendedCampaigns.map((campaign) => (
|
||||
<div key={campaign.id} className="p-6 bg-white border border-gray-200 rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900">{campaign.title}</h3>
|
||||
<Badge variant="outline" className={getPriorityColor(campaign.priority)}>
|
||||
{campaign.priority === 'high' ? '高优先级' : '中优先级'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-gray-300 text-gray-600">
|
||||
{campaign.timeframe}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-gray-600">目标群体: {campaign.targetGroup}</p>
|
||||
</div>
|
||||
<Button>
|
||||
执行方案
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 mb-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">目标用户数</div>
|
||||
<div className="text-lg font-semibold text-gray-900">{campaign.userCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">预期收益</div>
|
||||
<div className="text-lg font-semibold text-green-600">¥{(campaign.expectedRevenue / 10000).toFixed(1)}万</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">成功概率</div>
|
||||
<div className="text-lg font-semibold text-blue-600">{campaign.probability}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">预期ROI</div>
|
||||
<div className="text-lg font-semibold text-purple-600">{campaign.roi}x</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-2">推荐动作</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{campaign.actions.map((action, index) => (
|
||||
<Badge key={index} variant="outline" className="border-blue-300 text-blue-600 bg-blue-50">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
{action}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 推荐目标群体 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
{recommendedSegments.map((segment, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 text-lg">{segment.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">用户数</span>
|
||||
<span className="text-lg font-semibold text-gray-900">{segment.count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">预期转化率</span>
|
||||
<span className="text-lg font-semibold text-green-600">{segment.conversion}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">人均价值</span>
|
||||
<span className="text-lg font-semibold text-blue-600">¥{segment.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600 mb-2">群体特征</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{segment.characteristics.map((char, i) => (
|
||||
<Badge key={i} variant="outline" className="border-gray-300 text-gray-700 text-xs">
|
||||
{char}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容推荐 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
内容推荐
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{contentRecommendations.map((content, index) => (
|
||||
<div key={index} className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-1">{content.type}</h4>
|
||||
<p className="text-gray-600">{content.theme}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-purple-500 text-purple-600 bg-purple-50">
|
||||
最佳时间: {content.bestTime}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">目标触达: </span>
|
||||
<span className="font-semibold text-gray-900">{content.targetUsers.toLocaleString()} 人</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">预期互动率: </span>
|
||||
<span className="font-semibold text-green-600">{content.expectedEngagement}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
222
app/ai-assistant/trend-prediction/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TrendingUp, TrendingDown, AlertCircle, Download, RefreshCw } from 'lucide-react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||
|
||||
export default function TrendPrediction() {
|
||||
const userGrowthPrediction = [
|
||||
{ date: '2024-06', actual: 428, predicted: null },
|
||||
{ date: '2024-07', actual: null, predicted: 445 },
|
||||
{ date: '2024-08', actual: null, predicted: 468 },
|
||||
{ date: '2024-09', actual: null, predicted: 495 },
|
||||
{ date: '2024-10', actual: null, predicted: 528 },
|
||||
{ date: '2024-11', actual: null, predicted: 565 },
|
||||
{ date: '2024-12', actual: null, predicted: 608 },
|
||||
]
|
||||
|
||||
const valuePrediction = [
|
||||
{ date: '2024-06', actual: 125.8, predicted: null },
|
||||
{ date: '2024-07', actual: null, predicted: 132.5 },
|
||||
{ date: '2024-08', actual: null, predicted: 141.2 },
|
||||
{ date: '2024-09', actual: null, predicted: 151.8 },
|
||||
{ date: '2024-10', actual: null, predicted: 164.5 },
|
||||
{ date: '2024-11', actual: null, predicted: 179.2 },
|
||||
{ date: '2024-12', actual: null, predicted: 196.5 },
|
||||
]
|
||||
|
||||
const churnRiskData = [
|
||||
{ segment: '高价值用户', risk: 8.5, count: 1285, trend: 'down' },
|
||||
{ segment: 'VIP用户', risk: 12.3, count: 856, trend: 'stable' },
|
||||
{ segment: '活跃用户', risk: 18.6, count: 12856, trend: 'up' },
|
||||
{ segment: '沉默用户', risk: 45.8, count: 28560, trend: 'up' },
|
||||
]
|
||||
|
||||
const opportunityData = [
|
||||
{ name: '新品推广机会', potential: '¥85.6M', probability: 78, timeframe: '未来30天' },
|
||||
{ name: '复购唤醒', potential: '¥56.2M', probability: 65, timeframe: '未来15天' },
|
||||
{ name: '会员升级', potential: '¥38.5M', probability: 82, timeframe: '未来7天' },
|
||||
{ name: '交叉销售', potential: '¥92.8M', probability: 58, timeframe: '未来45天' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">趋势预测</h1>
|
||||
<p className="text-gray-600">用户增长预测、价值预测与流失预警</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新预测
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预测指标 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">下月预测用户数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600 mb-1">445M</div>
|
||||
<div className="text-sm text-gray-600">+3.9% 增长</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-green-50 to-green-100 border-green-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">下月预测价值</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600 mb-1">¥132.5亿</div>
|
||||
<div className="text-sm text-gray-600">+5.3% 增长</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">流失风险用户</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600 mb-1">42.5K</div>
|
||||
<div className="text-sm text-gray-600">需重点关注</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">预测准确率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600 mb-1">94.2%</div>
|
||||
<div className="text-sm text-gray-600">基于历史数据</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 趋势图表 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-blue-600" />
|
||||
用户增长预测
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={userGrowthPrediction}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="actual" stroke="#3b82f6" strokeWidth={2} name="实际值" />
|
||||
<Line type="monotone" dataKey="predicted" stroke="#8b5cf6" strokeWidth={2} strokeDasharray="5 5" name="预测值" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
资产价值预测
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={valuePrediction}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="actual" stroke="#10b981" fill="#10b981" fillOpacity={0.3} name="实际值" />
|
||||
<Area type="monotone" dataKey="predicted" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.2} strokeDasharray="5 5" name="预测值" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 流失风险预警 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
流失风险预警
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{churnRiskData.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900">{item.segment}</div>
|
||||
<div className="text-sm text-gray-600">{item.count.toLocaleString()} 用户</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-orange-600">{item.risk}%</div>
|
||||
<div className="text-xs text-gray-600">流失风险</div>
|
||||
</div>
|
||||
{item.trend === 'down' ? (
|
||||
<TrendingDown className="w-6 h-6 text-green-600" />
|
||||
) : item.trend === 'up' ? (
|
||||
<TrendingUp className="w-6 h-6 text-red-600" />
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<div className="w-4 h-0.5 bg-gray-400"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 商机预测 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-purple-600" />
|
||||
商机预测
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{opportunityData.map((item, index) => (
|
||||
<div key={index} className="p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{item.name}</h3>
|
||||
<Badge variant="outline" className="border-purple-500 text-purple-600 bg-purple-50">
|
||||
{item.timeframe}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">预测收益</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{item.potential}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600 mb-1">成功概率</div>
|
||||
<div className="text-2xl font-bold text-green-600">{item.probability}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
app/ai-assistant/user-profiling/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Users, Tag, Download, RefreshCw, TrendingUp } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts'
|
||||
|
||||
export default function UserProfiling() {
|
||||
const ageDistribution = [
|
||||
{ range: '18-25', count: 856780, percentage: 20 },
|
||||
{ range: '26-35', count: 1500000, percentage: 35 },
|
||||
{ range: '36-45', count: 1285670, percentage: 30 },
|
||||
{ range: '46-55', count: 428567, percentage: 10 },
|
||||
{ range: '55+', count: 214284, percentage: 5 },
|
||||
]
|
||||
|
||||
const behaviorProfile = [
|
||||
{ aspect: '消费能力', value: 85 },
|
||||
{ aspect: '活跃度', value: 72 },
|
||||
{ aspect: '互动频率', value: 68 },
|
||||
{ aspect: '忠诚度', value: 78 },
|
||||
{ aspect: '推荐意愿', value: 62 },
|
||||
{ aspect: '复购率', value: 75 },
|
||||
]
|
||||
|
||||
const segments = [
|
||||
{
|
||||
name: '高净值群体',
|
||||
count: 1284567,
|
||||
characteristics: ['消费能力强', '忠诚度高', '品牌认知强'],
|
||||
value: 580,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: '年轻潮流族',
|
||||
count: 2856780,
|
||||
characteristics: ['追求时尚', '社交活跃', '冲动消费'],
|
||||
value: 280,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
name: '理性消费者',
|
||||
count: 4285670,
|
||||
characteristics: ['价格敏感', '重视品质', '对比研究'],
|
||||
value: 180,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
name: '价格敏感型',
|
||||
count: 1714283,
|
||||
characteristics: ['促销驱动', '低频复购', '优惠敏感'],
|
||||
value: 85,
|
||||
color: 'yellow'
|
||||
},
|
||||
]
|
||||
|
||||
const interestTags = [
|
||||
{ tag: '美妆护肤', count: 2856780, heat: 95 },
|
||||
{ tag: '时尚穿搭', count: 2145680, heat: 88 },
|
||||
{ tag: '健康养生', count: 1856780, heat: 76 },
|
||||
{ tag: '数码科技', count: 1428567, heat: 72 },
|
||||
{ tag: '母婴育儿', count: 1285670, heat: 68 },
|
||||
{ tag: '美食烹饪', count: 985670, heat: 58 },
|
||||
{ tag: '运动健身', count: 856780, heat: 52 },
|
||||
{ tag: '旅游出行', count: 714283, heat: 45 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">用户画像</h1>
|
||||
<p className="text-gray-600">智能分析用户群体特征与行为偏好</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新分析
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出画像
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">用户群体数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">128</div>
|
||||
<div className="text-sm text-gray-600">覆盖42.8亿用户</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">标签覆盖率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">87.5%</div>
|
||||
<Badge variant="outline" className="border-green-500 text-green-600 bg-green-50">优秀</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">画像完整度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">92.3%</div>
|
||||
<Badge variant="outline" className="border-blue-500 text-blue-600 bg-blue-50">优秀</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-600">更新频率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">实时</div>
|
||||
<div className="text-sm text-gray-600">每小时更新</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 年龄分布与行为画像 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
年龄分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={ageDistribution}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="range" stroke="#6b7280" />
|
||||
<YAxis stroke="#6b7280" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#8b5cf6" name="用户数" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
行为画像
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RadarChart data={behaviorProfile}>
|
||||
<PolarGrid stroke="#e5e7eb" />
|
||||
<PolarAngleAxis dataKey="aspect" stroke="#6b7280" />
|
||||
<PolarRadiusAxis stroke="#6b7280" />
|
||||
<Radar name="行为指数" dataKey="value" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.5} />
|
||||
<Tooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 用户群体细分 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
用户群体细分
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{segments.map((segment, index) => (
|
||||
<div key={index} className={`p-6 bg-gradient-to-br from-${segment.color}-50 to-${segment.color}-100 rounded-lg border border-${segment.color}-200`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">{segment.name}</h3>
|
||||
<Badge variant="outline" className={`border-${segment.color}-500 text-${segment.color}-600 bg-${segment.color}-50`}>
|
||||
{segment.count.toLocaleString()} 人
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
{segment.characteristics.map((char, i) => (
|
||||
<Badge key={i} variant="outline" className="mr-2 border-gray-300 text-gray-700">
|
||||
{char}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
人均价值: <span className="text-lg font-semibold text-gray-900">¥{segment.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 兴趣标签热度 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900 flex items-center gap-2">
|
||||
<Tag className="w-5 h-5" />
|
||||
兴趣标签热度
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{interestTags.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-gray-700 font-medium">{item.tag}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{item.count.toLocaleString()} 人</span>
|
||||
<span className="text-sm font-semibold text-blue-600">热度 {item.heat}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||
style={{ width: `${item.heat}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地登录 API - 支持邮箱/手机号 + 密码
|
||||
* 当未配置 NEXT_PUBLIC_API_BASE_URL 时使用
|
||||
* 开发账号: zhiqun@qq.com / Zhiqun1984
|
||||
*/
|
||||
const MOCK_USERS: Record<string, { password: string }> = {
|
||||
"zhiqun@qq.com": { password: "Zhiqun1984" },
|
||||
}
|
||||
|
||||
function isEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const account = (formData.get("email") || formData.get("phone") || "").toString().trim()
|
||||
const password = (formData.get("password") || "").toString()
|
||||
const verificationCode = formData.get("verificationCode")?.toString()
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入邮箱或手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证码登录:开发环境下任意6位验证码通过
|
||||
if (verificationCode) {
|
||||
if (verificationCode.length >= 4) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ code: 40002, message: "验证码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
if (!password) {
|
||||
return NextResponse.json(
|
||||
{ code: 40003, message: "请输入密码" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = isEmail(account) ? account : account
|
||||
const user = MOCK_USERS[key]
|
||||
|
||||
if (user && user.password === password) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ code: 40004, message: "邮箱/手机号或密码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[auth/login]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地验证码发送 API (mock)
|
||||
* 开发环境下直接返回成功,验证码可为任意4位以上
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const phone = (formData.get("phone") || "").toString().trim()
|
||||
|
||||
if (!phone) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Mock: 模拟发送成功,开发时可用 123456 等作为验证码
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "验证码已发送(开发模式:可使用任意4位以上数字)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[auth/send-code]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* RFM 分析 API
|
||||
* 支持 MongoDB 真实数据 + 内存计算
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { analyzeUser, type AnalyzeInput } from "@/services/rfm-engine"
|
||||
import { analyzeUserRFM } from "@/services/rfm-mongodb-service"
|
||||
import { generateText } from "ai"
|
||||
import { openai } from "@ai-sdk/openai"
|
||||
|
||||
/**
|
||||
* 可选 AI 标签增强
|
||||
* 可选 AI 标签增强:
|
||||
* - 使用 AI SDK (generateText + openai("gpt-4o")),符合统一标准 [^1]
|
||||
* - 无 OPENAI_API_KEY 时自动跳过,保持稳定
|
||||
*/
|
||||
async function aiTagging(chat_logs?: string[]) {
|
||||
const text = (chat_logs ?? []).slice(0, 8).join("。")
|
||||
@@ -37,38 +33,13 @@ export async function POST(req: Request) {
|
||||
const body = await req.json()
|
||||
const inputs: AnalyzeInput[] = Array.isArray(body) ? body : [body]
|
||||
const useAI = (Array.isArray(body) ? (body as any).useAI : (body as any)?.useAI) ?? false
|
||||
const useMongoData = (body as any)?.useMongoData ?? true
|
||||
|
||||
const results = []
|
||||
|
||||
for (const input of inputs) {
|
||||
let base: any
|
||||
let source = 'memory'
|
||||
|
||||
// 尝试从 MongoDB 获取真实数据
|
||||
if (useMongoData && input.user_id) {
|
||||
// 如果 user_id 是手机号格式,尝试从 MongoDB 查询
|
||||
const phone = input.user_id.replace(/\D/g, '')
|
||||
if (/^1[3-9]\d{9}$/.test(phone)) {
|
||||
const mongoResult = await analyzeUserRFM(phone)
|
||||
if (mongoResult.found) {
|
||||
base = mongoResult.data
|
||||
source = 'mongodb'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 MongoDB 没有数据,使用内存计算
|
||||
if (!base) {
|
||||
base = analyzeUser(input)
|
||||
source = 'memory'
|
||||
}
|
||||
|
||||
// AI 标签增强
|
||||
const base = analyzeUser(input)
|
||||
if (useAI) {
|
||||
const ai = await aiTagging(input.chat_logs)
|
||||
if (ai) {
|
||||
base.tags = base.tags || {}
|
||||
base.tags.emotion = ai.emotion ?? base.tags.emotion
|
||||
base.tags.intent = ai.intent ?? base.tags.intent
|
||||
if (Array.isArray(ai.behavior)) {
|
||||
@@ -76,55 +47,10 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ ...base, source })
|
||||
results.push(base)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: results })
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('RFM analyze error:', e)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: e?.message || "Invalid input"
|
||||
}, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 方法:按手机号查询用户 RFM
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const phone = searchParams.get('phone')
|
||||
|
||||
if (!phone) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '请提供手机号参数'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await analyzeUserRFM(phone)
|
||||
|
||||
if (!result.found) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未找到该用户'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
source: 'mongodb'
|
||||
})
|
||||
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: e?.message || "查询失败"
|
||||
}, { status: 500 })
|
||||
return NextResponse.json({ success: false, error: e?.message || "Invalid input" }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,12 @@
|
||||
/**
|
||||
* RFM 标签获取 API
|
||||
* 对接 MongoDB 真实数据
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getRFMTagsDistribution } from "@/services/rfm-mongodb-service"
|
||||
import { getUserTags } from "@/services/rfm-engine"
|
||||
|
||||
/**
|
||||
* GET /api/rfm/get_tags
|
||||
* 获取标签分布或指定用户的标签
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const userId = searchParams.get('user_id')
|
||||
|
||||
// 如果指定用户,返回用户标签
|
||||
if (userId) {
|
||||
const userTags = getUserTags(userId)
|
||||
if (userTags) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: userTags.tags,
|
||||
source: 'memory'
|
||||
})
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未找到该用户标签'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
// 否则返回标签分布
|
||||
const distribution = await getRFMTagsDistribution()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: distribution,
|
||||
source: 'mongodb'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get tags error:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 })
|
||||
const url = new URL(req.url)
|
||||
const userId = url.searchParams.get("user_id")
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, error: "missing user_id" }, { status: 400 })
|
||||
}
|
||||
const data = getUserTags(userId)
|
||||
return NextResponse.json({ success: true, data })
|
||||
}
|
||||
|
||||
@@ -1,54 +1,7 @@
|
||||
/**
|
||||
* RFM 分组统计 API
|
||||
* 对接 MongoDB 真实数据
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getMongoRFMGroupSummary } from "@/services/rfm-mongodb-service"
|
||||
import { getGroupSummary } from "@/services/rfm-engine"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 尝试从 MongoDB 获取真实数据
|
||||
const mongoData = await getMongoRFMGroupSummary()
|
||||
|
||||
if (mongoData.totalUsers > 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
gradeCount: mongoData.gradeCount,
|
||||
valueCount: mongoData.valueCount,
|
||||
lifecycleCount: {}, // MongoDB 暂无此字段
|
||||
totalUsers: mongoData.totalUsers,
|
||||
avgScore: mongoData.avgScore
|
||||
},
|
||||
source: 'mongodb'
|
||||
})
|
||||
}
|
||||
|
||||
// 回退到内存数据
|
||||
const memData = getGroupSummary()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: memData,
|
||||
source: 'memory'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('RFM group summary error:', error)
|
||||
|
||||
// 返回默认数据
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
gradeCount: { S: 0, A: 0, B: 0, C: 0, D: 0 },
|
||||
valueCount: { '高': 0, '中': 0, '低': 0 },
|
||||
lifecycleCount: {},
|
||||
totalUsers: 0,
|
||||
avgScore: 0
|
||||
},
|
||||
source: 'fallback',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
const data = getGroupSummary()
|
||||
return NextResponse.json({ success: true, data })
|
||||
}
|
||||
|
||||
@@ -1,177 +1,52 @@
|
||||
/**
|
||||
* 智能搜索 API 路由
|
||||
* 对接神射手 MongoDB - 跨库查询
|
||||
*/
|
||||
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
intelligentSearch,
|
||||
queryFullProfile,
|
||||
queryPhoneByQQ,
|
||||
UserValuationDoc
|
||||
} from "@/lib/mongodb"
|
||||
import { getIntelligentSearchService } from "@/services/intelligent-search-service"
|
||||
|
||||
/**
|
||||
* 脱敏手机号
|
||||
*/
|
||||
function maskPhone(phone: string | undefined): string {
|
||||
if (!phone) return ''
|
||||
if (phone.length !== 11) return phone
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换搜索结果
|
||||
*/
|
||||
function transformSearchResult(doc: UserValuationDoc, queryType: string): any {
|
||||
const name = doc.name || '未知用户'
|
||||
return {
|
||||
id: doc._id?.toString(),
|
||||
type: 'user',
|
||||
title: name,
|
||||
subtitle: doc.phone_masked || maskPhone(doc.phone),
|
||||
description: `${doc.province || ''}${doc.city || ''} | ${doc.user_level || '未分级'} | RFM: ${doc.rfm_composite_score?.toFixed(2) || 'N/A'}`,
|
||||
data: {
|
||||
phone: doc.phone,
|
||||
phone_masked: doc.phone_masked || maskPhone(doc.phone),
|
||||
name: doc.name,
|
||||
province: doc.province,
|
||||
city: doc.city,
|
||||
userLevel: doc.user_level,
|
||||
rfmScore: doc.rfm_composite_score,
|
||||
tags: doc.tags || [],
|
||||
email: doc.email
|
||||
},
|
||||
matchedBy: queryType,
|
||||
relevanceScore: doc.rfm_composite_score || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search
|
||||
* 智能搜索
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get("q") || ""
|
||||
const type = (searchParams.get("type") as "user" | "traffic" | "all") || "all"
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "50")
|
||||
const offset = Number.parseInt(searchParams.get("offset") || "0")
|
||||
const useAI = searchParams.get("ai") === "true"
|
||||
const includeInsights = searchParams.get("insights") === "true"
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({
|
||||
error: "搜索查询不能为空"
|
||||
}, { status: 400 })
|
||||
return NextResponse.json({ error: "搜索查询不能为空" }, { status: 400 })
|
||||
}
|
||||
|
||||
// 执行智能搜索
|
||||
const result = await intelligentSearch(query, { limit, offset })
|
||||
|
||||
// 转换结果
|
||||
const items = result.users.map(doc => transformSearchResult(doc, result.queryType))
|
||||
|
||||
// 如果是 QQ 查询,补充 QQ 信息
|
||||
if (result.queryType === 'qq' && result.users.length > 0) {
|
||||
const qqInfo = await queryPhoneByQQ(query.trim())
|
||||
if (qqInfo) {
|
||||
items[0].data.qq = qqInfo.qq
|
||||
items[0].data.qqScore = qqInfo.QQ号评分
|
||||
items[0].data.carrier = qqInfo.运营商
|
||||
}
|
||||
}
|
||||
const searchService = getIntelligentSearchService()
|
||||
|
||||
return NextResponse.json({
|
||||
query,
|
||||
queryType: result.queryType,
|
||||
total: result.total,
|
||||
items,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + items.length < result.total
|
||||
}
|
||||
const results = await searchService.search(query, type, {
|
||||
limit,
|
||||
offset,
|
||||
useAI,
|
||||
includeInsights,
|
||||
filters: {},
|
||||
})
|
||||
|
||||
|
||||
return NextResponse.json(results)
|
||||
} catch (error) {
|
||||
console.error("搜索API错误:", error)
|
||||
return NextResponse.json({
|
||||
error: "搜索失败,请稍后重试",
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 })
|
||||
return NextResponse.json({ error: "搜索失败,请稍后重试" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/search
|
||||
* 高级搜索(支持更多参数)
|
||||
* 返回格式适配前端 SearchResponse 接口
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const body = await request.json()
|
||||
const { query, type = "all", options = {} } = body
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return NextResponse.json({
|
||||
error: "搜索查询不能为空"
|
||||
}, { status: 400 })
|
||||
return NextResponse.json({ error: "搜索查询不能为空" }, { status: 400 })
|
||||
}
|
||||
|
||||
const limit = options.limit || 50
|
||||
const offset = options.offset || 0
|
||||
|
||||
const result = await intelligentSearch(query, { limit, offset })
|
||||
|
||||
// 转换为前端期望的格式
|
||||
const results = result.users.map(doc => {
|
||||
const name = doc.name || '未知用户'
|
||||
return {
|
||||
id: doc._id?.toString() || '',
|
||||
type: 'user' as const,
|
||||
title: name,
|
||||
description: `${doc.province || ''}${doc.city || ''} | 估值: ${doc.user_evaluation_score || 'N/A'}`,
|
||||
tags: doc.tags || [],
|
||||
relevanceScore: doc.user_evaluation_score || 0,
|
||||
updatedAt: doc.computed_at?.toISOString() || new Date().toISOString(),
|
||||
metadata: {
|
||||
phone: doc.phone,
|
||||
phone_masked: doc.phone_masked || maskPhone(doc.phone),
|
||||
province: doc.province,
|
||||
city: doc.city,
|
||||
gender: doc.gender,
|
||||
age_range: doc.age_range,
|
||||
userLevel: doc.user_level,
|
||||
rfmScore: doc.rfm_composite_score,
|
||||
evaluationScore: doc.user_evaluation_score,
|
||||
dataQuality: doc.data_quality
|
||||
}
|
||||
}
|
||||
})
|
||||
const searchService = getIntelligentSearchService()
|
||||
const results = await searchService.search(query, type, options)
|
||||
|
||||
const queryTime = Date.now() - startTime
|
||||
|
||||
// 返回前端期望的 SearchResponse 格式
|
||||
return NextResponse.json({
|
||||
results,
|
||||
stats: {
|
||||
totalResults: result.total,
|
||||
queryTime,
|
||||
suggestions: [],
|
||||
filters: {
|
||||
queryType: result.queryType
|
||||
}
|
||||
},
|
||||
hasMore: offset + results.length < result.total
|
||||
})
|
||||
|
||||
return NextResponse.json(results)
|
||||
} catch (error) {
|
||||
console.error("搜索API错误:", error)
|
||||
return NextResponse.json({
|
||||
error: "搜索失败,请稍后重试",
|
||||
results: [],
|
||||
stats: { totalResults: 0, queryTime: 0, suggestions: [], filters: {} },
|
||||
hasMore: false
|
||||
}, { status: 500 })
|
||||
return NextResponse.json({ error: "搜索失败,请稍后重试" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,18 @@
|
||||
/**
|
||||
* 系统状态 API 路由
|
||||
* 返回 MongoDB 数据库真实状态
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import { getMindsDBConnector } from "@/lib/mindsdb-connector"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabaseStats, healthCheck } from "@/lib/mongodb"
|
||||
|
||||
/**
|
||||
* GET /api/system-status
|
||||
* 获取系统状态
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 健康检查
|
||||
const health = await healthCheck()
|
||||
|
||||
if (!health.mongodb) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
connected: false,
|
||||
latencyMs: health.latencyMs,
|
||||
error: health.error || 'MongoDB 连接失败',
|
||||
databases: [],
|
||||
totalDocuments: 0,
|
||||
totalSizeGB: 0,
|
||||
lastCheck: new Date().toISOString()
|
||||
}, { status: 503 })
|
||||
}
|
||||
|
||||
// 获取数据库统计
|
||||
const stats = await getDatabaseStats()
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
connected: stats.connected,
|
||||
latencyMs: health.latencyMs,
|
||||
databases: stats.databases,
|
||||
totalDocuments: stats.totalDocuments,
|
||||
totalSizeGB: stats.totalSizeGB,
|
||||
lastCheck: new Date().toISOString(),
|
||||
// 格式化显示
|
||||
summary: {
|
||||
userCount: formatNumber(stats.totalDocuments),
|
||||
dataSize: `${stats.totalSizeGB} GB`,
|
||||
dbCount: stats.databases.length,
|
||||
responseTime: `${health.latencyMs}ms`
|
||||
}
|
||||
}, {
|
||||
headers: { 'Cache-Control': 'no-store, max-age=0' }
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('System status error:', error)
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
connected: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
lastCheck: new Date().toISOString()
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
const mindsDB = getMindsDBConnector()
|
||||
const status = await mindsDB.getSystemStatus()
|
||||
|
||||
/**
|
||||
* 格式化数字显示
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000000) {
|
||||
return `${(num / 1000000000).toFixed(2)}B`
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("系统状态API错误:", error)
|
||||
return NextResponse.json({ error: "获取系统状态失败" }, { status: 500 })
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
@@ -1,186 +1,233 @@
|
||||
/**
|
||||
* 用户 API 路由
|
||||
* 对接神射手 MongoDB 数据库 - KR.用户估值
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server"
|
||||
import type { TrafficUser } from "@/types/traffic"
|
||||
import { addUser, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
|
||||
|
||||
import { NextResponse, NextRequest } from "next/server"
|
||||
import {
|
||||
queryUserList,
|
||||
queryUserByPhone,
|
||||
queryFullProfile,
|
||||
UserValuationDoc
|
||||
} from "@/lib/mongodb"
|
||||
// 中文名字生成器数据
|
||||
const familyNames = [
|
||||
"张",
|
||||
"王",
|
||||
"李",
|
||||
"赵",
|
||||
"陈",
|
||||
"刘",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"朱",
|
||||
"孙",
|
||||
"马",
|
||||
"胡",
|
||||
"郭",
|
||||
"林",
|
||||
"何",
|
||||
"高",
|
||||
"梁",
|
||||
"郑",
|
||||
"罗",
|
||||
"宋",
|
||||
"谢",
|
||||
"唐",
|
||||
"韩",
|
||||
"曹",
|
||||
"许",
|
||||
"邓",
|
||||
"萧",
|
||||
"冯",
|
||||
]
|
||||
const givenNames1 = [
|
||||
"志",
|
||||
"建",
|
||||
"文",
|
||||
"明",
|
||||
"永",
|
||||
"春",
|
||||
"秀",
|
||||
"金",
|
||||
"水",
|
||||
"玉",
|
||||
"国",
|
||||
"立",
|
||||
"德",
|
||||
"海",
|
||||
"和",
|
||||
"荣",
|
||||
"伟",
|
||||
"新",
|
||||
"英",
|
||||
"佳",
|
||||
]
|
||||
const givenNames2 = [
|
||||
"华",
|
||||
"平",
|
||||
"军",
|
||||
"强",
|
||||
"辉",
|
||||
"敏",
|
||||
"峰",
|
||||
"磊",
|
||||
"超",
|
||||
"艳",
|
||||
"娜",
|
||||
"霞",
|
||||
"燕",
|
||||
"娟",
|
||||
"静",
|
||||
"丽",
|
||||
"涛",
|
||||
"洋",
|
||||
"勇",
|
||||
"龙",
|
||||
]
|
||||
|
||||
/**
|
||||
* 脱敏手机号
|
||||
*/
|
||||
function maskPhone(phone: string | undefined): string {
|
||||
if (!phone) return ''
|
||||
if (phone.length !== 11) return phone
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
// 生成固定的用户数据池
|
||||
const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去7天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 7))
|
||||
|
||||
/**
|
||||
* 转换用户数据格式(适配前端)
|
||||
*/
|
||||
function transformUser(doc: UserValuationDoc, index: number = 0): any {
|
||||
const name = doc.name || '未知用户'
|
||||
return {
|
||||
id: doc._id?.toString() || `user-${index}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${name[0] || 'U'}`,
|
||||
nickname: name,
|
||||
wechatId: doc.phone ? `wxid_${doc.phone.slice(-8)}` : '',
|
||||
phone: doc.phone || '',
|
||||
phone_masked: doc.phone_masked || maskPhone(doc.phone),
|
||||
region: doc.province && doc.city ? `${doc.province}${doc.city}` : (doc.province || '未知'),
|
||||
note: '',
|
||||
status: 'added' as const,
|
||||
addTime: doc.created_at?.toISOString() || new Date().toISOString(),
|
||||
source: (doc.source_channels && doc.source_channels[0]) || '神射手',
|
||||
assignedTo: '',
|
||||
category: 'customer' as const,
|
||||
tags: doc.tags || [],
|
||||
// RFM 数据
|
||||
userLevel: doc.user_level || 'D',
|
||||
rfmScore: doc.rfm_composite_score || 0,
|
||||
rfmR: doc.rfm_r_score,
|
||||
rfmF: doc.rfm_f_score,
|
||||
rfmM: doc.rfm_m_score,
|
||||
totalAmount: doc.total_amount || 0,
|
||||
orderCount: doc.order_count || 0,
|
||||
// 额外信息
|
||||
email: doc.email,
|
||||
address: doc.address,
|
||||
province: doc.province,
|
||||
city: doc.city,
|
||||
id: `${Date.now()}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/users
|
||||
* 查询用户列表或单个用户详情
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
// 单用户详情查询(按ID或手机号)
|
||||
const id = searchParams.get('id')
|
||||
const phone = searchParams.get('phone')
|
||||
|
||||
if (id || phone) {
|
||||
// 如果是手机号格式,按手机号查询
|
||||
const queryPhone = phone || (id && /^1[3-9]\d{9}$/.test(id) ? id : null)
|
||||
|
||||
if (queryPhone) {
|
||||
// 完整画像查询(跨库)
|
||||
const profile = await queryFullProfile(queryPhone)
|
||||
|
||||
if (profile.valuation) {
|
||||
const user = transformUser(profile.valuation)
|
||||
|
||||
// 补充 QQ 信息
|
||||
if (profile.qq) {
|
||||
user.qq = profile.qq.qq
|
||||
user.qqScore = profile.qq.QQ号评分
|
||||
user.phoneScore = profile.qq.手机号评分
|
||||
}
|
||||
|
||||
// 补充存客宝信息
|
||||
if (profile.ckb) {
|
||||
user.wechat = profile.ckb.social_accounts?.wechat
|
||||
user.trafficPool = profile.ckb.traffic_pool?.pool_name
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: user,
|
||||
sources: {
|
||||
valuation: !!profile.valuation,
|
||||
qq: !!profile.qq,
|
||||
ckb: !!profile.ckb
|
||||
}
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: null,
|
||||
error: '未找到该用户'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: null,
|
||||
error: '无效的查询参数'
|
||||
}, { status: 400 })
|
||||
// 计算今日新增数量
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
|
||||
|
||||
// 生成微信好友数据池
|
||||
const generateWechatFriends = (wechatId: string, count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去30天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 30))
|
||||
|
||||
return {
|
||||
id: `wechat-${wechatId}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
|
||||
Math.floor(Math.random() * 7)
|
||||
],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
const q = searchParams.get('q') || undefined
|
||||
const tagsStr = searchParams.get('tags') || ''
|
||||
const userLevel = searchParams.get('userLevel') || searchParams.get('status') || undefined
|
||||
const rfmMin = searchParams.get('rfmMin') ? Number(searchParams.get('rfmMin')) : undefined
|
||||
const rfmMax = searchParams.get('rfmMax') ? Number(searchParams.get('rfmMax')) : undefined
|
||||
const page = Number(searchParams.get('page') ?? 1)
|
||||
const pageSize = Number(searchParams.get('pageSize') ?? 20)
|
||||
|
||||
const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
|
||||
|
||||
const result = await queryUserList({
|
||||
page,
|
||||
pageSize,
|
||||
userLevel,
|
||||
minRfm: rfmMin,
|
||||
maxRfm: rfmMax,
|
||||
search: q,
|
||||
tags
|
||||
})
|
||||
|
||||
const transformedData = result.data.map((doc, i) => transformUser(doc, i))
|
||||
|
||||
return NextResponse.json({
|
||||
data: transformedData,
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(result.total / pageSize)
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Users API error:', error)
|
||||
|
||||
// 数据库连接失败时返回模拟数据
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 0,
|
||||
error: error instanceof Error ? error.message : '查询失败',
|
||||
fallback: true
|
||||
}, {
|
||||
status: 500,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* 创建用户(预留接口)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
|
||||
// TODO: 实现用户创建逻辑
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户创建功能暂未开放'
|
||||
}, { status: 501 })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '创建失败'
|
||||
}, { status: 500 })
|
||||
}
|
||||
// 微信好友数据缓存
|
||||
const wechatFriendsCache = new Map<string, TrafficUser[]>()
|
||||
|
||||
function parseArrayParam(v: string | null) {
|
||||
if (!v) return []
|
||||
return v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
// 详情优先
|
||||
const id = searchParams.get("id")
|
||||
if (id) {
|
||||
const detail = getUserById(id)
|
||||
return NextResponse.json({ data: detail }, { headers: { "Cache-Control": "no-store" } })
|
||||
}
|
||||
|
||||
// 列表
|
||||
const q = searchParams.get("q") ?? undefined
|
||||
const tagsStr = searchParams.get("tags") ?? ""
|
||||
const statusStr = searchParams.get("status") ?? ""
|
||||
const rfmMin = Number(searchParams.get("rfmMin") ?? 0)
|
||||
const rfmMax = Number(searchParams.get("rfmMax") ?? 100)
|
||||
const page = Number(searchParams.get("page") ?? 1)
|
||||
const pageSize = Number(searchParams.get("pageSize") ?? 20)
|
||||
|
||||
const tags = tagsStr ? tagsStr.split(",").filter(Boolean) : undefined
|
||||
const status = statusStr ? (statusStr.split(",").filter(Boolean) as UserStatus[]) : undefined
|
||||
|
||||
const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
|
||||
return NextResponse.json(result, { headers: { "Cache-Control": "no-store" } })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const created = addUser(body ?? {})
|
||||
return NextResponse.json({ data: created }, { status: 201 })
|
||||
}
|
||||
|
||||
159
app/globals.css
@@ -4,14 +4,14 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 98%;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 240 10% 100%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 240 10% 100%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 240 5.9% 98%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
@@ -23,26 +23,26 @@
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.75rem;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 240 5.9% 98%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 240 5.9% 98%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 240 5.9% 98%;
|
||||
--primary: 240 5.9% 98%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 240 5.9% 98%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 240 5.9% 98%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 240 5.9% 98%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
@@ -53,140 +53,7 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gradient-to-br from-blue-50 via-white to-purple-50 text-foreground min-h-screen;
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
background-attachment: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply backdrop-blur-md border border-white/20;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
}
|
||||
|
||||
.glass-light {
|
||||
@apply backdrop-blur-sm border border-white/30;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 16px 0 rgba(31, 38, 135, 0.2);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
@apply backdrop-blur-xl border border-white/40;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 16px 64px 0 rgba(31, 38, 135, 0.5);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply backdrop-blur-md border border-white/10;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass {
|
||||
@apply backdrop-blur-sm;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-light {
|
||||
@apply backdrop-blur-sm;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
@apply backdrop-blur-md;
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl p-6 transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass-card {
|
||||
@apply p-4 rounded-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-nav {
|
||||
@apply glass-light rounded-2xl transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass-button {
|
||||
@apply px-6 py-3 text-base;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
@apply ring-2 ring-white/50;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.glass-input {
|
||||
@apply px-4 py-3 text-base;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overflow-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import ClientLayout from "./ClientLayout"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
@@ -18,6 +19,3 @@ export default function RootLayout({
|
||||
}) {
|
||||
return <ClientLayout>{children}</ClientLayout>
|
||||
}
|
||||
|
||||
|
||||
import './globals.css'
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Eye, EyeOff, Mail, Phone } from "lucide-react"
|
||||
import { Eye, EyeOff, Phone } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -12,8 +12,8 @@ import { WeChatIcon } from "@/components/icons/wechat-icon"
|
||||
import { AppleIcon } from "@/components/icons/apple-icon"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
// 使用环境变量:不配置则用本地 API(支持 zhiqun@qq.com / Zhiqun1984)
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""
|
||||
// 使用环境变量获取API域名
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
|
||||
|
||||
// 定义登录响应类型
|
||||
interface LoginResponse {
|
||||
@@ -25,7 +25,7 @@ interface LoginResponse {
|
||||
}
|
||||
|
||||
interface LoginForm {
|
||||
account: string // 邮箱或手机号
|
||||
phone: string
|
||||
password: string
|
||||
verificationCode: string
|
||||
agreeToTerms: boolean
|
||||
@@ -36,7 +36,7 @@ export default function LoginPage() {
|
||||
const [activeTab, setActiveTab] = useState<"password" | "verification">("password")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
account: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
verificationCode: "",
|
||||
agreeToTerms: false,
|
||||
@@ -55,11 +55,11 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.account.trim()) {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入账号",
|
||||
description: "请输入邮箱或手机号",
|
||||
title: "请输入手机号",
|
||||
description: "手机号不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -101,9 +101,9 @@ export default function LoginPage() {
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
const isEmail = form.account.includes("@")
|
||||
formData.append(isEmail ? "email" : "phone", form.account)
|
||||
formData.append("phone", form.phone)
|
||||
|
||||
if (activeTab === "password") {
|
||||
formData.append("password", form.password)
|
||||
@@ -111,8 +111,8 @@ export default function LoginPage() {
|
||||
formData.append("verificationCode", form.verificationCode)
|
||||
}
|
||||
|
||||
const apiUrl = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/api/auth/login"
|
||||
const response = await fetch(apiUrl, {
|
||||
// 发送登录请求
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
// 不需要设置Content-Type,浏览器会自动设置为multipart/form-data并添加boundary
|
||||
@@ -125,7 +125,7 @@ export default function LoginPage() {
|
||||
localStorage.setItem("token", result.data.token)
|
||||
|
||||
// 成功后跳转
|
||||
router.push("/")
|
||||
router.push("/profile")
|
||||
|
||||
toast({
|
||||
title: "登录成功",
|
||||
@@ -146,7 +146,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!form.account.trim()) {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入手机号",
|
||||
@@ -157,11 +157,12 @@ export default function LoginPage() {
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append("phone", form.account)
|
||||
formData.append("phone", form.phone)
|
||||
|
||||
const apiUrl = API_BASE_URL ? `${API_BASE_URL}/auth/send-code` : "/api/auth/send-code"
|
||||
const response = await fetch(apiUrl, {
|
||||
// 发送验证码请求
|
||||
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
@@ -219,25 +220,22 @@ export default function LoginPage() {
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-600 mb-6">支持 邮箱 / 手机号 / 微信 / Apple 登录</p>
|
||||
<p className="text-gray-600 mb-6">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
name="account"
|
||||
value={form.account}
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="邮箱或手机号"
|
||||
className="pl-12 border-gray-300 text-gray-900 h-12"
|
||||
placeholder="手机号"
|
||||
className="pl-16 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{form.account.includes("@") ? (
|
||||
<Mail className="h-4 w-4" />
|
||||
) : (
|
||||
<Phone className="h-4 w-4" />
|
||||
)}
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1">
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
603
app/page.tsx
@@ -1,373 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Users, Database, Activity, DollarSign, TrendingUp, Search, ArrowUpRight, Zap } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import UserSearch from '@/components/home/user-search'
|
||||
import UserList from '@/components/home/user-list'
|
||||
import Link from "next/link"
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts"
|
||||
|
||||
interface SystemStats {
|
||||
userCount: number
|
||||
keywordCount: number
|
||||
versionCount: number
|
||||
avgResponseTime: number
|
||||
cacheSize: number
|
||||
connected: boolean
|
||||
}
|
||||
// 模拟数据
|
||||
const growthData = [
|
||||
{ month: "1月", users: 3200, value: 420 },
|
||||
{ month: "2月", users: 3800, value: 480 },
|
||||
{ month: "3月", users: 4100, value: 520 },
|
||||
{ month: "4月", users: 4600, value: 580 },
|
||||
{ month: "5月", users: 5200, value: 650 },
|
||||
{ month: "6月", users: 5800, value: 720 },
|
||||
]
|
||||
|
||||
interface GrowthData {
|
||||
period: string
|
||||
userGrowth: number
|
||||
dataGrowth: number
|
||||
activeUsers: number
|
||||
}
|
||||
const valueDistribution = [
|
||||
{ name: "S级", value: 5, color: "#8B5CF6" },
|
||||
{ name: "A级", value: 15, color: "#3B82F6" },
|
||||
{ name: "B级", value: 35, color: "#10B981" },
|
||||
{ name: "C级", value: 30, color: "#F59E0B" },
|
||||
{ name: "D级", value: 15, color: "#EF4444" },
|
||||
]
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">{icon}<span>{label}</span></div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const recentActivities = [
|
||||
{ id: 1, user: "张伟", action: "完成首次购买", time: "2分钟前", type: "purchase" },
|
||||
{ id: 2, user: "李娜", action: "升级为A级用户", time: "5分钟前", type: "upgrade" },
|
||||
{ id: 3, user: "王芳", action: "触发流失预警", time: "8分钟前", type: "warning" },
|
||||
{ id: 4, user: "刘洋", action: "完成问卷调查", time: "12分钟前", type: "survey" },
|
||||
{ id: 5, user: "陈明", action: "加入VIP计划", time: "15分钟前", type: "vip" },
|
||||
]
|
||||
|
||||
export default function OverviewPage() {
|
||||
const router = useRouter()
|
||||
export default function HomePage() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [systemStats, setSystemStats] = useState<SystemStats>({
|
||||
userCount: 4000000000,
|
||||
keywordCount: 150000,
|
||||
versionCount: 25,
|
||||
avgResponseTime: 120,
|
||||
cacheSize: 0,
|
||||
connected: true,
|
||||
})
|
||||
const [growthData, setGrowthData] = useState<GrowthData[]>([
|
||||
{ period: "今日", userGrowth: 2.3, dataGrowth: 1.8, activeUsers: 85600000 },
|
||||
{ period: "本周", userGrowth: 12.5, dataGrowth: 8.9, activeUsers: 520000000 },
|
||||
{ period: "本月", userGrowth: 45.2, dataGrowth: 32.1, activeUsers: 1200000000 },
|
||||
])
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date())
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
// 自动刷新数据
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
refreshData()
|
||||
}, 30000) // 30秒刷新一次
|
||||
|
||||
return () => clearInterval(interval)
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
// 模拟数据更新
|
||||
setSystemStats((prev) => ({
|
||||
...prev,
|
||||
userCount: prev.userCount + Math.floor(Math.random() * 1000),
|
||||
avgResponseTime: Math.floor(Math.random() * 50) + 100,
|
||||
}))
|
||||
|
||||
setGrowthData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
userGrowth: item.userGrowth + (Math.random() - 0.5) * 0.5,
|
||||
dataGrowth: item.dataGrowth + (Math.random() - 0.5) * 0.3,
|
||||
activeUsers: Math.floor(item.activeUsers * (1 + (Math.random() - 0.5) * 0.01)),
|
||||
})),
|
||||
)
|
||||
|
||||
setLastUpdate(new Date())
|
||||
} catch (error) {
|
||||
console.error("刷新数据失败:", error)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/intelligent-search?q=${encodeURIComponent(searchQuery)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000000) {
|
||||
return `${(num / 1000000000).toFixed(1)}B`
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题和搜索 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">概览</h1>
|
||||
<p className="text-gray-600">数据资产中台</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-green-600 border-green-200">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
系统正常
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative max-w-2xl">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8">
|
||||
{/* 顶部标题区 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">数据资产中台</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{currentTime.toLocaleDateString("zh-CN", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}
|
||||
{" · "}
|
||||
{currentTime.toLocaleTimeString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户或流量关键词..."
|
||||
placeholder="搜索用户、手机号、标签..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="pl-12 pr-24 h-14 text-lg border-2 border-gray-200 focus:border-blue-500 rounded-xl shadow-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-lg"
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<Button>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
快速分析
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 核心数据展示 - 40亿用户为中心 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* 用户总数 - 主要指标 */}
|
||||
<Card className="lg:col-span-2 border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-3 text-2xl">
|
||||
<div className="p-3 bg-blue-500 rounded-xl">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
用户资产总量
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold text-blue-600 mb-2">{formatNumber(systemStats.userCount)}</div>
|
||||
<div className="text-lg text-gray-600">总用户数</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
||||
{growthData.map((data, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">+{data.userGrowth.toFixed(1)}%</div>
|
||||
<div className="text-sm text-gray-500">{data.period}增长</div>
|
||||
<div className="text-xs text-gray-400 mt-1">活跃: {formatNumber(data.activeUsers)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 系统状态 */}
|
||||
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
实时状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">响应时间</span>
|
||||
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">数据源</span>
|
||||
<Badge variant="secondary">{systemStats.versionCount}个</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">缓存大小</span>
|
||||
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 pt-2 border-t">最后更新: {lastUpdate.toLocaleTimeString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据增长趋势 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
用户资产增长
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{growthData.map((data, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">{data.period}</div>
|
||||
<div className="text-sm text-gray-500">活跃用户: {formatNumber(data.activeUsers)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">增长率</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-yellow-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500 rounded-lg">
|
||||
<Database className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
数据增长情况
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">流量关键词</div>
|
||||
<div className="text-sm text-gray-500">总计 {formatNumber(systemStats.keywordCount)} 个</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">+{growthData[0].dataGrowth.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">今日增长</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">数据处理量</div>
|
||||
<div className="text-sm text-gray-500">实时处理中</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">条/秒</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">存储容量</div>
|
||||
<div className="text-sm text-gray-500">云端存储</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{Math.floor(systemStats.userCount / 1000000)}TB
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">已使用</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 快速访问入口 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-blue-100 hover:border-blue-300"
|
||||
onClick={() => router.push("/data-platform")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-blue-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">数据中台</h3>
|
||||
<p className="text-sm text-gray-600">数据源管理与AI模型</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-green-100 hover:border-green-300"
|
||||
onClick={() => router.push("/user-portrait")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-green-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Users className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">用户画像</h3>
|
||||
<p className="text-sm text-gray-600">用户管理与标签体系</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-purple-100 hover:border-purple-300"
|
||||
onClick={() => router.push("/ai-assistant")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-purple-100 rounded-full w-fit mx-auto mb-3">
|
||||
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">AI智能助手</h3>
|
||||
<p className="text-sm text-gray-600">数据分析与报告生成</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-orange-100 hover:border-orange-300"
|
||||
onClick={() => router.push("/intelligent-search")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-orange-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Globe className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">智能搜索</h3>
|
||||
<p className="text-sm text-gray-600">全局搜索与AI分析</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 快速指标示例(可后续接入真实数据) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-8">
|
||||
<StatCard icon={<Users className="h-4 w-4" />} label="用户总量" value="~120+" />
|
||||
<StatCard icon={<Activity className="h-4 w-4" />} label="近7日活跃" value="动态计算" />
|
||||
<StatCard icon={<BarChart3 className="h-4 w-4" />} label="平均RFM" value="50-80" />
|
||||
<StatCard icon={<Users className="h-4 w-4" />} label="新客占比" value="≈25%" />
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 条件过滤 + 列表 */}
|
||||
<UserSearch query={searchQuery} />
|
||||
</div>
|
||||
<Toaster />
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-200 dark:border-violet-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">用户资产总量</CardTitle>
|
||||
<Users className="h-4 w-4 text-violet-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">40.5亿</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
<span className="text-green-500">+20.1%</span>
|
||||
<span className="ml-1">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-blue-500/10 to-cyan-500/10 border-blue-200 dark:border-blue-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">活跃用户</CardTitle>
|
||||
<Activity className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">2,350万</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
<span className="text-green-500">+18.2%</span>
|
||||
<span className="ml-1">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-emerald-500/10 to-green-500/10 border-emerald-200 dark:border-emerald-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">数据处理量</CardTitle>
|
||||
<Database className="h-4 w-4 text-emerald-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">125亿条</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
<span className="text-green-500">+32.5%</span>
|
||||
<span className="ml-1">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-200 dark:border-amber-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">资产总价值</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-amber-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">¥125.8亿</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
<span className="text-green-500">+45.2%</span>
|
||||
<span className="ml-1">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<div className="grid gap-4 md:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>用户增长趋势</CardTitle>
|
||||
<CardDescription>近6个月用户与资产价值变化</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={growthData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="month" className="text-xs" />
|
||||
<YAxis className="text-xs" />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="users" stroke="#8B5CF6" strokeWidth={2} name="用户(万)" />
|
||||
<Line type="monotone" dataKey="value" stroke="#3B82F6" strokeWidth={2} name="价值(亿)" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>用户价值分布</CardTitle>
|
||||
<CardDescription>S/A/B/C/D级用户占比</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={valueDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}%`}
|
||||
>
|
||||
{valueDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{valueDistribution.map((item) => (
|
||||
<Badge key={item.name} variant="outline" style={{ borderColor: item.color, color: item.color }}>
|
||||
{item.name}: {item.value}%
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>实时动态</CardTitle>
|
||||
<CardDescription>用户行为实时监控</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentActivities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-sm font-medium">
|
||||
{activity.user.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{activity.user}</p>
|
||||
<p className="text-xs text-muted-foreground">{activity.action}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{activity.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>快速入口</CardTitle>
|
||||
<CardDescription>常用功能快速访问</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link href="/data-platform">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-4 bg-transparent">
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">数据中台</div>
|
||||
<div className="text-xs text-muted-foreground">数据源管理</div>
|
||||
</div>
|
||||
<ArrowUpRight className="ml-auto h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/user-portrait">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-4 bg-transparent">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">用户画像</div>
|
||||
<div className="text-xs text-muted-foreground">用户分析</div>
|
||||
</div>
|
||||
<ArrowUpRight className="ml-auto h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/value-assessment">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-4 bg-transparent">
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">价值评估</div>
|
||||
<div className="text-xs text-muted-foreground">RFM模型</div>
|
||||
</div>
|
||||
<ArrowUpRight className="ml-auto h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/ai-assistant">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-4 bg-transparent">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">AI助手</div>
|
||||
<div className="text-xs text-muted-foreground">智能分析</div>
|
||||
</div>
|
||||
<ArrowUpRight className="ml-auto h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,239 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Search, Filter, Plus } from 'lucide-react'
|
||||
import FilterDrawer, { type FilterValues } from "@/components/user-portrait/filter-drawer"
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActivity: string
|
||||
status: "活跃" | "沉睡" | "已封禁"
|
||||
}
|
||||
|
||||
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
|
||||
|
||||
export default function UserPortraitPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Users, Search, Tag, TrendingUp, Filter, UserCircle, Mail, Phone, MapPin, Calendar, Activity, Target } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
export default function UserPortrait() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isAddingUser, setIsAddingUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] })
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [allTags, setAllTags] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState<FilterValues>({ tags: [], status: [], rfm: [0, 100] })
|
||||
// 用户群体分布
|
||||
const userSegments = [
|
||||
{ name: "高价值用户", count: 128456700, percentage: 3.0, avgValue: 580, color: "purple" },
|
||||
{ name: "活跃用户", count: 1285670000, percentage: 30.0, avgValue: 280, color: "blue" },
|
||||
{ name: "沉默用户", count: 1714283500, percentage: 40.0, avgValue: 85, color: "yellow" },
|
||||
{ name: "流失预警", count: 857134000, percentage: 20.0, avgValue: 45, color: "orange" },
|
||||
{ name: "新增用户", count: 300125800, percentage: 7.0, avgValue: 120, color: "green" },
|
||||
]
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (searchQuery) p.set("q", searchQuery)
|
||||
if (filters.tags.length) p.set("tags", filters.tags.join(","))
|
||||
if (filters.status.length) p.set("status", filters.status.join(","))
|
||||
p.set("rfmMin", String(filters.rfm[0]))
|
||||
p.set("rfmMax", String(filters.rfm[1]))
|
||||
p.set("page", "1")
|
||||
p.set("pageSize", "50")
|
||||
return p.toString()
|
||||
}, [searchQuery, filters])
|
||||
// 标签分类统计
|
||||
const tagCategories = [
|
||||
{ name: "基础属性", count: 485, usage: 4285670000, coverage: 100 },
|
||||
{ name: "行为标签", count: 328, usage: 3256780000, coverage: 76 },
|
||||
{ name: "兴趣偏好", count: 256, usage: 2570000000, coverage: 60 },
|
||||
{ name: "消费能力", count: 89, usage: 1714283500, coverage: 40 },
|
||||
{ name: "自定义标签", count: 100, usage: 857134000, coverage: 20 },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [queryString])
|
||||
// 最近用户示例
|
||||
const recentUsers = [
|
||||
{
|
||||
id: "U001285",
|
||||
name: "张**",
|
||||
phone: "138****5678",
|
||||
level: "S",
|
||||
tags: ["高净值", "美妆爱好者", "VIP客户"],
|
||||
value: 5800,
|
||||
lastActive: "2小时前",
|
||||
},
|
||||
{
|
||||
id: "U002456",
|
||||
name: "李**",
|
||||
phone: "186****1234",
|
||||
level: "A",
|
||||
tags: ["活跃用户", "教育培训", "复购客户"],
|
||||
value: 3200,
|
||||
lastActive: "5小时前",
|
||||
},
|
||||
{
|
||||
id: "U003789",
|
||||
name: "王**",
|
||||
phone: "159****9876",
|
||||
level: "B",
|
||||
tags: ["潜力用户", "电商购物", "新客户"],
|
||||
value: 1800,
|
||||
lastActive: "1天前",
|
||||
},
|
||||
{
|
||||
id: "U004123",
|
||||
name: "赵**",
|
||||
phone: "177****5432",
|
||||
level: "C",
|
||||
tags: ["普通用户", "低频互动"],
|
||||
value: 850,
|
||||
lastActive: "3天前",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
const resp = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newUser),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (data?.success) {
|
||||
setIsAddingUser(false)
|
||||
setNewUser({ name: "", phone: "", email: "", tags: [] })
|
||||
// 触发刷新
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors = {
|
||||
S: "border-purple-500/50 text-purple-400 bg-purple-500/10",
|
||||
A: "border-blue-500/50 text-blue-400 bg-blue-500/10",
|
||||
B: "border-green-500/50 text-green-400 bg-green-500/10",
|
||||
C: "border-yellow-500/50 text-yellow-400 bg-yellow-500/10",
|
||||
D: "border-gray-500/50 text-gray-400 bg-gray-500/10",
|
||||
}
|
||||
return colors[level as keyof typeof colors] || colors.D
|
||||
}
|
||||
|
||||
const getSegmentColor = (color: string) => {
|
||||
const colors = {
|
||||
purple: "from-purple-500/20 to-purple-900/20 border-purple-500/30",
|
||||
blue: "from-blue-500/20 to-blue-900/20 border-blue-500/30",
|
||||
green: "from-green-500/20 to-green-900/20 border-green-500/30",
|
||||
yellow: "from-yellow-500/20 to-yellow-900/20 border-yellow-500/30",
|
||||
orange: "from-orange-500/20 to-orange-900/20 border-orange-500/30",
|
||||
}
|
||||
return colors[color as keyof typeof colors]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
|
||||
<div className="min-h-screen bg-black text-white p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">用户画像</h1>
|
||||
<p className="text-gray-400">用户管理、标签体系与群体分析</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" className="bg-white/5 border-white/10 hover:bg-white/10">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
高级筛选
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-purple-500">
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
创建用户群
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
<div className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">用户画像</h1>
|
||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">共 {total} 人</div>
|
||||
<Tabs defaultValue="segments" className="space-y-6">
|
||||
<TabsList className="bg-white/5 border border-white/10">
|
||||
<TabsTrigger value="segments">用户群体</TabsTrigger>
|
||||
<TabsTrigger value="tags">标签体系</TabsTrigger>
|
||||
<TabsTrigger value="users">用户列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 用户群体分布 */}
|
||||
<TabsContent value="segments" className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{userSegments.map((segment, index) => (
|
||||
<Card key={index} className={`bg-gradient-to-br ${getSegmentColor(segment.color)}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-lg">{segment.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-white">{formatNumber(segment.count)}</div>
|
||||
<div className="text-sm text-gray-300">占比 {segment.percentage}%</div>
|
||||
<div className="pt-2 border-t border-white/20">
|
||||
<div className="text-xs text-gray-300 mb-1">人均价值</div>
|
||||
<div className="text-lg font-semibold text-white">¥{segment.avgValue}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-white">用户管理</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-white">标签管理</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 用户增长趋势 */}
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
用户增长趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 flex items-center justify-center text-gray-400">
|
||||
用户增长趋势图表区域
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input className="pl-8" placeholder="搜索用户…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
{/* 标签体系 */}
|
||||
<TabsContent value="tags" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{tagCategories.map((category, index) => (
|
||||
<Card key={index} className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<Tag className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{category.name}</h3>
|
||||
<p className="text-sm text-gray-400">{category.count} 个标签</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-blue-500/50 text-blue-400">
|
||||
覆盖率 {category.coverage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setFilterOpen(true)}>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddingUser(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">标签数量</div>
|
||||
<div className="text-xl font-bold text-white">{category.count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">使用用户</div>
|
||||
<div className="text-xl font-bold text-white">{formatNumber(category.usage)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">覆盖率</div>
|
||||
<div className="text-xl font-bold text-green-400">{category.coverage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="border bg-white/70 backdrop-blur-md shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-3 items-center">
|
||||
<div className="col-span-5">
|
||||
<Link href={`/user-portrait/${u.id}`} className="font-medium hover:underline">
|
||||
{u.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后活跃 {new Date(u.lastActivity).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
{/* 用户列表 */}
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索用户手机号、姓名、标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="bg-white/5 border-white/10 hover:bg-white/10">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 用户卡片列表 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{recentUsers.map((user) => (
|
||||
<Card key={user.id} className="bg-white/5 border-white/10 hover:bg-white/10 transition-colors cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-500">
|
||||
{user.name[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-lg font-semibold text-white">{user.name}</span>
|
||||
<Badge variant="outline" className={getLevelColor(user.level)}>
|
||||
{user.level}级
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
{user.phone}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="text-sm">{u.phone}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
{u.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{u.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-sm font-semibold">{u.rfmScore}</div>
|
||||
<div className="col-span-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
u.status === "活跃" ? "bg-green-100 text-green-700" :
|
||||
u.status === "沉睡" ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{user.lastActive}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">标签管理将在接入数据字典后提供配置与统计</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400 mb-1">用户价值</div>
|
||||
<div className="text-2xl font-bold text-green-400">¥{user.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{user.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="border-blue-500/30 text-blue-400">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
{/* 筛选抽屉 */}
|
||||
<FilterDrawer
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
allTags={allTags}
|
||||
value={filters}
|
||||
onApply={(v) => setFilters(v)}
|
||||
/>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={isAddingUser} onOpenChange={setIsAddingUser}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>添加新用户</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input id="name" value={newUser.name} onChange={(e) => setNewUser((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input id="phone" value={newUser.phone} onChange={(e) => setNewUser((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser((p) => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
{!!allTags.length && (
|
||||
<div className="space-y-2">
|
||||
<Label>用户标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={newUser.tags.includes(t)}
|
||||
onCheckedChange={(ck) =>
|
||||
setNewUser((p) => ({ ...p, tags: ck ? [...p.tags, t] : p.tags.filter((x) => x !== t) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
||||
<Button onClick={handleAddUser}>添加用户</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
322
app/value-assessment/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { DollarSign, TrendingUp, BarChart3, Calculator, Target, Users, ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
export default function ValueAssessment() {
|
||||
// 价值评估模型数据
|
||||
const valueModels = [
|
||||
{
|
||||
name: "RFM价值模型",
|
||||
description: "基于最近购买、购买频率、购买金额",
|
||||
totalValue: 8560000000,
|
||||
avgValue: 199.8,
|
||||
coverage: 92.5,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
name: "用户生命周期价值",
|
||||
description: "预测用户终身价值(LTV)",
|
||||
totalValue: 12580000000,
|
||||
avgValue: 293.5,
|
||||
coverage: 78.3,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
name: "行为价值模型",
|
||||
description: "基于用户行为数据评估",
|
||||
totalValue: 6780000000,
|
||||
avgValue: 158.2,
|
||||
coverage: 85.7,
|
||||
status: "training",
|
||||
},
|
||||
]
|
||||
|
||||
// 项目价值分布
|
||||
const projectValues = [
|
||||
{ name: "美妆行业项目A", users: 45600000, value: 2856000000, growth: 15.8, level: "A" },
|
||||
{ name: "教育培训项目B", users: 32800000, value: 1968000000, growth: 12.3, level: "A" },
|
||||
{ name: "电商零售项目C", users: 58900000, value: 3534000000, growth: 18.5, level: "S" },
|
||||
{ name: "金融服务项目D", users: 28500000, value: 1995000000, growth: 8.7, level: "B" },
|
||||
{ name: "本地生活项目E", users: 19200000, value: 1152000000, growth: 6.5, level: "B" },
|
||||
]
|
||||
|
||||
// 微信号价值排名
|
||||
const wechatRanking = [
|
||||
{ name: "微信号001", users: 2856000, value: 168560000, avgValue: 59.0, rank: 1 },
|
||||
{ name: "微信号002", users: 2345000, value: 145780000, avgValue: 62.2, rank: 2 },
|
||||
{ name: "微信号003", users: 1980000, value: 128640000, avgValue: 65.0, rank: 3 },
|
||||
{ name: "微信号004", users: 1756000, value: 112340000, avgValue: 64.0, rank: 4 },
|
||||
{ name: "微信号005", users: 1520000, value: 95600000, avgValue: 62.9, rank: 5 },
|
||||
]
|
||||
|
||||
// 价值增长趋势
|
||||
const growthTrend = [
|
||||
{ month: "1月", value: 9850000000, growth: 8.5 },
|
||||
{ month: "2月", value: 10250000000, growth: 4.1 },
|
||||
{ month: "3月", value: 10850000000, growth: 5.9 },
|
||||
{ month: "4月", value: 11420000000, growth: 5.3 },
|
||||
{ month: "5月", value: 11980000000, growth: 4.9 },
|
||||
{ month: "6月", value: 12580000000, growth: 5.0 },
|
||||
]
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatCurrency = (num: number): string => {
|
||||
if (num >= 100000000) return `${(num / 100000000).toFixed(1)}亿`
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}万`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors = {
|
||||
S: "border-purple-500/50 text-purple-400 bg-purple-500/10",
|
||||
A: "border-blue-500/50 text-blue-400 bg-blue-500/10",
|
||||
B: "border-green-500/50 text-green-400 bg-green-500/10",
|
||||
C: "border-yellow-500/50 text-yellow-400 bg-yellow-500/10",
|
||||
D: "border-gray-500/50 text-gray-400 bg-gray-500/10",
|
||||
}
|
||||
return colors[level as keyof typeof colors] || colors.D
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">价值评估</h1>
|
||||
<p className="text-gray-400">用户价值分析、项目价值统计与预测</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" className="bg-white/5 border-white/10 hover:bg-white/10">
|
||||
<Calculator className="w-4 h-4 mr-2" />
|
||||
价值计算器
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-purple-500">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
生成报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心价值指标 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<Card className="bg-gradient-to-br from-purple-500/20 to-purple-900/20 border-purple-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
平台总价值
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-purple-400 mb-2">¥{formatCurrency(12580000000)}</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ArrowUpRight className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400">+5.0%</span>
|
||||
<span className="text-gray-400">本月增长</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-blue-500/20 to-blue-900/20 border-blue-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
人均价值
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-blue-400 mb-2">¥293.5</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ArrowUpRight className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400">+2.8%</span>
|
||||
<span className="text-gray-400">环比提升</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-500/20 to-green-900/20 border-green-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
价值覆盖率
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold text-green-400 mb-2">85.5%</div>
|
||||
<div className="text-sm text-gray-400">已评估用户占比</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="models" className="space-y-6">
|
||||
<TabsList className="bg-white/5 border border-white/10">
|
||||
<TabsTrigger value="models">价值模型</TabsTrigger>
|
||||
<TabsTrigger value="projects">项目价值</TabsTrigger>
|
||||
<TabsTrigger value="wechat">微信号排名</TabsTrigger>
|
||||
<TabsTrigger value="trend">增长趋势</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 价值模型 */}
|
||||
<TabsContent value="models" className="space-y-6">
|
||||
{valueModels.map((model, index) => (
|
||||
<Card key={index} className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-white text-xl mb-1">{model.name}</CardTitle>
|
||||
<p className="text-sm text-gray-400">{model.description}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
model.status === "active"
|
||||
? "border-green-500/50 text-green-400"
|
||||
: "border-yellow-500/50 text-yellow-400"
|
||||
}
|
||||
>
|
||||
{model.status === "active" ? "运行中" : "训练中"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-6 mb-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">模型总价值</div>
|
||||
<div className="text-2xl font-bold text-white">¥{formatCurrency(model.totalValue)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">人均价值</div>
|
||||
<div className="text-2xl font-bold text-blue-400">¥{model.avgValue}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">覆盖率</div>
|
||||
<div className="text-2xl font-bold text-green-400">{model.coverage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={model.coverage} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* 项目价值 */}
|
||||
<TabsContent value="projects" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{projectValues.map((project, index) => (
|
||||
<Card key={index} className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-white">{project.name}</div>
|
||||
<Badge variant="outline" className={getLevelColor(project.level)}>
|
||||
{project.level}级项目
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpRight className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400 font-semibold">+{project.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">用户数</div>
|
||||
<div className="text-lg font-semibold text-white">{formatNumber(project.users)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">项目价值</div>
|
||||
<div className="text-lg font-semibold text-green-400">¥{formatCurrency(project.value)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">人均价值</div>
|
||||
<div className="text-lg font-semibold text-blue-400">
|
||||
¥{(project.value / project.users).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">增长率</div>
|
||||
<div className="text-lg font-semibold text-white">{project.growth}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 微信号排名 */}
|
||||
<TabsContent value="wechat" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{wechatRanking.map((wechat, index) => (
|
||||
<Card key={index} className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl font-bold ${
|
||||
wechat.rank === 1
|
||||
? "bg-gradient-to-br from-yellow-400 to-orange-500"
|
||||
: wechat.rank === 2
|
||||
? "bg-gradient-to-br from-gray-300 to-gray-500"
|
||||
: wechat.rank === 3
|
||||
? "bg-gradient-to-br from-orange-400 to-orange-600"
|
||||
: "bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{wechat.rank}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{wechat.name}</h3>
|
||||
<div className="text-sm text-gray-400">{formatNumber(wechat.users)} 用户</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-green-400">¥{formatCurrency(wechat.value)}</div>
|
||||
<div className="text-sm text-gray-400">人均 ¥{wechat.avgValue}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 增长趋势 */}
|
||||
<TabsContent value="trend" className="space-y-6">
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
价值增长趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{growthTrend.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-lg font-semibold text-white">{item.month}</div>
|
||||
<div className="text-2xl font-bold text-green-400">¥{formatCurrency(item.value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpRight className="w-4 h-4 text-green-400" />
|
||||
<span className="text-lg font-semibold text-green-400">+{item.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
components/.DS_Store
vendored
@@ -11,7 +11,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Search, Filter, Download, RefreshCw, Play, ArrowRight, Settings } from "lucide-react"
|
||||
import { Search, Filter, Download, RefreshCw, Play, ArrowRight, Settings } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -250,7 +250,7 @@ export function DataCorrelationAnalysis() {
|
||||
if (significance <= 0.05) {
|
||||
return <Badge className="bg-yellow-100 text-yellow-800">边际显著 (p≤0.05)</Badge>
|
||||
}
|
||||
return <Badge className="bg-red-100 text-red-800">不显著 (p>0.05)</Badge>
|
||||
return <Badge className="bg-red-100 text-red-800">{'不显著 (p>0.05)'}</Badge>
|
||||
}
|
||||
|
||||
const toggleFieldSelection = (fieldId: string) => {
|
||||
@@ -275,11 +275,11 @@ export function DataCorrelationAnalysis() {
|
||||
<h2 className="text-2xl font-bold">数据关联分析</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" >
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出分析结果
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreatingAnalysis(true)}>
|
||||
<Play className="mr-2 h-4 w-4" >
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
创建新分析
|
||||
</Button>
|
||||
</div>
|
||||
@@ -531,9 +531,9 @@ export function DataCorrelationAnalysis() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="user-network" className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
@@ -26,11 +28,15 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/users?${queryString}`, { signal: controller.signal, cache: 'no-store' })
|
||||
const res = await fetch(`/api/users?${queryString}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
||||
const json: ApiResp = await res.json()
|
||||
if (!aborted) {
|
||||
@@ -38,11 +44,14 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
setTotal(json.pagination?.total || 0)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!aborted) setError(e?.message || '未知错误')
|
||||
if (!aborted && e.name !== "AbortError") {
|
||||
setError(e?.message || "未知错误")
|
||||
}
|
||||
} finally {
|
||||
if (!aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
aborted = true
|
||||
@@ -53,50 +62,91 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
const rows = useMemo(() => data, [data])
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-4">加载中...</div>
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded-lg border bg-white p-4 text-red-600">加载失败:{error}</div>
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4">
|
||||
<div className="text-red-600 text-center py-8">
|
||||
<div className="text-lg font-medium mb-2">加载失败</div>
|
||||
<div className="text-sm">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="text-sm text-gray-600">共 {total} 条</div>
|
||||
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||
<div className="text-sm text-gray-600">
|
||||
共 <span className="font-medium text-gray-900">{total}</span> 条用户数据
|
||||
</div>
|
||||
{total > 0 && <div className="text-xs text-gray-500">显示前 {Math.min(20, total)} 条</div>}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-gray-500">
|
||||
<th className="px-3 py-2 font-medium">姓名</th>
|
||||
<th className="px-3 py-2 font-medium">邮箱</th>
|
||||
<th className="px-3 py-2 font-medium">手机号</th>
|
||||
<th className="px-3 py-2 font-medium">RFM</th>
|
||||
<th className="px-3 py-2 font-medium">最近活跃</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr className="text-left text-gray-600">
|
||||
<th className="px-4 py-3 font-medium">姓名</th>
|
||||
<th className="px-4 py-3 font-medium">邮箱</th>
|
||||
<th className="px-4 py-3 font-medium">手机号</th>
|
||||
<th className="px-4 py-3 font-medium">RFM分数</th>
|
||||
<th className="px-4 py-3 font-medium">最近活跃</th>
|
||||
<th className="px-4 py-3 font-medium">标签</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t">
|
||||
<td className="px-3 py-2">{r.name}</td>
|
||||
<td className="px-3 py-2">{r.email}</td>
|
||||
<td className="px-3 py-2">{r.phone}</td>
|
||||
<td className="px-3 py-2">{r.rfmScore}</td>
|
||||
<td className="px-3 py-2">{formatDateTime(r.lastActiveAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{r.email}</td>
|
||||
<td className="px-4 py-3 text-gray-600 font-mono">{r.phone}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={r.rfmScore >= 70 ? "default" : r.rfmScore >= 40 ? "secondary" : "outline"}
|
||||
className="font-medium"
|
||||
>
|
||||
{r.rfmScore}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{formatDateTime(r.lastActiveAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(r.tags || []).slice(0, 3).map((t) => (
|
||||
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">{t}</span>
|
||||
<Badge key={t} variant="outline" className="text-xs px-2 py-0.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{(r.tags || []).length > 3 && <span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>}
|
||||
{(r.tags || []).length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0.5">
|
||||
+{r.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!rows.length && (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}>无匹配数据</td>
|
||||
<td className="px-4 py-12 text-center text-gray-500" colSpan={6}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-lg">暂无数据</div>
|
||||
<div className="text-sm">尝试调整筛选条件</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -109,8 +159,20 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
function formatDateTime(iso: string) {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "今天 " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
} else if (diffDays === 1) {
|
||||
return "昨天 " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`
|
||||
} else {
|
||||
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" })
|
||||
}
|
||||
} catch {
|
||||
return iso
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import UserList from './user-list'
|
||||
import useDebounce from '@/hooks/use-debounce'
|
||||
import { useMemo, useState } from "react"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import UserList from "./user-list"
|
||||
import useDebounce from "@/hooks/use-debounce"
|
||||
|
||||
type UserStatus = '活跃' | '沉睡' | '流失风险'
|
||||
type UserStatus = "活跃" | "沉睡" | "流失风险"
|
||||
|
||||
export default function UserSearch({ query }: { query: string }) {
|
||||
const [status, setStatus] = useState<UserStatus[]>([])
|
||||
@@ -17,62 +17,83 @@ export default function UserSearch({ query }: { query: string }) {
|
||||
|
||||
const qs = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (debouncedQ.trim()) p.set('q', debouncedQ.trim())
|
||||
if (status.length) p.set('status', status.join(','))
|
||||
if (rfm[0] !== 0) p.set('rfmMin', String(rfm[0]))
|
||||
if (rfm[1] !== 100) p.set('rfmMax', String(rfm[1]))
|
||||
p.set('page', '1')
|
||||
p.set('pageSize', '20')
|
||||
if (debouncedQ.trim()) p.set("q", debouncedQ.trim())
|
||||
if (status.length) p.set("status", status.join(","))
|
||||
if (rfm[0] !== 0) p.set("rfmMin", String(rfm[0]))
|
||||
if (rfm[1] !== 100) p.set("rfmMax", String(rfm[1]))
|
||||
p.set("page", "1")
|
||||
p.set("pageSize", "20")
|
||||
return p.toString()
|
||||
}, [debouncedQ, status, rfm])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 过滤区 */}
|
||||
<div className="rounded-lg border bg-white p-4 md:sticky md:top-4 h-max">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">状态</div>
|
||||
<div className="grid gap-2 text-sm">
|
||||
{(['活跃', '沉睡', '流失风险'] as UserStatus[]).map((s) => {
|
||||
const checked = status.includes(s)
|
||||
return (
|
||||
<label key={s} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) =>
|
||||
setStatus((prev) => (v ? [...prev, s] : prev.filter((x) => x !== s)))
|
||||
}
|
||||
/>
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold mb-4">用户搜索与筛选</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 过滤区 */}
|
||||
<div className="rounded-lg border bg-white p-4 md:sticky md:top-4 h-max">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">状态</div>
|
||||
<div className="grid gap-2 text-sm">
|
||||
{(["活跃", "沉睡", "流失风险"] as UserStatus[]).map((s) => {
|
||||
const checked = status.includes(s)
|
||||
return (
|
||||
<label key={s} className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => setStatus((prev) => (v ? [...prev, s] : prev.filter((x) => x !== s)))}
|
||||
/>
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">RFM 区间</div>
|
||||
<Slider
|
||||
value={rfm}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={(v) => setRfm([v[0], v[1]] as [number, number])}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500 flex justify-between">
|
||||
<span>{rfm[0]}</span>
|
||||
<span>{rfm[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus([])
|
||||
setRfm([0, 100])
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 触发一次刷新:依赖于 qs 的变化,列表会自动刷新
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">RFM 区间</div>
|
||||
<Slider value={rfm} min={0} max={100} step={1} onValueChange={(v) => setRfm([v[0], v[1]] as any)} />
|
||||
<div className="mt-2 text-xs text-gray-500">{rfm[0]} - {rfm[1]}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { setStatus([]); setRfm([0, 100]) }}>重置</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 触发一次刷新:依赖于 qs 的变化,列表会自动刷新,这里可以空操作
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表区 */}
|
||||
<div className="md:col-span-3">
|
||||
<UserList queryString={qs} />
|
||||
{/* 列表区 */}
|
||||
<div className="md:col-span-3">
|
||||
<UserList queryString={qs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
61
components/main-nav.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LayoutDashboard, Database, Users, BarChart3, Bot } from "lucide-react"
|
||||
|
||||
export function MainNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const routes = [
|
||||
{
|
||||
href: "/",
|
||||
label: "首页",
|
||||
icon: LayoutDashboard,
|
||||
active: pathname === "/",
|
||||
},
|
||||
{
|
||||
href: "/data-platform",
|
||||
label: "数据中台",
|
||||
icon: Database,
|
||||
active: pathname.startsWith("/data-platform"),
|
||||
},
|
||||
{
|
||||
href: "/user-portrait",
|
||||
label: "用户画像",
|
||||
icon: Users,
|
||||
active: pathname.startsWith("/user-portrait"),
|
||||
},
|
||||
{
|
||||
href: "/value-assessment",
|
||||
label: "价值评估",
|
||||
icon: BarChart3,
|
||||
active: pathname.startsWith("/value-assessment"),
|
||||
},
|
||||
{
|
||||
href: "/ai-assistant",
|
||||
label: "AI助手",
|
||||
icon: Bot,
|
||||
active: pathname.startsWith("/ai-assistant"),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-4 lg:space-x-6 mx-6">
|
||||
{routes.map((route) => (
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary flex items-center gap-2",
|
||||
route.active ? "text-black dark:text-white" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<route.icon className="h-4 w-4" />
|
||||
{route.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
72
components/overview.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: "1月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "2月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "3月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "4月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "5月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "6月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "7月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "8月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "9月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "10月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "11月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
{
|
||||
name: "12月",
|
||||
total: Math.floor(Math.random() * 5000) + 1000,
|
||||
},
|
||||
]
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `¥${value}`}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
58
components/platform-nav.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, Database, Users, DollarSign, Brain, Settings, Home } from 'lucide-react'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/platform/dashboard", icon: LayoutDashboard, label: "平台总览" },
|
||||
{ href: "/platform/data-management", icon: Database, label: "数据管理" },
|
||||
{ href: "/platform/user-portrait", icon: Users, label: "用户画像" },
|
||||
{ href: "/platform/value-assessment", icon: DollarSign, label: "价值评估" },
|
||||
{ href: "/platform/ai-assistant", icon: Brain, label: "AI助手" },
|
||||
]
|
||||
|
||||
export default function PlatformNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-gray-200 p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-1">神射手</h2>
|
||||
<p className="text-sm text-gray-600">数据资产中台</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="font-medium">返回首页</span>
|
||||
</Link>
|
||||
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-lg transition-colors",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-600 font-semibold"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
63
components/recent-sales.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
||||
export function RecentSales() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src="/avatars/01.png" alt="Avatar" />
|
||||
<AvatarFallback>OM</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Olivia Martin</p>
|
||||
<p className="text-sm text-muted-foreground">olivia.martin@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+¥1,999.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Avatar className="flex h-9 w-9 items-center justify-center space-y-0 border">
|
||||
<AvatarImage src="/avatars/02.png" alt="Avatar" />
|
||||
<AvatarFallback>JL</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Jackson Lee</p>
|
||||
<p className="text-sm text-muted-foreground">jackson.lee@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+¥39.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src="/avatars/03.png" alt="Avatar" />
|
||||
<AvatarFallback>IN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Isabella Nguyen</p>
|
||||
<p className="text-sm text-muted-foreground">isabella.nguyen@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+¥299.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src="/avatars/04.png" alt="Avatar" />
|
||||
<AvatarFallback>WK</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">William Kim</p>
|
||||
<p className="text-sm text-muted-foreground">will@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+¥99.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src="/avatars/05.png" alt="Avatar" />
|
||||
<AvatarFallback>SD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Sofia Davis</p>
|
||||
<p className="text-sm text-muted-foreground">sofia.davis@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+¥39.00</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
"h-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-slate-200">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-slate-900" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
className={cn(
|
||||
"block h-4 w-4 rounded-full border border-slate-300 bg-white shadow",
|
||||
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
)}
|
||||
/>
|
||||
<SliderPrimitive.Thumb
|
||||
className={cn(
|
||||
"block h-4 w-4 rounded-full border border-slate-300 bg-white shadow",
|
||||
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400",
|
||||
"disabled:pointer-events-none disabled:opacity-50"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = "Slider"
|
||||
|
||||
export { Slider }
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toaster
|
||||
export { ToastAction }
|
||||
51
components/user-nav.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function UserNav() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt="@shadcn" />
|
||||
<AvatarFallback>SC</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">管理员</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">admin@example.com</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
个人资料
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
设置
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
退出登录
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
function useDebounce<T>(value: T, delay = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export default useDebounce
|
||||
|
||||
BIN
lib/.DS_Store
vendored
@@ -1,6 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export type UserStatus = '活跃' | '沉睡' | '流失风险'
|
||||
export type UserStatus = "活跃" | "沉睡" | "流失风险"
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -15,38 +13,196 @@ export interface User {
|
||||
lastActiveAt: string
|
||||
}
|
||||
|
||||
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
|
||||
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
|
||||
const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
|
||||
const statusPool: UserStatus[] = ['活跃','沉睡','流失风险']
|
||||
const familyNames = [
|
||||
"张",
|
||||
"李",
|
||||
"王",
|
||||
"赵",
|
||||
"刘",
|
||||
"陈",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"徐",
|
||||
"孙",
|
||||
"胡",
|
||||
"朱",
|
||||
"高",
|
||||
"林",
|
||||
"何",
|
||||
"郭",
|
||||
"马",
|
||||
"罗",
|
||||
]
|
||||
const givenNames = [
|
||||
"伟",
|
||||
"芳",
|
||||
"娜",
|
||||
"敏",
|
||||
"静",
|
||||
"秀英",
|
||||
"丽",
|
||||
"强",
|
||||
"磊",
|
||||
"军",
|
||||
"洋",
|
||||
"艳",
|
||||
"勇",
|
||||
"杰",
|
||||
"娟",
|
||||
"涛",
|
||||
"明",
|
||||
"超",
|
||||
"霞",
|
||||
"平",
|
||||
"俊",
|
||||
"凯",
|
||||
"佳",
|
||||
"鑫",
|
||||
"鹏",
|
||||
"晨",
|
||||
"倩",
|
||||
"颖",
|
||||
"梅",
|
||||
"慧",
|
||||
"雪",
|
||||
"宇",
|
||||
"涵",
|
||||
"宁",
|
||||
"璐",
|
||||
"龙",
|
||||
"震",
|
||||
"航",
|
||||
"璟",
|
||||
"钰",
|
||||
]
|
||||
const tagPool = [
|
||||
"高价值",
|
||||
"近7日活跃",
|
||||
"新客",
|
||||
"回流",
|
||||
"社群达人",
|
||||
"潜在复购",
|
||||
"高互动",
|
||||
"低客单",
|
||||
"私域粉",
|
||||
"公众号粉",
|
||||
]
|
||||
const statusPool: UserStatus[] = ["活跃", "沉睡", "流失风险"]
|
||||
|
||||
const avatars = [
|
||||
'/user-avatar-zhangsan.png',
|
||||
'/user-avatar-lisi.png',
|
||||
'/avatar-wanglei.png',
|
||||
'/generic-user-avatar.png',
|
||||
'/wechat-avatar-1.png',
|
||||
'/wechat-avatar-2.png',
|
||||
'/wechat-avatar-3.png',
|
||||
"/user-avatar-zhangsan.png",
|
||||
"/user-avatar-lisi.png",
|
||||
"/avatar-wanglei.png",
|
||||
"/generic-user-avatar.png",
|
||||
"/wechat-avatar-1.png",
|
||||
"/wechat-avatar-2.png",
|
||||
"/wechat-avatar-3.png",
|
||||
]
|
||||
|
||||
const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||
const pick = <T,>(arr: T[]) => arr[rand(0, arr.length - 1)]
|
||||
|
||||
function toPinyinLike(name: string) {
|
||||
const map: Record<string,string> = { '张':'zhang','李':'li','王':'wang','赵':'zhao','刘':'liu','陈':'chen','杨':'yang','黄':'huang','周':'zhou','吴':'wu','徐':'xu','孙':'sun','胡':'hu','朱':'zhu','高':'gao','林':'lin','何':'he','郭':'guo','马':'ma','罗':'luo' }
|
||||
return name.split('').map(c => map[c] ?? 'u').join('')
|
||||
const map: Record<string, string> = {
|
||||
张: "zhang",
|
||||
李: "li",
|
||||
王: "wang",
|
||||
赵: "zhao",
|
||||
刘: "liu",
|
||||
陈: "chen",
|
||||
杨: "yang",
|
||||
黄: "huang",
|
||||
周: "zhou",
|
||||
吴: "wu",
|
||||
徐: "xu",
|
||||
孙: "sun",
|
||||
胡: "hu",
|
||||
朱: "zhu",
|
||||
高: "gao",
|
||||
林: "lin",
|
||||
何: "he",
|
||||
郭: "guo",
|
||||
马: "ma",
|
||||
罗: "luo",
|
||||
伟: "wei",
|
||||
芳: "fang",
|
||||
娜: "na",
|
||||
敏: "min",
|
||||
静: "jing",
|
||||
秀英: "xiuying",
|
||||
丽: "li",
|
||||
强: "qiang",
|
||||
磊: "lei",
|
||||
军: "jun",
|
||||
洋: "yang",
|
||||
艳: "yan",
|
||||
勇: "yong",
|
||||
杰: "jie",
|
||||
娟: "juan",
|
||||
涛: "tao",
|
||||
明: "ming",
|
||||
超: "chao",
|
||||
霞: "xia",
|
||||
平: "ping",
|
||||
俊: "jun",
|
||||
凯: "kai",
|
||||
佳: "jia",
|
||||
鑫: "xin",
|
||||
鹏: "peng",
|
||||
晨: "chen",
|
||||
倩: "qian",
|
||||
颖: "ying",
|
||||
梅: "mei",
|
||||
慧: "hui",
|
||||
雪: "xue",
|
||||
宇: "yu",
|
||||
涵: "han",
|
||||
宁: "ning",
|
||||
璐: "lu",
|
||||
龙: "long",
|
||||
震: "zhen",
|
||||
航: "hang",
|
||||
璟: "jing",
|
||||
钰: "yu",
|
||||
}
|
||||
return name
|
||||
.split("")
|
||||
.map((c) => map[c] ?? "u")
|
||||
.join("")
|
||||
}
|
||||
|
||||
function randomPhone() {
|
||||
const prefixes = ['139','138','137','136','135','188','187','186','185','184','183','182','159','158','157','156','155']
|
||||
return `${pick(prefixes)}${rand(1000,9999)}${rand(1000,9999)}`
|
||||
const prefixes = [
|
||||
"139",
|
||||
"138",
|
||||
"137",
|
||||
"136",
|
||||
"135",
|
||||
"188",
|
||||
"187",
|
||||
"186",
|
||||
"185",
|
||||
"184",
|
||||
"183",
|
||||
"182",
|
||||
"159",
|
||||
"158",
|
||||
"157",
|
||||
"156",
|
||||
"155",
|
||||
]
|
||||
return `${pick(prefixes)}${rand(1000, 9999)}${rand(1000, 9999)}`
|
||||
}
|
||||
|
||||
function randomTags() {
|
||||
const count = rand(2,4)
|
||||
const count = rand(2, 4)
|
||||
const s = new Set<string>()
|
||||
while (s.size < count) s.add(pick(tagPool))
|
||||
return Array.from(s)
|
||||
}
|
||||
|
||||
function timeNearNow(daysSpan = 90) {
|
||||
const now = Date.now()
|
||||
const offset = rand(0, daysSpan * 86400000)
|
||||
@@ -58,11 +214,11 @@ let cache: User[] | null = null
|
||||
function seed(n = 120) {
|
||||
const list: User[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const name = `${pick(familyNames)}${pick(givenNames)}${Math.random() < 0.2 ? pick(givenNames) : ''}`
|
||||
const email = `${toPinyinLike(name)}${rand(1,99)}@example.com`
|
||||
const name = `${pick(familyNames)}${pick(givenNames)}${Math.random() < 0.2 ? pick(givenNames) : ""}`
|
||||
const email = `${toPinyinLike(name)}${rand(1, 99)}@example.com`
|
||||
const phone = randomPhone()
|
||||
list.push({
|
||||
id: randomUUID(),
|
||||
id: `user_${Date.now()}_${i}`,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
@@ -98,24 +254,25 @@ export function queryUsers(params: QueryParams) {
|
||||
|
||||
if (q && q.trim()) {
|
||||
const s = q.trim().toLowerCase()
|
||||
list = list.filter(u =>
|
||||
u.name.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s) ||
|
||||
u.phone.includes(s) ||
|
||||
u.tags.some(t => t.toLowerCase().includes(s)),
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s) ||
|
||||
u.phone.includes(s) ||
|
||||
u.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
)
|
||||
}
|
||||
|
||||
if (tags?.length) {
|
||||
list = list.filter(u => tags.every(t => u.tags.includes(t)))
|
||||
list = list.filter((u) => tags.every((t) => u.tags.includes(t)))
|
||||
}
|
||||
|
||||
if (status?.length) {
|
||||
const st = new Set(status)
|
||||
list = list.filter(u => st.has(u.status))
|
||||
list = list.filter((u) => st.has(u.status))
|
||||
}
|
||||
|
||||
list = list.filter(u => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
|
||||
list = list.filter((u) => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
|
||||
|
||||
const total = list.length
|
||||
const start = (page - 1) * pageSize
|
||||
@@ -123,7 +280,7 @@ export function queryUsers(params: QueryParams) {
|
||||
const data = list.slice(start, end)
|
||||
|
||||
// 列表行仅返回必要字段
|
||||
const thin = data.map(u => ({
|
||||
const thin = data.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
@@ -143,7 +300,7 @@ export function addUser(input: Partial<User>) {
|
||||
const email = input.email ?? `${toPinyinLike(name)}@example.com`
|
||||
const phone = input.phone ?? randomPhone()
|
||||
const u: User = {
|
||||
id: randomUUID(),
|
||||
id: `user_${Date.now()}_${rand(1000, 9999)}`,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
@@ -159,5 +316,5 @@ export function addUser(input: Partial<User>) {
|
||||
}
|
||||
|
||||
export function getUserById(id: string) {
|
||||
return getUsersStore().find(u => u.id === id) ?? null
|
||||
return getUsersStore().find((u) => u.id === id) ?? null
|
||||
}
|
||||
|
||||
515
lib/mongodb.ts
@@ -1,515 +0,0 @@
|
||||
/**
|
||||
* 神射手 MongoDB 连接器
|
||||
* 连接到卡若AI神射手后端数据库
|
||||
*
|
||||
* 数据库概览:
|
||||
* - KR.用户估值: 1436万条 - 统一画像、RFM评分
|
||||
* - KR_腾讯.QQ+手机: 7.05亿条 - QQ↔手机关联
|
||||
* - KR_微博.微博uid+手机: 2.17亿条 - 微博UID↔手机
|
||||
* - KR_京东.jd_com: 1.42亿条 - 京东用户
|
||||
* - KR_存客宝.用户资产统一视图: 21.6万条 - 存客宝用户
|
||||
*/
|
||||
|
||||
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
||||
|
||||
// MongoDB 连接配置
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://admin:admin123@localhost:27017/?authSource=admin'
|
||||
|
||||
// 数据库名称常量
|
||||
export const DB_NAMES = {
|
||||
KR: 'KR',
|
||||
KR_TENCENT: 'KR_腾讯',
|
||||
KR_WEIBO: 'KR_微博',
|
||||
KR_JD: 'KR_京东',
|
||||
KR_HOTEL: 'KR_酒店',
|
||||
KR_SF: 'KR_顺丰',
|
||||
KR_CKB: 'KR_存客宝',
|
||||
} as const
|
||||
|
||||
// 集合名称常量
|
||||
export const COLLECTION_NAMES = {
|
||||
USER_VALUATION: '用户估值',
|
||||
QQ_PHONE: 'QQ+手机',
|
||||
WEIBO_UID_PHONE: '微博uid+手机',
|
||||
JD_COM: 'jd_com',
|
||||
HOTEL_RECORDS: '酒店开房记录_2013年8月_2000万内',
|
||||
SF_EXPRESS: '顺丰快递数据',
|
||||
CKB_UNIFIED: '用户资产统一视图',
|
||||
} as const
|
||||
|
||||
// 全局客户端实例(连接池复用)
|
||||
let client: MongoClient | null = null
|
||||
let clientPromise: Promise<MongoClient> | null = null
|
||||
|
||||
/**
|
||||
* 获取 MongoDB 客户端(单例模式)
|
||||
*/
|
||||
export async function getMongoClient(): Promise<MongoClient> {
|
||||
if (client) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (!clientPromise) {
|
||||
clientPromise = MongoClient.connect(MONGODB_URI, {
|
||||
maxPoolSize: 10,
|
||||
minPoolSize: 2,
|
||||
maxIdleTimeMS: 60000,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
connectTimeoutMS: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
client = await clientPromise
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定数据库
|
||||
*/
|
||||
export async function getDatabase(dbName: string): Promise<Db> {
|
||||
const client = await getMongoClient()
|
||||
return client.db(dbName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定集合
|
||||
*/
|
||||
export async function getCollection<T extends Document = Document>(
|
||||
dbName: string,
|
||||
collectionName: string
|
||||
): Promise<Collection<T>> {
|
||||
const db = await getDatabase(dbName)
|
||||
return db.collection<T>(collectionName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户估值文档类型(匹配实际数据库结构)
|
||||
*/
|
||||
export interface UserValuationDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
name?: string
|
||||
email?: string
|
||||
email_masked?: string
|
||||
id_number_masked?: string
|
||||
gender?: string
|
||||
age_range?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
// 评分字段
|
||||
user_evaluation_score?: number // 用户估值分
|
||||
rfm_composite_score?: number
|
||||
rfm_r_score?: number
|
||||
rfm_f_score?: number
|
||||
rfm_m_score?: number
|
||||
user_level?: string
|
||||
// 来源和标签
|
||||
source_collections?: string[]
|
||||
source_channels?: string[]
|
||||
first_channel?: string
|
||||
merge_evidence?: string[]
|
||||
tags?: string[]
|
||||
// 数据质量
|
||||
data_quality?: {
|
||||
completeness?: number
|
||||
source_count?: number
|
||||
}
|
||||
// 时间
|
||||
computed_at?: Date
|
||||
created_at?: Date
|
||||
updated_at?: Date
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ+手机文档类型
|
||||
*/
|
||||
export interface QQPhoneDoc {
|
||||
_id?: any
|
||||
qq?: string
|
||||
phone?: string
|
||||
省份?: string
|
||||
地区?: string
|
||||
运营商?: string
|
||||
手机号评分?: number
|
||||
QQ号评分?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝用户资产文档类型
|
||||
*/
|
||||
export interface CKBUserAssetDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
core_profile?: {
|
||||
name?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
email?: string
|
||||
}
|
||||
social_accounts?: {
|
||||
wechat?: string
|
||||
qq?: string
|
||||
}
|
||||
rfm_scores?: {
|
||||
composite_score?: number
|
||||
user_level?: string
|
||||
R?: number
|
||||
F?: number
|
||||
M?: number
|
||||
}
|
||||
user_evaluation_score?: number
|
||||
unified_tags?: string[]
|
||||
traffic_pool?: {
|
||||
pool_id?: string
|
||||
pool_name?: string
|
||||
}
|
||||
source_channels?: string[]
|
||||
}
|
||||
|
||||
// ========== 快捷查询函数 ==========
|
||||
|
||||
/**
|
||||
* 标准化手机号格式
|
||||
* 支持: 13800138000, +8613800138000, 8613800138000
|
||||
*/
|
||||
function normalizePhone(phone: string): string[] {
|
||||
const cleaned = phone.replace(/\D/g, '')
|
||||
const variants: string[] = []
|
||||
|
||||
if (cleaned.startsWith('86') && cleaned.length === 13) {
|
||||
// 8613800138000 -> 多种格式
|
||||
const base = cleaned.slice(2)
|
||||
variants.push(base, `+86${base}`, `86${base}`, cleaned)
|
||||
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||
// 13800138000 -> 多种格式
|
||||
variants.push(cleaned, `+86${cleaned}`, `86${cleaned}`)
|
||||
} else {
|
||||
variants.push(phone, cleaned)
|
||||
}
|
||||
|
||||
return [...new Set(variants)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询用户估值
|
||||
*/
|
||||
export async function queryUserByPhone(phone: string): Promise<UserValuationDoc | null> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 尝试多种手机号格式
|
||||
const phoneVariants = normalizePhone(phone)
|
||||
|
||||
return coll.findOne({
|
||||
$or: phoneVariants.map(p => ({ phone: p }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询QQ
|
||||
*/
|
||||
export async function queryQQByPhone(phone: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ phone })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按QQ号查询手机
|
||||
*/
|
||||
export async function queryPhoneByQQ(qq: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ qq })
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨库完整画像查询(并行)
|
||||
*/
|
||||
export async function queryFullProfile(phone: string): Promise<{
|
||||
valuation: UserValuationDoc | null
|
||||
qq: QQPhoneDoc | null
|
||||
ckb: CKBUserAssetDoc | null
|
||||
}> {
|
||||
const [valuation, qq, ckb] = await Promise.all([
|
||||
queryUserByPhone(phone),
|
||||
queryQQByPhone(phone),
|
||||
(async () => {
|
||||
const coll = await getCollection<CKBUserAssetDoc>(DB_NAMES.KR_CKB, COLLECTION_NAMES.CKB_UNIFIED)
|
||||
return coll.findOne({ phone })
|
||||
})()
|
||||
])
|
||||
|
||||
return { valuation, qq, ckb }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表查询(分页)
|
||||
*/
|
||||
export async function queryUserList(options: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
userLevel?: string
|
||||
minRfm?: number
|
||||
maxRfm?: number
|
||||
search?: string
|
||||
tags?: string[]
|
||||
}): Promise<{ data: UserValuationDoc[], total: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
userLevel,
|
||||
minRfm,
|
||||
maxRfm,
|
||||
search,
|
||||
tags
|
||||
} = options
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
|
||||
if (userLevel) {
|
||||
query.user_level = userLevel
|
||||
}
|
||||
|
||||
if (minRfm !== undefined || maxRfm !== undefined) {
|
||||
query.rfm_composite_score = {}
|
||||
if (minRfm !== undefined) query.rfm_composite_score.$gte = minRfm
|
||||
if (maxRfm !== undefined) query.rfm_composite_score.$lte = maxRfm
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ phone: { $regex: search, $options: 'i' } },
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ phone_masked: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
query.tags = { $in: tags }
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
coll.find(query)
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.sort({ rfm_composite_score: -1 })
|
||||
.toArray(),
|
||||
coll.countDocuments(query)
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库统计信息
|
||||
*/
|
||||
export async function getDatabaseStats(): Promise<{
|
||||
connected: boolean
|
||||
databases: { name: string, collections: number, documents: number, sizeGB: number }[]
|
||||
totalDocuments: number
|
||||
totalSizeGB: number
|
||||
}> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
|
||||
// 获取数据库列表
|
||||
const dbs = await adminDb.admin().listDatabases()
|
||||
|
||||
const databases = []
|
||||
let totalDocuments = 0
|
||||
let totalSizeGB = 0
|
||||
|
||||
for (const db of dbs.databases) {
|
||||
if (db.name.startsWith('KR')) {
|
||||
const database = client.db(db.name)
|
||||
const collections = await database.listCollections().toArray()
|
||||
|
||||
let dbDocCount = 0
|
||||
for (const coll of collections) {
|
||||
try {
|
||||
const count = await database.collection(coll.name).estimatedDocumentCount()
|
||||
dbDocCount += count
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
const sizeGB = (db.sizeOnDisk || 0) / (1024 * 1024 * 1024)
|
||||
|
||||
databases.push({
|
||||
name: db.name,
|
||||
collections: collections.length,
|
||||
documents: dbDocCount,
|
||||
sizeGB: Math.round(sizeGB * 100) / 100
|
||||
})
|
||||
|
||||
totalDocuments += dbDocCount
|
||||
totalSizeGB += sizeGB
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
databases,
|
||||
totalDocuments,
|
||||
totalSizeGB: Math.round(totalSizeGB * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MongoDB stats error:', error)
|
||||
return {
|
||||
connected: false,
|
||||
databases: [],
|
||||
totalDocuments: 0,
|
||||
totalSizeGB: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFM 用户分组统计
|
||||
*/
|
||||
export async function getRFMGroupSummary(): Promise<{
|
||||
levels: { level: string, count: number, percentage: number }[]
|
||||
total: number
|
||||
}> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const pipeline = [
|
||||
{
|
||||
$group: {
|
||||
_id: '$user_level',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { count: -1 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = await coll.aggregate(pipeline).toArray()
|
||||
const total = result.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const levels = result.map(item => ({
|
||||
level: item._id || '未分类',
|
||||
count: item.count,
|
||||
percentage: Math.round((item.count / total) * 10000) / 100
|
||||
}))
|
||||
|
||||
return { levels, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标签
|
||||
*/
|
||||
export async function getDistinctTags(): Promise<string[]> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const tags = await coll.distinct('tags')
|
||||
return tags.filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能搜索 - 跨库查询
|
||||
*/
|
||||
export async function intelligentSearch(query: string, options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
users: UserValuationDoc[]
|
||||
total: number
|
||||
queryType: 'phone' | 'name' | 'qq' | 'email' | 'general'
|
||||
}> {
|
||||
const { limit = 20, offset = 0 } = options
|
||||
|
||||
// 识别查询类型
|
||||
let queryType: 'phone' | 'name' | 'qq' | 'email' | 'general' = 'general'
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||
queryType = 'phone'
|
||||
} else if (/^\d{5,12}$/.test(trimmed)) {
|
||||
queryType = 'qq'
|
||||
} else if (/@/.test(trimmed)) {
|
||||
queryType = 'email'
|
||||
} else if (/^[\u4e00-\u9fa5]{2,4}$/.test(trimmed)) {
|
||||
queryType = 'name'
|
||||
}
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
let filter: any = {}
|
||||
|
||||
switch (queryType) {
|
||||
case 'phone':
|
||||
// 使用多种格式匹配
|
||||
const phoneVariants = normalizePhone(trimmed)
|
||||
filter = { $or: phoneVariants.map(p => ({ phone: p })) }
|
||||
break
|
||||
case 'qq':
|
||||
// 先查QQ找手机,再查用户估值
|
||||
const qqResult = await queryPhoneByQQ(trimmed)
|
||||
if (qqResult?.phone) {
|
||||
const qqPhoneVariants = normalizePhone(qqResult.phone)
|
||||
filter = { $or: qqPhoneVariants.map(p => ({ phone: p })) }
|
||||
} else {
|
||||
filter = { _id: null } // 查不到
|
||||
}
|
||||
break
|
||||
case 'email':
|
||||
filter = { email: { $regex: trimmed, $options: 'i' } }
|
||||
break
|
||||
case 'name':
|
||||
filter = { name: trimmed }
|
||||
break
|
||||
default:
|
||||
filter = {
|
||||
$or: [
|
||||
{ phone: { $regex: trimmed, $options: 'i' } },
|
||||
{ name: { $regex: trimmed, $options: 'i' } },
|
||||
{ phone_masked: { $regex: trimmed, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
coll.find(filter).skip(offset).limit(limit).toArray(),
|
||||
coll.countDocuments(filter)
|
||||
])
|
||||
|
||||
return { users, total, queryType }
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
export async function healthCheck(): Promise<{
|
||||
mongodb: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
}> {
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('admin').command({ ping: 1 })
|
||||
|
||||
return {
|
||||
mongodb: true,
|
||||
latencyMs: Date.now() - start
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
mongodb: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
86
package.json
@@ -9,61 +9,59 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^6.3.0",
|
||||
"@ai-sdk/openai": "latest",
|
||||
"@ant-design/plots": "latest",
|
||||
"@radix-ui/react-accordion": "latest",
|
||||
"@radix-ui/react-avatar": "latest",
|
||||
"@radix-ui/react-checkbox": "latest",
|
||||
"@radix-ui/react-collapsible": "latest",
|
||||
"@radix-ui/react-dialog": "latest",
|
||||
"@radix-ui/react-dropdown-menu": "latest",
|
||||
"@radix-ui/react-icons": "latest",
|
||||
"@radix-ui/react-label": "latest",
|
||||
"@radix-ui/react-popover": "latest",
|
||||
"@radix-ui/react-progress": "latest",
|
||||
"@radix-ui/react-radio-group": "latest",
|
||||
"@radix-ui/react-scroll-area": "latest",
|
||||
"@radix-ui/react-select": "latest",
|
||||
"@radix-ui/react-separator": "latest",
|
||||
"@radix-ui/react-slider": "latest",
|
||||
"@radix-ui/react-slot": "latest",
|
||||
"@radix-ui/react-switch": "latest",
|
||||
"@radix-ui/react-tabs": "latest",
|
||||
"@radix-ui/react-toast": "latest",
|
||||
"@radix-ui/react-tooltip": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"@tanstack/react-virtual": "latest",
|
||||
"ai": "latest",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "latest",
|
||||
"crypto": "latest",
|
||||
"date-fns": "latest",
|
||||
"docx": "latest",
|
||||
"dom-to-image": "latest",
|
||||
"html-to-image": "latest",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "14.2.16",
|
||||
"next-themes": "latest",
|
||||
"@ai-sdk/openai": "2.0.32",
|
||||
"@ant-design/plots": "2.6.4",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.12",
|
||||
"ai": "4.3.19",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"docx": "9.5.1",
|
||||
"dom-to-image": "2.6.0",
|
||||
"html-to-image": "1.11.13",
|
||||
"lucide-react": "^0.323.0",
|
||||
"next": "14.1.0",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "^18",
|
||||
"react-day-picker": "latest",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18",
|
||||
"react-intersection-observer": "latest",
|
||||
"recharts": "latest",
|
||||
"regenerator-runtime": "latest",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"recharts": "^2.12.0",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"postcss": "^8.5",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
747
pnpm-lock.yaml
generated
BIN
public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icon-dark-32x32.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
public/icon-light-32x32.png
Normal file
|
After Width: | Height: | Size: 566 B |
26
public/icon.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.background { fill: black; }
|
||||
.foreground { fill: white; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.background { fill: white; }
|
||||
.foreground { fill: black; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_7960_43945)">
|
||||
<rect class="background" width="180" height="180" rx="37" />
|
||||
<g style="transform: scale(95%); transform-origin: center">
|
||||
<path class="foreground"
|
||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||
<path class="foreground"
|
||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7960_43945">
|
||||
<rect width="180" height="180" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,45 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 将本地全部内容强制推送到 GitHub v0/fnvtk-5efaab9c(以本地为主)
|
||||
# Token 从卡若AI/金盾/账号密码与资料管理 读取
|
||||
# 在终端执行: bash scripts/push-to-github.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 从卡若AI 账号与API索引 提取 GitHub Token
|
||||
TOKEN_FILE="$HOME/Documents/个人/卡若AI/01_卡资(金)/_团队成员/金盾/账号密码与资料管理/账号与API索引.md"
|
||||
if [ -f "$TOKEN_FILE" ]; then
|
||||
GITHUB_TOKEN=$(grep "Token.*ghp_" "$TOKEN_FILE" | sed -E "s/.*\`([^\`]+)\`.*/\1/")
|
||||
fi
|
||||
if [ -z "$GITHUB_TOKEN" ]; then
|
||||
echo "未找到 GitHub Token,使用 origin 推送(需已配置凭据)"
|
||||
PUSH_URL="origin"
|
||||
else
|
||||
PUSH_URL="https://${GITHUB_TOKEN}@github.com/fnvtk/users.git"
|
||||
echo "已从卡若AI 读取 Token"
|
||||
fi
|
||||
|
||||
echo "=== 1. 当前状态 ==="
|
||||
git status
|
||||
echo ""
|
||||
|
||||
echo "=== 2. 添加所有更改 ==="
|
||||
git add -A
|
||||
git status
|
||||
echo ""
|
||||
|
||||
echo "=== 3. 提交 ==="
|
||||
if git diff --cached --quiet; then
|
||||
echo "没有需要提交的更改"
|
||||
else
|
||||
git commit -m "chore: 以本地为准,强制同步全部内容"
|
||||
echo "提交完成"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== 4. 强制推送到 v0/fnvtk-5efaab9c ==="
|
||||
git push --force "$PUSH_URL" main:v0/fnvtk-5efaab9c
|
||||
echo ""
|
||||
|
||||
echo "=== 完成 ==="
|
||||
echo "请检查: https://github.com/fnvtk/users/tree/v0/fnvtk-5efaab9c"
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* RFM MongoDB 服务
|
||||
* 从 MongoDB 获取真实 RFM 数据
|
||||
*/
|
||||
|
||||
import {
|
||||
getCollection,
|
||||
getRFMGroupSummary,
|
||||
getDistinctTags,
|
||||
DB_NAMES,
|
||||
COLLECTION_NAMES,
|
||||
UserValuationDoc
|
||||
} from '@/lib/mongodb'
|
||||
|
||||
export interface RFMStats {
|
||||
gradeCount: Record<string, number>
|
||||
valueCount: Record<string, number>
|
||||
totalUsers: number
|
||||
avgScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 分组统计(从 MongoDB)
|
||||
*/
|
||||
export async function getMongoRFMGroupSummary(): Promise<RFMStats> {
|
||||
try {
|
||||
const result = await getRFMGroupSummary()
|
||||
|
||||
// 转换为前端期望的格式
|
||||
const gradeCount: Record<string, number> = {
|
||||
'S': 0,
|
||||
'A': 0,
|
||||
'B': 0,
|
||||
'C': 0,
|
||||
'D': 0
|
||||
}
|
||||
|
||||
const valueCount: Record<string, number> = {
|
||||
'高': 0,
|
||||
'中': 0,
|
||||
'低': 0
|
||||
}
|
||||
|
||||
// 映射用户等级到 S/A/B/C/D
|
||||
for (const item of result.levels) {
|
||||
const level = item.level
|
||||
if (level === 'S' || level === '钻石用户' || level === 'S级用户') {
|
||||
gradeCount['S'] += item.count
|
||||
valueCount['高'] += item.count
|
||||
} else if (level === 'A' || level === '黄金用户' || level === 'A级用户') {
|
||||
gradeCount['A'] += item.count
|
||||
valueCount['高'] += item.count
|
||||
} else if (level === 'B' || level === '白银用户' || level === 'B级用户') {
|
||||
gradeCount['B'] += item.count
|
||||
valueCount['中'] += item.count
|
||||
} else if (level === 'C' || level === '青铜用户' || level === 'C级用户') {
|
||||
gradeCount['C'] += item.count
|
||||
valueCount['中'] += item.count
|
||||
} else {
|
||||
gradeCount['D'] += item.count
|
||||
valueCount['低'] += item.count
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均分
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const avgResult = await coll.aggregate([
|
||||
{ $match: { rfm_composite_score: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: null, avgScore: { $avg: '$rfm_composite_score' } } }
|
||||
]).toArray()
|
||||
|
||||
const avgScore = avgResult[0]?.avgScore || 0
|
||||
|
||||
return {
|
||||
gradeCount,
|
||||
valueCount,
|
||||
totalUsers: result.total,
|
||||
avgScore: Math.round(avgScore * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('RFM stats error:', error)
|
||||
return {
|
||||
gradeCount: { S: 0, A: 0, B: 0, C: 0, D: 0 },
|
||||
valueCount: { '高': 0, '中': 0, '低': 0 },
|
||||
totalUsers: 0,
|
||||
avgScore: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 RFM 分析
|
||||
*/
|
||||
export async function analyzeUserRFM(phone: string): Promise<{
|
||||
found: boolean
|
||||
data: any
|
||||
}> {
|
||||
try {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const user = await coll.findOne({ phone })
|
||||
|
||||
if (!user) {
|
||||
return { found: false, data: null }
|
||||
}
|
||||
|
||||
// 计算 RFM 等级
|
||||
const rfmScore = user.rfm_composite_score || 0
|
||||
let grade: 'S' | 'A' | 'B' | 'C' | 'D' = 'D'
|
||||
if (rfmScore >= 4.5) grade = 'S'
|
||||
else if (rfmScore >= 3.5) grade = 'A'
|
||||
else if (rfmScore >= 2.5) grade = 'B'
|
||||
else if (rfmScore >= 1.5) grade = 'C'
|
||||
|
||||
// 计算价值等级
|
||||
let value: '高' | '中' | '低' = '低'
|
||||
if (rfmScore >= 4.0) value = '高'
|
||||
else if (rfmScore >= 2.8) value = '中'
|
||||
|
||||
// 生命周期标签
|
||||
let lifecycle = '未知'
|
||||
const rScore = user.rfm_r_score || 0
|
||||
if (rScore >= 5) lifecycle = '活跃用户'
|
||||
else if (rScore >= 3) lifecycle = '新用户'
|
||||
else if (rScore === 2) lifecycle = '沉睡用户'
|
||||
else lifecycle = '流失风险'
|
||||
|
||||
return {
|
||||
found: true,
|
||||
data: {
|
||||
user_id: phone,
|
||||
rfm_score: {
|
||||
R: user.rfm_r_score || 0,
|
||||
F: user.rfm_f_score || 0,
|
||||
M: user.rfm_m_score || 0,
|
||||
total: rfmScore,
|
||||
grade
|
||||
},
|
||||
tags: {
|
||||
lifecycle,
|
||||
value,
|
||||
behavior: user.tags || []
|
||||
},
|
||||
weights: { R: 0.3, F: 0.3, M: 0.4 },
|
||||
raw: {
|
||||
name: user.name,
|
||||
province: user.province,
|
||||
city: user.city,
|
||||
userLevel: user.user_level,
|
||||
totalAmount: user.total_amount,
|
||||
orderCount: user.order_count
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analyze user RFM error:', error)
|
||||
return { found: false, data: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 标签分布
|
||||
*/
|
||||
export async function getRFMTagsDistribution(): Promise<{
|
||||
tags: { name: string, count: number }[]
|
||||
}> {
|
||||
try {
|
||||
const tags = await getDistinctTags()
|
||||
|
||||
// 对每个标签计数
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const tagCounts: { name: string, count: number }[] = []
|
||||
|
||||
// 只统计前 20 个标签
|
||||
for (const tag of tags.slice(0, 20)) {
|
||||
const count = await coll.countDocuments({ tags: tag })
|
||||
tagCounts.push({ name: tag, count })
|
||||
}
|
||||
|
||||
// 按数量排序
|
||||
tagCounts.sort((a, b) => b.count - a.count)
|
||||
|
||||
return { tags: tagCounts }
|
||||
} catch (error) {
|
||||
console.error('Tags distribution error:', error)
|
||||
return { tags: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RFM 用户列表
|
||||
*/
|
||||
export async function getRFMUserList(options: {
|
||||
grade?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<{
|
||||
users: any[]
|
||||
total: number
|
||||
}> {
|
||||
const { grade, page = 1, pageSize = 20 } = options
|
||||
|
||||
try {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const query: any = {}
|
||||
|
||||
if (grade) {
|
||||
// 映射等级
|
||||
if (grade === 'S') {
|
||||
query.rfm_composite_score = { $gte: 4.5 }
|
||||
} else if (grade === 'A') {
|
||||
query.rfm_composite_score = { $gte: 3.5, $lt: 4.5 }
|
||||
} else if (grade === 'B') {
|
||||
query.rfm_composite_score = { $gte: 2.5, $lt: 3.5 }
|
||||
} else if (grade === 'C') {
|
||||
query.rfm_composite_score = { $gte: 1.5, $lt: 2.5 }
|
||||
} else {
|
||||
query.rfm_composite_score = { $lt: 1.5 }
|
||||
}
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
coll.find(query)
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.sort({ rfm_composite_score: -1 })
|
||||
.toArray(),
|
||||
coll.countDocuments(query)
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map(u => ({
|
||||
id: u._id?.toString(),
|
||||
phone: u.phone,
|
||||
phone_masked: u.phone_masked || (u.phone ? `${u.phone.slice(0, 3)}****${u.phone.slice(-4)}` : ''),
|
||||
name: u.name,
|
||||
rfmScore: u.rfm_composite_score,
|
||||
userLevel: u.user_level,
|
||||
province: u.province,
|
||||
tags: u.tags
|
||||
})),
|
||||
total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('RFM user list error:', error)
|
||||
return { users: [], total: 0 }
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
2025年12月16日 下午 4:01|1小时 12分钟 40秒
|
||||
|
||||
关键词:
|
||||
流量、标签、插件、数据源、板块、核心、算法、资产、服务器、用户画像、数据清洗、数据资产、价值模型、自然语言、任务调度、用户资产、开发设计、数据结构
|
||||
|
||||
文字记录:
|
||||
说话人 1 00:01
|
||||
工资这两天发放出去,有一笔款还没回过来。
|
||||
|
||||
说话人 1 00:15
|
||||
领投。
|
||||
|
||||
说话人 1 01:03
|
||||
这个应该现在都能访问吧?最近。
|
||||
|
||||
说话人 1 01:34
|
||||
陈路明那个部署他是不知道咋部署吗?
|
||||
|
||||
说话人 1 01:46
|
||||
那就开个会议,那个远程让他弄,要签名应该也没用,这个不是弄一下就好了,又不复杂。
|
||||
|
||||
说话人 1 02:28
|
||||
很卡吗?就这个,对。
|
||||
|
||||
说话人 1 10:22
|
||||
诶,这啥?
|
||||
|
||||
说话人 1 12:27
|
||||
就是首页。就你要看这些数据标签的这些数据,我们应该就在这个数据资产里面直接看就行了,看它是在哪一个类目。
|
||||
|
||||
说话人 2 12:45
|
||||
你项目的那些也要放。
|
||||
|
||||
说话人 1 12:47
|
||||
他项目就是标签,我们首页可能是筛选标签出来就可以了嘛。不是说你还要看什么?项目也要在上面体现,是吧?对,项目要它就是首页,它是筛选制的,它没有,那么我给你看一下,比如这个就具体一点嘛。就这边诶,就是我们现在首页,不就这个首页嘛?拿这个大的总的词嘛?总池嘛?这旁边不就这个嘛?那这边的话应该是指的项目的词,并且是可以筛选的,可以,我们就跟选千川一样,比如这个是,就是那个玩值的,对吧?然后这个是电销的,对吧?银掌柜,或者这个可能是那个小额通。
|
||||
|
||||
说话人 1 13:59
|
||||
通了,我这个我弄完之后,我在首页弄完之后,我这边是统计所有的用户数据,并且这几块可以定义的,可以自定义的嘛。然后我展开完之后,像这一个,我这个时候会出现一个需求是什么?这边有需求出来之后玩值这边比如玩值这边里面有用户会比较多,我在这些用户我要孵化另外一个私域,我就会做。
|
||||
|
||||
说话人 1 14:29
|
||||
一个操作,比如这个游戏的会跟大健康的结合,比如那个腰椎歪,腰椎疼痛的,对吧?或者那个肝不好的熬夜的,那里面的产品就很多了,这个就变成游戏加大健康,这个是游戏加金融,这个加游戏大健康的用户,我就要筛选出来一个流量池,这个流量池就是直接导到什么白金海岸的私域里面去了,让他们去加了,是吧?就直接这个,直接就导过去了白金海岸,是吧?那我在这个上面的话,操作就相对会比较容易一些。
|
||||
|
||||
说话人 1 15:30
|
||||
然后那些用户的那个评估就在这个总词这个上面,其实这个上面理论上是不变的,现在更像是个工具,知道我们还是会以那个用户为中心去做这个是第一块,他就点击进去就是筛选。
|
||||
|
||||
说话人 1 16:04
|
||||
然后第二块的话才是那个,这个就概览的这一个嘛?以这个为核心。然后第二块的话就是细细分下来的东西了。
|
||||
|
||||
说话人 1 16:27
|
||||
第二块就细后细分下来就。中台,中台这一个现在问题是不大,就中台这一个嘛?我们,诶,我把这个屏幕。
|
||||
|
||||
说话人 1 16:46
|
||||
第二块的话就是你数据中台都可以,你治理跟洗这个数据是一样的,就这一块的话就是数据源来源这个。
|
||||
|
||||
说话人 1 17:04
|
||||
然后那个数据清洗,就是现在做了一些分类,那这个数据监控跟完就这一块可能要去怎么样去整合一调?还有调度跟那个数据源这两个板块是不是能合并的?数据源那个任务调度是怎么设计的?
|
||||
|
||||
说话人 2 17:29
|
||||
要有一些去监控它的一些状态啊。就比如。我这个。质量监控就是看你整套的这个执行下来的它的一个效果是什么样的呀。你如果不知道效果,你都不知道它的结果是什么样的任务调度,这个就是它的执行的一些任务的一些情况啊。
|
||||
|
||||
说话人 1 18:00
|
||||
那这个不就变成一个小的按钮,它不需要放在这个组的那个部分了,可能就某一个页面的在清洗页面的。那你这个得。
|
||||
|
||||
说话人 2 18:10
|
||||
如果叠在那里面,那你那里面就变得二三级了,就很多了,都没必要这个,就直接那个。
|
||||
|
||||
说话人 1 18:19
|
||||
或者这个专门有一个调度的地方,不然看起来会很乱,你这边的话你就是集中很多数据的过来的地方,其实最重要的。还是数据源?
|
||||
|
||||
说话人 2 18:30
|
||||
就是接入,是吧?第二个就清洗,第三就是看整个数据的一个跑起来的一个质量情况嘛。第四个就是整个任务。
|
||||
|
||||
说话人 1 18:39
|
||||
管理嘛。那能不能结构不对?能不能这样?就是数据中台就是只有一个,这里面是这一个,包括数据清洗,它应该只是这边的几个按钮而已,是不是你应该这几个放在这个地方,不然它是变同级的,它可能就一一个一个这几个按钮,它是在。
|
||||
|
||||
说话人 2 18:59
|
||||
这本来就是同级的呀。
|
||||
|
||||
说话人 1 19:02
|
||||
清洗怎么会是同级的?它正常扭转的不是从这里进来之后开始第一步清洗和执行的情况。第一,你是不是要。
|
||||
|
||||
说话人 2 19:11
|
||||
接入吗?是吧?接入你要管理很多接入,然后进行清理啊。对。
|
||||
|
||||
说话人 1 19:18
|
||||
这像这里我们新建一个数据源,新建完之后它的执行步骤不就 123 吗?不是,所以它。它本质上就是一个标签,它没不用去展开啊。不能。
|
||||
|
||||
说话人 2 19:33
|
||||
这么说,就是看你以什么样的角度去看。因为。
|
||||
|
||||
说话人 1 19:38
|
||||
你如果你走关联形式的话,一定是走这三个往这方面走的,知道这三个都是为了这个服务的功能吧。你这个数据源就是管理。
|
||||
|
||||
说话人 2 19:53
|
||||
你的接入,就是也。一个就是你要接,是吧?第二你要清洗,第三就是查看它的效果,是。
|
||||
|
||||
说话人 1 20:05
|
||||
吧?它就是讲的是。这,这个没问题,这个没问题,就是它这个,这三个是不是为它服务的嘛?是不是这三个相当于它的插件而已嘛?为了这个数据源的整个进度?
|
||||
|
||||
说话人 2 20:19
|
||||
对呀。里面的数据源也是属于。觉得是不用去纠结。
|
||||
|
||||
说话人 1 20:28
|
||||
这个。我,我知道这个是逻辑关系的问题,以后就是。不同的逻辑,不同人看的逻辑。我,我们在。是吧?我们所有的东西都是一样,它是一定是以怎么样把用户导进来为核心的,所以这一些是以工具层面是没有问题。知道,但是你反过来看,你在以用户层面,他是你这个用户进来之后,第一步、第二步、第三步,你得有逻辑关系才是正确的,是不是?你不能是。说的那个是。
|
||||
|
||||
说话人 2 20:57
|
||||
嗯,逻辑关系,那我觉得。工具。大的模块。
|
||||
|
||||
说话人 1 21:03
|
||||
这你按模块分类是没问题,就是你要以想的是咱们这一个东西一定是用户为中心的,它只是用户为中心,而不是一个工具去查看整个系统的运转的程度和数据库运转的程度。那个没必要,那只是一个面板而已,我们要的整个的东西一定是它可应用度要很高的。比如我现在刚刚那个界面,我拉一下它这边是不是能直线在运转的一个状态?是不是我能知道哪里出错?或者他清洗的一些东西?或者他是我们分析出他需要什么类型的,那个什么类型的数据,对吧?我们现在可能这个什么地方、什么类型的数据能分析出来,能让我们去对接出来,对吧?哪里有或者哪里有这类型的数据能对过来?这个是结果性的东西。不是,不是深不深的问题,我不是说深的问题,这个不是深的问题,是这一个数据,这个你可以这么理解啊。
|
||||
|
||||
说话人 1 22:09
|
||||
首先这一个是用户池,我们的大池就一类是大池。对啊。这个是支流,这个山川什么流进来的?那流进来的,你是不是流进来的?那个门闸也好,垃圾治理也好, 7 的 8 的治理也好,这些都是为了服务这个流进来这条线的清洁度,是不是那本质上我们。我们只是第二个功能,只是为了让它流进来,所以流进来我不可能去关注它。再建个大坝,再建一个,再让谁去工人去清理河道,再去什么什么?这些我不管,我只管你流进来。这条线最终是不是我要的纯净水?那你怎么中间用的什么样的工具我就不管了,知道吧?我可以去,你就自由地去调配,但是我是不管你这个东西是怎么用的,那这个整个池子它。
|
||||
|
||||
说话人 1 23:00
|
||||
的这个用户进来,对吧?你这个是大池子,这个是我们第一个的流量池,大的池子嘛。那第二个的数据中台它核心一点是什么?我这边有很多条线,你这个是我这个流量池需要什么?我们需要什么?它分配什么嘛?这个就是支线的支线流量,支线的用户流量。那我只要卡,卡一个啥?你这个支线的用户。流量是不是?对,符合我这个标签,符合你才能进来,不符合我我不让你进来。
|
||||
|
||||
说话人 1 23:35
|
||||
知道,那你这个,你想达到我符合的这一些东西,你去生产插件,ABC、d、e、f、g,不只是这几个插件,以后可能有无数个插件,我不管的这个都是一些模块,你怎么样去过滤把?我们之前不是有做过那个京东的什么什么这些东西吗?你怎么过滤什么用工具我就不管的,你只要达到我的要求你才能进来,只是现在是我们做的要求。是符合我们自己的要求,知道这个是这一个板块。
|
||||
|
||||
说话人 1 24:12
|
||||
不是,这个是基础架构,不是细,因为现在看不是页面,是底层的架构,你知道如果是纯粹调页面,咱们讨论这个。是没有意义,这个这版就过了,知道吗?为什么要拎开另外来讨论这个事情,知道吗?它是。你放在那边。
|
||||
|
||||
说话人 2 24:37
|
||||
跟你放在那个。
|
||||
|
||||
说话人 1 24:39
|
||||
数据源里面差别不大的。我,我不是说页面的调整,我是指的是什么?这个数据源也好,你不要不是叫数据治理,我们不是为了去调整合这一个治理,不是治理,只是过程,它只是工具,知道吗?它不是我们要的结果。我们现在要的是这个数据流进来的,最终流进来它是有效的,能对过来是有效的。所以增加很多插件,我们增加很多插件或者标签化或者 AI 的这些功能,它只是来服务这条支线的这个流量池进来的这个是大的池子,这个是外部的池子,这个是规则,规则是我们自定义的规则,这个是很重要的一个点,你看如果是这一个功能。
|
||||
|
||||
说话人 1 25:29
|
||||
回头他们要开发一个功能出来,那就不一样了,他们要开发一个功能,就是这个功能就是去数据库里面去筛选指定的数据或分析指定的数据报告,不是的开发一个功能,我是我们要符合这个要求,所以这个功能是在这个用户里面去筛选出精准的符合条线的池子,可能这里面留个 1, 000 万条,我最终通过筛选,我只要1万条,那这个外部的进来1万条是我要的,其他我不要。
|
||||
|
||||
说话人 1 25:59
|
||||
而不是我去查外部的那个流量池过来,是吧?我如果去查外部的流量池是没有意义的,是不是它一定是服务支线的呀?讨论的重点是我们就神射手,它是以用户的流量、用户画像为核心的一个事情。知道它是用户资产平台,所以我们全部都是以。用户画像以及用户画像形成的用户池子为核心在讨论,知道不是我们在,不然它就变成一个纯粹的查询工具,那查询工具我现在用 AI 更快,我直接在本地用 AI 绝对比这个还快,是不是?我提示词全部写好了?是不是?那它就是没有意义了?这个东西第一个我们能查,第二个我们能知道整个,比如我说一个需求。
|
||||
|
||||
说话人 1 26:59
|
||||
求对吧?我现在要知道厦门这边哪个地方卖那个烤串最好卖,转化率最高,是不是?我肯定,如果我是直接问 AI 的情况下,我们这个中台能不能发挥这个作用?对,这个作为一个底层的支撑,对吧?那他底层支撑,我提这一句话,他肯定是先到这个。流量池里面去找,是吧?他肯定是在这个里面去找相应的标签出来的,然后再推送给我呢。他在推送什么用户?比如五里那边,对吧?五里厚普那边吃烤串的最火,那因为他有1万个用户,经常吃的有1万个用户。其他地方可能只有 3, 000 或者 5, 000 个用户,那么他是要作为他的查询的底层的支撑的。这个里面也有啊。里里面有,是我们现在可以通过去跟他聊天,所以才有这个,我看到了。
|
||||
|
||||
说话人 1 28:30
|
||||
我,我的意思是什么?这个我们现在做的这一个东西,包括展示的东西跟逻辑关系都是以用户为核心的,那以这个为核心的目的是让我们以后能提能对话的时候,它才能做支撑,它才能给我输出数据。对啊。然后对,这个是等一下一个板块要聊的,所以这个支线的这一个。链路这一个用户是我们是要有很多的,就是你的数据中台数据清洗,包括 7 的、 8 的数据叠加或者那个,对吧?对,过来这一些都是为了清洗之前链路进来的用户,我们有多少用户能进来?
|
||||
|
||||
说话人 2 29:19
|
||||
比如我。这也是需要管理的。
|
||||
|
||||
说话人 1 29:24
|
||||
这个就你管理不都做好了,他只是整理一下逻辑结构,因为。这个跟什么有关系?跟逻辑结构有关系?不是跟那个,跟其他的东西那个有关联,不是的,知道吗?我跟你讲一个,这开发下去的时候,到时候要扩展开发会非常麻烦,我当时我反复强调还是会开发歪掉,你知道吗?我说一个其他的例子,之前存客宝都是工具,每一个都是点,最早的一个版本是无限的。工具的,那现在不一样,现在以设备为核心和用设备里面用户为核心就两个事情没了,他们就是去开发微信底下的功能,而不是设备一块,微信一块、发朋友圈一块、数据统计一块、财务自理一块。类。
|
||||
|
||||
说话人 2 30:19
|
||||
的方法、归类的方法不一样,但是它的底层逻辑还是一个一个。
|
||||
|
||||
说话人 1 30:27
|
||||
底逻,是的,底层。逻辑是一个功能点,它思考方式不一样,他们到时候会解释不清楚,就是我要发起一个需求,不,不是界面,不是你到时候它设计开发设计的时候会出现问题的,你就是我们一开始就要归类清楚第一个这一个是什么?第一个板块是什么,对吧?大的一个,一个流量池以及流量池的一个属性。
|
||||
|
||||
说话人 1 30:53
|
||||
第二个板块就是汇总数据以及汇总数据的筛选,你能一句话讲清楚。这个事情第三个,这个不是流量的一个问题嘛?你第三个不就是那个你,我们对一下你就知道了嘛。第三个你不是标签画像吗?跟价值模型吗?其实标签画像跟那个价值模型这两个应该也是拼在一起的第三个板块,我跟你讲它一点是什么?流量都进来了,那里面是干嘛?我重新起一个,你看我们这个流量池都进来,都。汇总了第三个板块是干嘛?这里面的流量池的归类方式都是各种标签或者标签中的叠加,它是这个样的,不管你是 AI 分类还是原有的标签体系,还有 7 的、 8 的这几个标签分类的形式全是一个东西的,你可能标签的模型a、b、c、d,这个跟第二个是一样。
|
||||
|
||||
说话人 1 31:59
|
||||
第二个是标签慢那个流量池的清洗,那这个也是相当于在做归类,它不是清洗,它在归做归类,我们只是归类的方法,是用 AI 归类还是用标签归类,还是用其他形式来做归类?那归类的核心还是一样给我们做那个用户资产做支撑,因为它归类好了,我们可以快速地去找到,并且点一下就能知道,比如这里这边是爱吃地摊,就樊登的。
|
||||
|
||||
说话人 1 32:29
|
||||
这一块我就能直接拎出来了,它查询速度是不一样的,这个是包,所以它这个东西值多少钱嘛?它的价值模型,你价值模型不是独立的一个标签,对吧?这一个私域值多少钱?然后有什么样的标签?它是就会变成你这个就变成一个对象了,它这个圈圈拎出来它就有属性了,你能描述清楚。
|
||||
|
||||
说话人 1 32:59
|
||||
属这个点它就有属性了,有 IFM 值多少,对吧? AI 的有什么属性?有什么属性它就能产生属性了。这个你能解释得清晰,那他们在,老王他们在设计结构的时候就比较容易,因为不然数据它的词条会很多的。我看了那数据库,知道存客宝这个回头还要把那些数据抛先重新整理,因为。它们全是横向的,现在数据库全是这样的,它是不支撑不起查询的,一定会出问题的。正常的数据一定是分类好之后,底下它是立体的品类,它是立体的标签,这个才是正常的。现在还没到量化的那个数据库,知道向量数据库还没到,知道吗?这查询会出问题的。特别这个数据一方面没有做归类,如果是传统的模型,你工具当然一张表设计没有关系,但你一多的时候你没有相关联,这些表是没有相关联的,会出问题的。
|
||||
|
||||
说话人 1 34:16
|
||||
他查一个为什么老王跟我说查一个 7 天,我查一个 40 秒,他是不一样的,数据结构是不一样的, 7 天跟 40 秒是天差地别的,这才1亿条数。数据知道,因为他一张表去翻这个就出问题了。我,所以我现在让他们从 Mysql 变成那个 Mongo 数据库,这只是那个基础,所以我们在搭这个的时候可能得注意一下,就是那个标签的那个画像,当然那个标签体系这些功能都出来,只是这个逻辑关系怎么样去解释他们会更。更清楚一些,不然这个会跟查询速度一定会受影响。
|
||||
|
||||
说话人 1 35:04
|
||||
就你查过来,我查的本来查的是一块,查的一块才会比较快,就是我查的这个东西就是厦门的点的妈的私域,这一整块我就拎出来,然后点的妈私域里面有什么什么什么什么什么,它是一个立体,我像查字典一样,我们可以很精准的去查,是吧?找到点的妈三个字直接。在里面翻每年消费每个月消费 1, 000 块的用户,我就直接拎出来结束了。那如果你是用这个表的形式的话,他是在几亿条数据里面去一张张查,现在用那个 MySQL 服务器为什么经常崩?后面换就是一条在查那巨耗能,是吧?你再怎么大的服务器也支撑不起,会出现这种问题,知道吧。
|
||||
|
||||
说话人 1 36:03
|
||||
那这个里面也是一样,你当然你这个这逻辑关系的问题,你这个用户画像就最终点进去,你看这里是不是点的嘛?他这样设计才会有结构感点的嘛,过来之后不就这个嘛点的嘛?整个私域的那个总的消费额度,对吧?客户画像私域的产出产值是多。多少?平均是多少?这些就是它的那个维度,到时候我们要调,就有很多数据可以调了。
|
||||
|
||||
说话人 1 36:36
|
||||
那我点了吗?我点击进去点了吗?之后是地摊的这个人的画像,就你现在不是做好吗?这个真正的那个用户画像再出来吗?对吧?那这个用户画像下去的话,他的一些人设朋友圈呐?他又有属性了,其他设计起来就。非常简洁了,一个一个外流,第三个板块是内流的点击和展开,它只是点击跟展开而已。
|
||||
|
||||
说话人 1 37:08
|
||||
我这边点击展开就有点的吗?一堆点的吗?展开就有一堆用户画像没了,那所有东西都是来服务这个用户画像的所有的功能,我包括我们后面扩展的什么功能都是,都,你可以都理解,都是插件,都是为了来服务它。更好地去扩展它的标签的,包括你。我们设计的很多算法也好, 7 的、 8 的只是为了把这个归类归出来,归得更细,把它的分母那个变得更小,更容易去做筛选。不然他会很容易那个这样容易找,因为你如果你去找查一个人的情况,那太复杂了。你这边几千万条,你要这一个人可能1万个标签,当你出现1万个标签的时候根本就没法查,你查1万个人服务器都白白了。那我要查这个1万个人,我就找到点了,就结束了,后边这一张表就过来,再差一点,再找另外一个符合这个类似的,找第二张表跟它一并直接出来了,它就两步,那两步完之后我查找完之后再。
|
||||
|
||||
说话人 1 38:29
|
||||
插件干嘛的呢?刚刚说的插件再进来这个里面去计算,我两个查中间的这一个出来,对吧?我这边再算法,再继续计算。那这个就很快了,他就只计算那一撮,不然第一步查询就卡住了。这个这一方面的,我现在是让他们快速先去了解这些数据库的。结构,不然处理起来无限麻烦,是吧?包括我现在自己在写那个查询,为什么就老王有一天问我为什么会我查这么快,几十秒就出来了?我能漏一个人,我就几十秒就非常快,是吧?是因为我里面我就做了字典,没其他的知道,其实这个,这个其实也是一个字典的一个一个功能快速查找用户。
|
||||
|
||||
说话人 1 39:30
|
||||
用户的池子,那我们所有的插件的功能只是为了把它做更好的归类,里面的归类越多,你看千川为什么?早期二,千川是 21 年 4 月份出来的,千川出来早期是所有的流量池,它早期就这个千川,早期就这个混沌的一个状态,没有其他的,那为什么千川现在能这么值钱呢?是吧?千川的核心逻辑它在这个底下,对吧?它一开始没有这些圈圈,都是一些基础,跟我们这个一样的混沌状态,很多只有基础的标签,那千川做完之后,这个是千川,千川做完之后很多人在跑视频,是不是在跑数据?点赞、评论、转发在跑数据,在帮它搭流量,池千川所有的直播带货的公司都在帮千川搞流量,池千川就不断地把人。
|
||||
|
||||
说话人 1 40:30
|
||||
把人拉到这边,推到他们的这个,比如这个直播间的流量池里面去,那直这个直播间里面的流量池或者视频的流量池,再去看他们的购买率评论、点赞、转发,对吧?那这个客户是有效的,他就只再给你加到这个属于高价值人群,他就出现一个高价值,以后这一帮人买了纺织的或者喜欢买什么的,他就到这个池子里。
|
||||
|
||||
说话人 1 40:59
|
||||
一遍的,那我千川投流,我就把这个池子丢给投流的直播间,他是不是转化率就高很多了?千川就一直在洗,那现在千川很成熟了,全部都是付费流量,你就是付费流量,就是把这些投到你的直播,把你的直播间你投钱,我就给你这个,你们十个人去竞价,看谁的转化率高,千川的底层就是这个没了,是吧?那他要解决高频。
|
||||
|
||||
说话人 1 41:29
|
||||
呢?千川要解决这个是不够的,我们这个只是低成本的行方式,千川要 0.001 秒的反应,我还得 40 秒,对吧?所以他要做分布式的服务器,这个就另外一回事了,对吧?分布式的数据库,那我们现在用这个 Mongo 是可以实现分布式的,只是它成本会很重而已。那这个后期挣钱了再说前期不考虑是这一点,但是前期我们一定是考虑。
|
||||
|
||||
说话人 1 41:59
|
||||
这个流量池的归类的问题才能实现。比如这个词,你在他们每天,我举个例子,银掌柜,这个是银掌柜,我每,我们每天给,这是金融的,金融的,厦门有做金融贷款的,我导到银掌柜的手机里面,那银掌柜通过手机打电话超过 30 秒,并且有付款意向的就会进到这个流量池,说明他有贷款需求。那么以后银。
|
||||
|
||||
说话人 1 42:29
|
||||
掌柜要开另外一个业务的时候,我是不把这个流量池直接导到那个银掌柜的那个里面去就可以了,他其他的手机就可以,他就不用每天打 300 个电话,他打了 300 个电话,这边筛选完之后可是这边的 10 倍,他也可以给其他便宜劳动力去打,那这个就很快了,就筛选很快了,对吧?这一些就会越来越。
|
||||
|
||||
说话人 1 42:59
|
||||
清晰的标签越来越多,所以我们在标签回流跟叠加的这一块就是要比较灵活了,是吧?这个是筛选的这一个,这第三部分的事情,这有什么疑问没有?反正我先过一遍,我们等一下可以探讨一下这个事情,因为我会考虑他开发的可能会碰到的一些问。问题,因为我们如这个肯定往下的,不然一定又数据等你跑了,非常非常多数据的时候,然后我们再去重构,那这个就大大调了,这个就不是小小事情了,知道吧?事情就很多了,我现在也是,所以我这两天为什么想让永平他们在设计的过程当。当中出那个架构图就出的时候,我就知道里面到底是怎么回事,他不出,按照他们的经验去跑,我已经发现很多了,一直在改,做完这一版可能又要改,就很麻烦。对,但我们提前给他罗列好就不会有这么多那个改来改很多版的一个问题,改是避免不了,但是我们尽可能知道的情况下不改嘛。
|
||||
|
||||
说话人 1 44:37
|
||||
所以你这个就要多出几张这种图,这种逻辑,我等一下讲完,你可这 5 个板块可能就是 10 张图就解释完把,然后把流程图都弄一下嘛?然后再给改一下。你的意思改一下,然后再去做界面的调整,它其实不是界面,你这些功能都实现了,都已经完成了,我觉得这个没太大问题,只是。你把这些问那些功能怎么样?那个让它是有逻辑性的地去跑,对吧?所以这我是想的一点是什么?这个标签画像的都是为了用户的池子服务的,知道我们这一块等于在做什么?私域版的千川,私域版的千川,这个是我先把其他的都说完,我们。再等一下,再碰,诶,你能看到界面吗?飞书啊。
|
||||
|
||||
说话人 1 45:45
|
||||
我知道,但要录下来啊。
|
||||
|
||||
说话人 1 45:55
|
||||
所以一个一个是汇总看的。一个是外部流量进来的,另外一个是内部的流量的那个整理跟切割就价可能价值模型跟标签画像这两个就是变成用户画像了,也本质上就是用户画像和一个数概览,一个数据中台,一个用户画像就三个,然后你那个另外一点是 AI 的这一个板块。
|
||||
|
||||
说话人 1 46:29
|
||||
快嘛? AI 的这个,它是这样的,就 AI 的这一块是要变成什么?变成一个那个类似于那个 AI agent 的那个功能,就你可以把这一个 AI 其实它是抽象成一个一个人,一个人。这个人就是我,我们可能就是跟他直接对话,是吧?直接对话,是吧?然后我们跟他对话完之后,他是可以就调用我们前三个板块的,在这个里面去更细化。
|
||||
|
||||
说话人 2 47:18
|
||||
自然语言查询。
|
||||
|
||||
说话人 1 47:19
|
||||
一个,对,一个自然语言查询,这是一块,第二个的话它是应该是一个,一个那个。那个 agent 的集合,理解,它就是一个 agent 的集合,我们你看为前几天让你们去看那个宁光,宁光其实它的本质上它也在搭agent,它其实不成熟的,它这个 AI 生成应用的就是在搭agent,就是在给它的数,那个数据跟它的那一些那些用户的那个。
|
||||
|
||||
说话人 1 47:59
|
||||
去优化它整个东西,它里面就会有什么,你数智员工,不是数智员工那个数据的一个 AI 的一个agent,我们这个是可以自建 agent 的数据的一个agent,是吧?它就是一个集合,比如我现在一个一个要做一个,就我们原来不是有。有弄这个吗?这个进来提问,正常逻辑就是提问,然后提问向他提问,就这里他的模块就是提问,第一个就提问,提问完之后就知识库,知识库其实就是数据库,是吧?就通过我们的数据库,对吧?然后再往后才会到那个就输出表格或者样式。或者网站或者那个流量池,它每一个的功能,它是这个,那这里面是我们定义好的agent,你里面其实已经有很多的,比如它做一些数据清洗的, AI 数据清洗的嘛。
|
||||
|
||||
说话人 1 49:21
|
||||
的agent,那这个提问中间它有个提示词,那这个流量池弄完之后是不是他自己导到他自己的存?存客宝里面去,或者他微信里面,通过存客宝找到他的微信或者公司这个用户就挪过去了。是不是他这个提问应该是往这方面去走的,对吧?你现在生成那个 agent 的模板和东西其实已经蛮多了,就怎么样去。
|
||||
|
||||
说话人 1 49:59
|
||||
这种嘛?然后那个规则跟另外一个的话,那个智能的预测,你不是有一个智能预测嘛,对吧?这几个板块其实你都已经做完了,只是它是一个集合,我们是从集合就等于智能预测啥,对吧?预测消费能力,对吧?预测它的那个消费周期,就像现在他们做。做那个什么游戏的游戏?他们就 3 月份、 6 月份、 9 月份、 12 月份,每每三个月一波,对吧?他就很容易去预测为啥?因为他浏览量是最多的,购买是最多的,用户活跃度是最高的, 369 立即就知道了,但这个模型不是我们来建为主,为什么宁光他要把这一些 agent 的对外放?他是让你在帮他们。
|
||||
|
||||
说话人 1 50:59
|
||||
清洗所有人,全网的人在帮他们清洗,他们的中间核心是数据核心,不是这些表面上的工具,表面上工具都是你可以创造无数,我们只是这里就制定一个 agent 的规则,一定不能走向工具型,我们如果做好工具,这种无数的工具怎么可能做好?我们就做好一个生成工具的规则就可以了,对吧?所以它本质上也是一样的,第四个板块也是一样, AI 跟 agent 去提问清洗,这个是数据库也,你也可以说是流量池,数据的,流量池的包我以前要去查一个,就像我就发现那个京东之前有一个套利,是干嘛呢?我做一个事情。
|
||||
|
||||
说话人 1 51:59
|
||||
去9月,每年的9月十五号是苹果发布日,知道吗?这9月 15 号是苹果发行的日期,那我干嘛呢?我为什么知道这个事情呢?我是去看了百度指数,对吧?阿里的指数我才知道诶,这个突然搜苹果两个字大涨,对吧?我是知道的这一个这个事情,所以我才去在九月一号到九月十五号之前。
|
||||
|
||||
说话人 1 52:29
|
||||
我是去把淘宝的商品宝贝给他放上去,让大家预售,我做预售让很多人他直接买我这个东西,买完我这个东西之后十五号就发布了,那我手上已经有淘宝很多的订单,我直接淘宝的订单直接就能贷款出来,然后直接来买货给他们。那我就看破不破发,我等个 5 天, 10 天交付,对吧?一破发那就 20% 利润。就囤苹果就行了,而且我都不用钱,直接就用他们的钱去淘宝那边那个带那个货款出来,直接买了就挣 20% 利差。
|
||||
|
||||
说话人 1 53:08
|
||||
那如果没有我就退钱给他就行,我没有成本的知道那这个怎么来的?这个就是从这些数据里边来的,所以现在有这个东西他能发现更多的东西了,然后我们这个 agent 就能干嘛?是不是放到存客宝里面去就可以了?一个工作台的一个功能。可以,或者直接在首页上面的一个提问就可以,它做底层的支撑,但是它是查查数据的,我们后台就能看到很多什么什么的 agent 的一些归类,各种的 agent 的归类,然后刚我们弄的很多的标签也好,这些都是作为这一些的底层的东西基础的这个 agent 的。底层的东西知道吧。
|
||||
|
||||
说话人 1 54:13
|
||||
自然语言查询它也是一样,它不就提问在这,那它是里面的功能模块,它在这是不是它就在这?然后这个的话就是我们第二个板块的东。东西,知识库、数据库第二个板块清洗出来东西,这个提问清洗出来东西,最后第四个板块才,它才形成简易的流量池。我们在第二个板块这个数据流的过程当中,它还只是普通的,要是可能都要手动或者查询而已,对吧?还没有那么的标准嘛。比如在存客宝,我要 1, 000 个金融的客户, 1, 000 个金融的客户出现。变成一个流量池的包,然后他付款结束,是吧?或者他付款直接导到场景获客里边去加一个多少钱,这个就变活了,他本质上搞这个东西是为了让整个组织是活的,是吧?然后再最后一个,就是比较重要的那个数智资产,是吧?
|
||||
|
||||
说话人 1 55:44
|
||||
就一个那个数据资产有一个其实也是一个逻辑的,你可以理解成数据资产就是一个项目包,知道吗?我们的数据资产是一个用户的项目。项目包什么项目?值什么钱?多少钱?具象化的那个项目值多少钱?然后这里面就是很多项目,银掌柜的项目跟标签分类最大的区别是这个项目出来之后,它是一个项目的价值包,就它的整个项目值多少钱, IFM 值多少钱?然后有多少人。那比如有这个维度,有多少人投了这个数字资产?就像我们现在就一个项目,就有三四个人投嘛?每一个 20 ~ 100 万,对吧?那这个人他投了多少?里面有多少个用户?值多少钱,对吧?比如这个银掌柜现在三个股东,每个股东投了 100 万,那 100 万他们交付的结果是什么?交付的结果是那个就总的是 600 万嘛。交付的结果是就是一个用户干到一年能有 600 块的利润,那他有1万个用户就结束了银掌柜的目标,就干这个事情,他的总盘就值 600 万启动,那这个项目很好很好找你从你做 600 万的启动的过程当中,你直接就是展示给有一个页面,因为这个是后边的那个板块嘛。
|
||||
|
||||
说话人 1 57:29
|
||||
做数据资产投资的另外一个APP,那这个 APPAPP 弄完之后,你是知道项目展开有什么样的人投?有这个项目里面有什么样的团队,对吧?然后他的单粉产值多少,对吧?那这个都是上面的 1234 去提供的数据做支撑,一个单粉产值,一个用户的那个估值,一个是这个是实际的产值,然后多少人投?然后他。们的收益率,对吧?就等于这个,你可以理解,这个项目包就是一个面上的证券公司,他我们这一个只是数据做支撑,后面开发的时候是直接可以调用的。我要搞这个 APP 做出来就是把这一些有多少个项目,这个项目归类到这个里面,然后直接让别人投就完了,线上直接投就完了,不然他们现在那。一个他们几个?他们不是投了 300 个了吗?那么这几个投了 300 个,这几个他是里面有多少人?现在的实际的产值是不清楚的,知道,只知道现在每天发了 7 台手机出去,每天每一个人能加 3 个人,那每天有 20 个客户来公司,就那观音山那边,对吧?每天就 20 个左右,平均他们来公司,对吧?那他这里。
|
||||
|
||||
说话人 1 58:59
|
||||
的话,目标肯定你要达到1万个客资,那长着嘞,知道吧?我就很清楚这个时候还要融 300 万,达到1万个客资,我是目的就是要把这个手机现在放了 7 台,我放 70 台,放大 10 倍,对吧?那一天 200 个,对吧? 30 天就有 6, 000 个了,买 600 个了,对吧?一年就 7, 000 多个了,一年就差不多结束战斗了,大家才敢投。钱,那他里面看,诶,这个收益就像上个月放了 700 多万出去,那这个可以,对吧?那这一个就可以能算得清楚了,这个是为了能大家能看得可视化多一些,里面就是一些细节嘛。所以这一个点就是我们要展开出来,然后里面是干嘛?你可以理解他的单粉产值也好,或者以后有什么 CLEM 的。
|
||||
|
||||
说话人 1 59:59
|
||||
GLV 的模型或者 RFM 的模型,估值模型跟这个用户生命周期的模型,它只是给这个项目做一个算法的清洗而已,这个就是一些插件,所以它以后的结构就很简单了,一个大池加上小池加上清洗的插件没了,每个板块都是一样,那他们很。很容易抽象,对吧?他们很容易抽象出来,或者算法插件没了所有的东西结构都是一样的,所有五个板块都是一样的,大词小词,清洗插件没了算法,他们去架构起来,这几个板块都是命名,都一致了,都一致,所以就只有三张大表。然后以后下面可能就是 6 张左右的小表,那底下全部是就那个向量的数据库去做查询,很快跟查字典一样,你查个人三次就查到了,没定位一个人多简单,对吧?中国福建,然后这1万个人去搜,对吧?那你交叉,有交叉那就更好找了,对吧?能找。一下就两下就找到了,就这个是为了方便他们去理解这个是怎么回事而设计的嘛?等我们这样弄下来会结构会清晰,那五个板块是,这五个板块是刚好是赋能我们现在五个 APP 的,以及未来的所有的 APP 就很好做了。我要搞一个啥东西?那太容易了,这几个我就把这几个板块我就调一下数据就出来了,甚至就 API 调一下就出来什么 APP 都是很好很好做的,他不会去,不会卡壳的就可以无限生产。像那个刚刚那个版版,那个版那个 agent 那个版本,是吧?然后那个系统监测这一些功能,我觉得这一些就是直接。
|
||||
|
||||
说话人 1 01:02:29
|
||||
放到这个里面去就行了,不是为了工具,这个本质上是为了把这个那个体系做完善,是吧?然后扩展性后面那些插件式的东西都是可扩展的,是吧?所以就一个一个回流,一个池几池子里面就跟钓鱼似的。是的,你去筛选另外一个的话,就是他的那一个。那一个 AI 的那个 agent 的那个设计跟选择,是吧?这几个板块其实弄好就差不多了。那你看我,我现在在做一些东西你就知道了,比如我。你看我最近看像这种,我不搞了,本书,写的本书。那写书。书之后我就直接让很多做售的那些人直接去帮我分发,里面就是 37 个人分享他最赚钱的一个月,我写的这个书写完之后所有做切片的我卖 9 块9,全部人 9 块 9 全部拿走。
|
||||
|
||||
说话人 1 01:03:42
|
||||
我做这个的过程当中为的是什么?为的是获客他们买完书之后是直接是到我的那个 KR 的那个后台的,是吧?退出一下,对吧?他们就一个一块钱,然后分销全得。有购买就购买,分销全得。那我做这个干嘛呢?做这个网站就是为了来找什么这个人他在我的网站上面付款,他是等于这一个进完之后是到这里面来的流量池里面来的,对吧?可能他有这个有新的网站,也算是网,不是agent。网站也算是一个流量池,对吧?这个 APP 可能就是一个流量池,不管是易思的还是我的,他是不是点击进来?那这边是不是我算法?其实就是我的网站看了什么什么东西,打了什么什么标签,进来之后他在这个流量池里边一筛选,我就立即知道这一个人他的所有画像,甚至他住哪里,我是非常清晰这一些的。那后台我们直接就完善数据。去了,知道吧?我就知道谁买的这个书,派对里面这个谁?他玩过什么什么游戏? 7 的, 8 的这个后台的数据一完善是很清楚的,对吧?这个才会有价值,而不是买书。
|
||||
|
||||
说话人 1 01:05:15
|
||||
我无非这些东西我都可以送的,这些全是送的,对吧?我每天写都是送给他们的,对吧?让他们反正一天一块钱,对吧?还有像那个就这种,你做这种网站出来是很快的。
|
||||
|
||||
说话人 1 01:05:52
|
||||
就是本质上可以做所有的网站,像搞这种是。是连发布都连一小时都不用,不需,都不需要一小时就可以直接发布到那个春节要到了,你看这个,对吧?这个是我另外一个朋友要做的,但我要看他做不做的话才往下做。这个跟为什么我让陈路明做那个MBTI?我要看一下速度怎么样?像这种做出来这个是非常快的,在就像 80% 能用的就直接上了,对吧?像这个就一个他们有。宣传的渠道它是那个,嗯,就美俏的那个,你是不是跟我去过美俏?方老师?对。它这个就是一个,对,就剪头发后悔就玩三个月嘛?那这个东西你就上传一个照片就可以了,对吧?我只是没对 AI 而已,我搞一个照片。对吧?我只是没对接 AI 而已,他就上传。
|
||||
|
||||
说话人 1 01:07:06
|
||||
那我就知道他什么发型,剪的什么头发,他自己选发型,对吧?有剪选这个,没?对,AI,他自己生成这大模型,弄完然后付 5 块钱,不然就是模糊的。他是不是想看付 5 块钱就进到我们流量池了?那进来,我是不是能知道这个人的?所有的东西就是他分享还能得两块5,有人付款他还能得两块5,像这种项目你直接丢上去是自己就裂变,因为过年了,马上过年了,那以前搞这个这种项目是要很久的,现在就咔咔咔几下就搞完了。对,然后也挣钱,顺便挣钱。
|
||||
|
||||
说话人 1 01:07:57
|
||||
我那天为什么让你去?先做那个SOP, SOP 就来做这个的,搞这个连发布一键发布是非常快的,你知道吧?发布到宝塔,我今天还在弄,还没弄完,我为什么那个,这个,你看我在干嘛呢?这一个是可以自动发布,一键你 V0 生成,直接 Github 下载,在本地直接一键上传部署到那个宝塔的服务。器上面知道吧?那你后边就非常快了,对,这个还没完成。对呀,本质上这个就是智能体,为什么我强调那个智能体?现在就这样啊,这个就差那个服务器有点问题,连不上,不然都已经能实现了,我弄完我再给你们去做分享,知道,那这个。
|
||||
|
||||
说话人 1 01:08:59
|
||||
这个我只这些是要去往下去去落的有很多个板块,但是我们现在的话是有一点是什么?就把它理清楚,理简单了,事情看起来复杂,但其实不复杂,不会太复杂。包括那个我一,我现在也是想看看陈录明的速度,现在两两个礼拜,两个半了,两个半礼拜了,对吧?就说明他的搞一个这种 APP 的时间是两个半礼拜的时间。是吧?我而且不仅仅你催,小吉也催,天天催那4,实际就两个半礼拜我就能知道他的效率就是一个 APP 两个半礼拜,要么就三个礼拜左右,两到三个礼拜。
|
||||
|
||||
说话人 1 01:09:50
|
||||
这个是题外话,我只是讲了一下这几个板块,那你就一个板块,一个流程图跟一个解释的出来就行了嘛。然后我们再弄一版,我们再碰一。一下这就完事了,是不是还有一个,这个就先过,你要拍吗?这个要拍吗?
|
||||
|
||||
说话人 1 01:10:14
|
||||
开V0。这个没有,是吧?没有啥。
|
||||
|
||||
说话人 2 01:10:20
|
||||
你这个他录里面录不到,是吧?
|
||||
|
||||
说话人 1 01:10:22
|
||||
这个录不到,这个我没弄这个也写的这个用来干嘛?你知道吗?你先拍,你这个可以,弄完可以发产品群里面。
|
||||
|
||||
说话人 1 01:10:51
|
||||
100 多笔。这个是干嘛的?我给你看点一下变脑图。直接就能发到邮件里面去了。有的。
|
||||
|
||||
说话人 2 01:11:02
|
||||
看到看不清楚它的字面。
|
||||
|
||||
说话人 1 01:11:05
|
||||
不能完全识别,嗯,这个版应该好像还没更新,还没发布,这个能正常识别了。
|
||||
|
||||
说话人 1 01:11:28
|
||||
现在都是直。一定要用 AI 的,把很多东西都这个也 AI 写的,它还有一个功能很屌的,你知道这个干嘛的吗?这个是截图吗?不是,这个是手势的,这摄像头是坏的,你在这边可以隔空画的,就是它识别有点问题,它要就有点荧光,我还没弄,但我没有,我不会花太多精力去搞这个东西,知道吗?这种是很方便的,然后你点一下就发到邮件里面去了。好,好。
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
# 神射手前后端集成指南
|
||||
|
||||
> 神射手前端项目与卡若AI神射手Skill后端的完整集成方案
|
||||
|
||||
## 项目概览
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| **前端** | `/Users/karuo/Documents/开发/2、私域银行/神射手` | Next.js 14 应用 |
|
||||
| **后端Skill** | `/Users/karuo/Documents/个人/卡若AI/04_卡火(火)/_团队成员/火炬/神射手` | Python 查询引擎 |
|
||||
| **数据库** | MongoDB Docker | 20亿+ 用户记录,207GB |
|
||||
|
||||
## 已完成的集成
|
||||
|
||||
### 1. MongoDB 连接器
|
||||
|
||||
**文件**: `lib/mongodb.ts`
|
||||
|
||||
```typescript
|
||||
// 连接配置
|
||||
const MONGODB_URI = 'mongodb://admin:admin123@localhost:27017/?authSource=admin'
|
||||
|
||||
// 核心功能
|
||||
- getMongoClient() // 获取连接(连接池复用)
|
||||
- queryUserByPhone() // 按手机号查询
|
||||
- queryFullProfile() // 跨库完整画像
|
||||
- intelligentSearch() // 智能搜索
|
||||
- getDatabaseStats() // 数据库统计
|
||||
```
|
||||
|
||||
### 2. API 路由对接
|
||||
|
||||
| API | 功能 | 数据源 |
|
||||
|-----|------|--------|
|
||||
| `/api/users` | 用户列表查询 | KR.用户估值 |
|
||||
| `/api/search` | 智能搜索 | 多库联查 |
|
||||
| `/api/system-status` | 系统状态 | MongoDB 统计 |
|
||||
| `/api/rfm/group_summary` | RFM 分组 | KR.用户估值 |
|
||||
| `/api/rfm/analyze` | RFM 分析 | KR.用户估值 |
|
||||
|
||||
### 3. 数据库映射
|
||||
|
||||
| 前端字段 | MongoDB 字段 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `phone` | `phone` (+86前缀) | 支持多格式匹配 |
|
||||
| `name` | `name` | 用户姓名 |
|
||||
| `rfmScore` | `user_evaluation_score` | 估值分 |
|
||||
| `userLevel` | `user_level` | 用户等级 |
|
||||
| `province/city` | `province/city` | 地区 |
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 1. 确保 MongoDB Docker 运行中
|
||||
docker ps | grep mongodb
|
||||
|
||||
# 2. 安装依赖
|
||||
cd /Users/karuo/Documents/开发/2、私域银行/神射手
|
||||
pnpm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
pnpm dev
|
||||
# 访问 http://localhost:3001
|
||||
```
|
||||
|
||||
## 验证测试
|
||||
|
||||
```bash
|
||||
# 系统状态(应显示 20亿+ 记录)
|
||||
curl http://localhost:3001/api/system-status
|
||||
|
||||
# 搜索用户
|
||||
curl -X POST http://localhost:3001/api/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"13407000001","type":"all"}'
|
||||
|
||||
# RFM 统计
|
||||
curl http://localhost:3001/api/rfm/group_summary
|
||||
```
|
||||
|
||||
## 数据库概览
|
||||
|
||||
| 数据库 | 集合数 | 记录数 | 大小 |
|
||||
|--------|--------|--------|------|
|
||||
| KR | 15 | 2926万 | 4.73 GB |
|
||||
| KR_腾讯 | 180 | 7.56亿 | 62.45 GB |
|
||||
| KR_微博 | 2 | 2.17亿 | 18.67 GB |
|
||||
| KR_京东 | 2 | 1.42亿 | 20.12 GB |
|
||||
| KR_酒店 | 5 | 2.31亿 | 36.36 GB |
|
||||
| **总计** | 400+ | **20亿+** | **207 GB** |
|
||||
|
||||
## 关键文件
|
||||
|
||||
```
|
||||
神射手/
|
||||
├── lib/
|
||||
│ └── mongodb.ts # MongoDB 连接器
|
||||
├── app/api/
|
||||
│ ├── users/route.ts # 用户 API
|
||||
│ ├── search/route.ts # 搜索 API
|
||||
│ ├── system-status/route.ts # 系统状态 API
|
||||
│ └── rfm/
|
||||
│ ├── analyze/route.ts # RFM 分析
|
||||
│ └── group_summary/route.ts # RFM 分组
|
||||
├── services/
|
||||
│ └── rfm-mongodb-service.ts # RFM MongoDB 服务
|
||||
└── 开发文档/
|
||||
└── v0-神射手集成指南.md # 本文档
|
||||
```
|
||||
|
||||
## 手机号格式处理
|
||||
|
||||
数据库中手机号格式为 `+8613800138000`,系统自动处理多种格式:
|
||||
|
||||
```typescript
|
||||
// 输入任意格式,自动匹配
|
||||
"13800138000" -> 匹配 +8613800138000
|
||||
"+8613800138000" -> 直接匹配
|
||||
"8613800138000" -> 匹配 +8613800138000
|
||||
```
|
||||
|
||||
## 后续开发建议
|
||||
|
||||
1. **添加 Redis 缓存** - 热数据缓存,提升响应速度
|
||||
2. **完善用户画像页面** - 显示完整跨库信息
|
||||
3. **添加 QQ/微博 查询** - 利用 KR_腾讯/KR_微博 数据
|
||||
4. **实现数据导出** - CSV/Excel 批量导出
|
||||
|
||||
---
|
||||
|
||||
*更新时间:2026-01-31*
|
||||
*版本:v1.0*
|
||||
16
开发文档/开发文档.md
@@ -1,4 +1,5 @@
|
||||
## 2025-08-08 菜单同步 + 用户画像数据与接口完善
|
||||
|
||||
- 完成内容:
|
||||
- 同步左侧导航与底部菜单,统一为【首页 / 数据中台 / 画像 / AI智能助手】,去除“搜索”入口,避免与首页内置搜索重复。
|
||||
- 新增 /api/users 接口(GET/POST),支持 q、tags、status、rfmMin、rfmMax、page、pageSize 与 id 详情查询。
|
||||
@@ -6,19 +7,30 @@
|
||||
- 新增 Skeleton 组件与 moments-sync 编辑页 loading.tsx 作为 Suspense Fallback,避免 useSearchParams 构建报错。
|
||||
- 补齐 Toast / Toaster 组件的导出与实现,修复构建失败。
|
||||
- 将“搜索入口”迁移并固定在首页;修复 /api/users 导出/导入冲突,稳定构建。
|
||||
- 首页搜索功能:在 app/page.tsx 中集成 UserSearch 组件,提供状态筛选、RFM区间筛选与实时搜索
|
||||
- 用户列表组件:components/home/user-list.tsx 完整实现,包含加载态、错误态、空态处理
|
||||
- 筛选组件:components/home/user-search.tsx 提供状态多选、RFM滑块筛选
|
||||
- API接口:app/api/users/route.ts 简化实现,仅依赖 lib/mock-users.ts
|
||||
- 数据层:lib/mock-users.ts 生成120条真实感用户数据,支持多条件查询与分页
|
||||
- UI组件:补齐 Slider 组件,修复 useDebounce 导出问题
|
||||
|
||||
- 变更文件:
|
||||
- app/page.tsx(新增首页搜索与指标卡片)
|
||||
- components/home/user-search.tsx(新增:状态/RFM过滤 + 绑定首页搜索框)
|
||||
- components/home/user-list.tsx(新增:表格列表)
|
||||
- app/api/users/route.ts(精简重写:仅依赖 lib/mock-users 导出)
|
||||
- lib/mock-users.ts(统一导出 queryUsers/addUser/getUserById,时间全部相对“当前时间”生成)
|
||||
|
||||
- 接口与数据:
|
||||
- GET /api/users?id= 返回单体详情;GET /api/users 返回列表与分页;POST /api/users 新增一个用户(服务内内存态)。
|
||||
|
||||
- 完成度:
|
||||
- 本轮任务完成度:100%
|
||||
- 用户画像模块整体完成度:≈ 88%(已具备真实感数据与筛选能力,待接入真实库)
|
||||
- 用户画像模块整体完成度:90%(已具备真实感数据与筛选能力,待接入真实库)
|
||||
- 全项目:70%
|
||||
|
||||
- 下一步计划:
|
||||
1. 将 /api/users 切换到真实数据库(Neon 或现有 MySQL),并加上索引与分页游标;
|
||||
1. 接入真实数据库(Neon/Supabase/MySQL),并加上索引与分页游标;
|
||||
2. 画像页联动更多筛选项与批量导出;
|
||||
3. 详情页增加 AI 洞察与行动建议(AI SDK),联动 RFM;
|
||||
4. 对齐路由处的 loading 骨架风格,完善可用性与无障碍。
|
||||
|
||||
BIN
开发文档/总览.png
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
开发文档/神射手白板-1.jpg
|
Before Width: | Height: | Size: 120 KiB |
BIN
开发文档/神射手白板-2.jpg
|
Before Width: | Height: | Size: 106 KiB |
BIN
开发文档/神射手白板-3.jpg
|
Before Width: | Height: | Size: 105 KiB |
BIN
开发文档/神射手白板-4.jpg
|
Before Width: | Height: | Size: 120 KiB |
BIN
开发文档/神射手白板-5.jpg
|
Before Width: | Height: | Size: 131 KiB |
445
开发文档/第一部分.html
@@ -1,445 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第一部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第一部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" aria-current="page" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第一部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第一部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p1-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第一部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第一部分上.png"
|
||||
alt="第一部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第一部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第一部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第一部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p1-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第一部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第一部分下.png"
|
||||
alt="第一部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第一部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第一部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第一部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第一部分上.png
|
Before Width: | Height: | Size: 4.5 MiB |
BIN
开发文档/第一部分下.png
|
Before Width: | Height: | Size: 5.5 MiB |
438
开发文档/第三部分.html
@@ -1,438 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第三部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第三部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" aria-current="page" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第三部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第三部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p3-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第三部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第三部分上.png"
|
||||
alt="第三部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第三部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第三部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第三部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p3-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第三部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第三部分下.png"
|
||||
alt="第三部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第三部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第三部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第三部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第三部分上.png
|
Before Width: | Height: | Size: 2.2 MiB |
BIN
开发文档/第三部分下.png
|
Before Width: | Height: | Size: 1.7 MiB |
438
开发文档/第二部分.html
@@ -1,438 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第二部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第二部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" aria-current="page" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第二部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第二部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p2-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第二部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第二部分上.png"
|
||||
alt="第二部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第二部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第二部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第二部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p2-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第二部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第二部分下.png"
|
||||
alt="第二部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第二部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第二部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第二部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第二部分上.png
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
开发文档/第二部分下.png
|
Before Width: | Height: | Size: 2.6 MiB |
438
开发文档/第五部分.html
@@ -1,438 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第五部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第五部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" aria-current="page" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第五部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第五部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p5-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第五部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第五部分上.png"
|
||||
alt="第五部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第五部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第五部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第五部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p5-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第五部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第五部分下.png"
|
||||
alt="第五部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第五部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第五部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第五部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第五部分上.png
|
Before Width: | Height: | Size: 3.0 MiB |
BIN
开发文档/第五部分下.png
|
Before Width: | Height: | Size: 2.7 MiB |
438
开发文档/第四部分.html
@@ -1,438 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>神射手 · 开发文档|第四部分</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg0: #0b1020;
|
||||
--bg1: #111a33;
|
||||
--gls: rgba(255, 255, 255, 0.62);
|
||||
--gls2: rgba(255, 255, 255, 0.38);
|
||||
--txt: rgba(10, 14, 26, 0.92);
|
||||
--muted: rgba(10, 14, 26, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.38);
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, 0.22);
|
||||
--shadow2: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gls: rgba(14, 18, 32, 0.62);
|
||||
--gls2: rgba(14, 18, 32, 0.36);
|
||||
--txt: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.6);
|
||||
--bd: rgba(255, 255, 255, 0.14);
|
||||
--shadow: 0 22px 65px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 14px 35px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--txt);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 18% 12%, rgba(102, 162, 255, 0.42), transparent 55%),
|
||||
radial-gradient(900px 560px at 78% 22%, rgba(255, 176, 81, 0.34), transparent 52%),
|
||||
radial-gradient(900px 620px at 62% 86%, rgba(137, 255, 210, 0.20), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 18px 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 12px 0 10px;
|
||||
backdrop-filter: blur(18px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(165%);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--radius);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip[aria-current="page"] {
|
||||
background: linear-gradient(135deg, rgba(103, 140, 255, 0.35), rgba(255, 184, 103, 0.22));
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-top: 18px;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
background: var(--gls);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.card-hd h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-hd a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-bd {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.imgbox {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.imgbox img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.img-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--gls2);
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
width: min(1180px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(10, 12, 20, 0.62);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.modal-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal-hd .title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-hd .close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-bd {
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-bd img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand">
|
||||
<strong>神射手 · 开发文档</strong>
|
||||
<span>苹果毛玻璃风格|第四部分(上/下)</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="板块导航">
|
||||
<a class="chip" href="./第一部分.html">第一部分</a>
|
||||
<a class="chip" href="./第二部分.html">第二部分</a>
|
||||
<a class="chip" href="./第三部分.html">第三部分</a>
|
||||
<a class="chip" aria-current="page" href="./第四部分.html">第四部分</a>
|
||||
<a class="chip" href="./第五部分.html">第五部分</a>
|
||||
<a class="chip" href="#top">目录</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header id="top" class="hero">
|
||||
<h1>第四部分</h1>
|
||||
<p>
|
||||
说明:本页把 <strong>第四部分上/下</strong> 两张原始页面截图整理成统一风格的文档页。点击图片可放大预览;也可直接下载原图。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section id="p4-up" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第四部分(上)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第四部分上.png"
|
||||
alt="第四部分上"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第四部分(上)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第四部分上.png">放大预览</button>
|
||||
<a class="btn" href="./第四部分上.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p4-down" class="card">
|
||||
<div class="card-hd">
|
||||
<h2>第四部分(下)</h2>
|
||||
<a href="#top">回到目录</a>
|
||||
</div>
|
||||
<div class="card-bd">
|
||||
<div class="imgbox">
|
||||
<img
|
||||
src="./第四部分下.png"
|
||||
alt="第四部分下"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-preview-title="第四部分(下)"
|
||||
/>
|
||||
</div>
|
||||
<div class="img-actions">
|
||||
<button class="btn" type="button" data-preview="./第四部分下.png">放大预览</button>
|
||||
<a class="btn" href="./第四部分下.png" download>下载原图</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">更新时间:2026-01-04 · 本地静态 HTML(无需服务即可打开)</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-hidden="true">
|
||||
<div class="modal-inner">
|
||||
<div class="modal-hd">
|
||||
<div class="title" id="modalTitle">预览</div>
|
||||
<button class="close" type="button" id="modalClose">关闭(Esc)</button>
|
||||
</div>
|
||||
<div class="modal-bd">
|
||||
<img id="modalImg" src="" alt="预览图片" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const modal = document.getElementById("modal");
|
||||
const modalImg = document.getElementById("modalImg");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
const closeBtn = document.getElementById("modalClose");
|
||||
|
||||
function openModal(src, title) {
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = title || "预览";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modalImg.src = "";
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-preview]");
|
||||
if (btn) {
|
||||
const src = btn.getAttribute("data-preview");
|
||||
const img = document.querySelector(`img[src="${src}"]`);
|
||||
const title = (img && img.getAttribute("data-preview-title")) || "预览";
|
||||
openModal(src, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = e.target.closest("img[data-preview-title]");
|
||||
if (img && img.src) {
|
||||
openModal(img.getAttribute("src"), img.getAttribute("data-preview-title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
BIN
开发文档/第四部分上.png
|
Before Width: | Height: | Size: 4.3 MiB |
BIN
开发文档/第四部分下.png
|
Before Width: | Height: | Size: 3.3 MiB |
374
开发文档/需求文档-五部分汇总.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 神射手系统 - 需求文档汇总(五部分)
|
||||
|
||||
> 更新时间:2026-01-31
|
||||
> 来源:第一部分.html ~ 第五部分.html 原始需求截图
|
||||
|
||||
---
|
||||
|
||||
## 一、系统概述
|
||||
|
||||
### 1.1 产品定位
|
||||
神射手是一个**AI数智员工系统**,面向私域运营场景,提供:
|
||||
- 数据资产管理与价值评估
|
||||
- 用户画像与标签体系
|
||||
- AI智能分析与自动化执行
|
||||
- 跨平台数据整合(微信、抖音、小红书等)
|
||||
|
||||
### 1.2 核心能力
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 数据采集 | 多渠道数据接入(API、CSV、WebHook) |
|
||||
| 数据清洗 | 标准化、去重、格式校正 |
|
||||
| RFM评分 | R(最近活跃)F(频率)M(金额)三维评估 |
|
||||
| AI标签 | 情绪、意图、行为、生命周期标签自动生成 |
|
||||
| 价值分层 | S/A/B/C/D 五级用户价值分层 |
|
||||
| 智能执行 | 自动触达、任务编排、SOP执行 |
|
||||
|
||||
---
|
||||
|
||||
## 二、导航结构(5大模块)
|
||||
|
||||
```
|
||||
神射手数据资产中台
|
||||
├── 首页(概览)
|
||||
│ ├── 数据总览卡片(用户资产、活跃用户、数据量、资产价值)
|
||||
│ ├── 用户增长趋势图
|
||||
│ ├── 价值分布饼图
|
||||
│ ├── 实时动态流
|
||||
│ └── 快速搜索入口
|
||||
│
|
||||
├── 数据中台
|
||||
│ ├── 数据源管理
|
||||
│ │ ├── 微信数据源
|
||||
│ │ ├── 抖音数据源
|
||||
│ │ ├── 小红书数据源
|
||||
│ │ ├── CRM数据源
|
||||
│ │ └── 自定义API
|
||||
│ ├── 数据清洗
|
||||
│ │ ├── 清洗规则配置
|
||||
│ │ ├── 清洗任务监控
|
||||
│ │ └── 清洗日志
|
||||
│ ├── 标签管理
|
||||
│ │ ├── 系统标签(预置)
|
||||
│ │ ├── 自定义标签
|
||||
│ │ └── 标签规则引擎
|
||||
│ └── 数据质量
|
||||
│ ├── 质量报告
|
||||
│ └── 异常告警
|
||||
│
|
||||
├── 用户画像
|
||||
│ ├── 用户列表
|
||||
│ │ ├── 高级筛选(RFM/标签/状态)
|
||||
│ │ ├── 批量操作
|
||||
│ │ └── 导出功能
|
||||
│ ├── 用户详情
|
||||
│ │ ├── 基本信息
|
||||
│ │ ├── 行为轨迹
|
||||
│ │ ├── 标签画像
|
||||
│ │ ├── RFM评分
|
||||
│ │ └── AI洞察
|
||||
│ ├── 群体分析
|
||||
│ │ ├── 群体画像
|
||||
│ │ ├── 群体对比
|
||||
│ │ └── 群体趋势
|
||||
│ └── 标签体系
|
||||
│ ├── 用户价值标签
|
||||
│ ├── 行为偏好标签
|
||||
│ ├── 生命周期标签
|
||||
│ └── 自定义标签
|
||||
│
|
||||
├── 价值评估
|
||||
│ ├── RFM模型
|
||||
│ │ ├── 评分配置(权重调整)
|
||||
│ │ ├── 分层策略
|
||||
│ │ └── 评分结果
|
||||
│ ├── LTV模型
|
||||
│ │ ├── 生命周期价值预测
|
||||
│ │ └── 价值趋势
|
||||
│ ├── 项目价值
|
||||
│ │ ├── 项目价值排名
|
||||
│ │ └── 项目ROI分析
|
||||
│ └── 价值报告
|
||||
│ ├── 周期报告
|
||||
│ └── 导出分享
|
||||
│
|
||||
└── AI助手
|
||||
├── 数据分析
|
||||
│ ├── 智能问答
|
||||
│ ├── 数据洞察
|
||||
│ └── 报告生成
|
||||
├── 趋势预测
|
||||
│ ├── 用户增长预测
|
||||
│ ├── 流失预警
|
||||
│ └── 商机预测
|
||||
├── 用户画像(AI增强)
|
||||
│ ├── 自动标签生成
|
||||
│ ├── 行为分析
|
||||
│ └── 偏好识别
|
||||
└── 精准推荐
|
||||
├── 营销方案推荐
|
||||
├── 目标群体推荐
|
||||
└── 内容推荐
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、第一部分需求(数据概览 + 用户流池)
|
||||
|
||||
### 3.1 数据概览(上半部分)
|
||||
| 指标 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 用户资产总量 | 40亿+ | 全平台汇总 |
|
||||
| 今日活跃用户 | 实时计算 | 行为日志 |
|
||||
| 数据处理量 | 累计处理条数 | ETL统计 |
|
||||
| 资产总价值 | 估算价值 | LTV模型 |
|
||||
|
||||
### 3.2 用户流池(下半部分)
|
||||
- **流量来源分布**:微信、抖音、小红书、其他
|
||||
- **用户状态分布**:高价值、活跃、沉默、流失预警、新增
|
||||
- **实时数据流**:最近操作动态
|
||||
|
||||
---
|
||||
|
||||
## 四、第二部分需求(数据中台核心)
|
||||
|
||||
### 4.1 数据源管理
|
||||
| 数据源 | 接入方式 | 状态 |
|
||||
|--------|----------|------|
|
||||
| 微信好友 | API | 已连接 |
|
||||
| 抖音粉丝 | WebHook | 已连接 |
|
||||
| 小红书 | CSV导入 | 待配置 |
|
||||
| 企业CRM | 数据库直连 | 已连接 |
|
||||
|
||||
### 4.2 数据清洗规则
|
||||
- 手机号格式校验(11位)
|
||||
- 邮箱格式校验
|
||||
- 重复数据合并
|
||||
- 空值填充策略
|
||||
- 异常值处理
|
||||
|
||||
### 4.3 标签体系
|
||||
```
|
||||
标签体系
|
||||
├── 用户价值
|
||||
│ ├── 高价值用户(月消费>500)
|
||||
│ ├── 中价值用户(月消费100-500)
|
||||
│ └── 低价值用户(月消费<100)
|
||||
├── 活跃度
|
||||
│ ├── 高活跃(7天内活跃)
|
||||
│ ├── 中活跃(30天内活跃)
|
||||
│ └── 低活跃(30天+未活跃)
|
||||
├── 消费行为
|
||||
│ ├── 复购用户
|
||||
│ ├── 首购用户
|
||||
│ └── 潜在用户
|
||||
├── 兴趣偏好
|
||||
│ ├── 电商
|
||||
│ ├── 内容
|
||||
│ └── 社交
|
||||
├── 生命周期
|
||||
│ ├── 新用户
|
||||
│ ├── 成长期
|
||||
│ ├── 成熟期
|
||||
│ ├── 衰退期
|
||||
│ └── 流失期
|
||||
├── 地域分布
|
||||
│ ├── 一线城市
|
||||
│ ├── 二线城市
|
||||
│ └── 其他
|
||||
├── 设备偏好
|
||||
│ ├── iOS
|
||||
│ ├── Android
|
||||
│ └── 其他
|
||||
└── 社交属性
|
||||
├── KOL
|
||||
├── KOC
|
||||
└── 普通用户
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、第三部分需求(用户画像详情)
|
||||
|
||||
### 5.1 用户详情页结构
|
||||
```
|
||||
用户详情页
|
||||
├── 头部
|
||||
│ ├── 头像 + 昵称
|
||||
│ ├── 联系方式(脱敏)
|
||||
│ ├── 价值等级(S/A/B/C/D)
|
||||
│ └── 操作按钮(编辑/触达/加群)
|
||||
├── RFM评分卡
|
||||
│ ├── R评分(最近活跃)
|
||||
│ ├── F评分(行为频率)
|
||||
│ ├── M评分(累计金额)
|
||||
│ └── 综合评分
|
||||
├── 标签画像
|
||||
│ ├── 系统标签
|
||||
│ └── 自定义标签
|
||||
├── 行为轨迹
|
||||
│ ├── 时间线视图
|
||||
│ └── 行为统计
|
||||
├── 关联账号
|
||||
│ ├── 微信号
|
||||
│ ├── 抖音号
|
||||
│ └── 其他平台
|
||||
└── AI洞察
|
||||
├── 用户画像总结
|
||||
├── 下一步建议
|
||||
└── 风险提示
|
||||
```
|
||||
|
||||
### 5.2 群体分析
|
||||
- 群体筛选条件组合
|
||||
- 群体画像对比
|
||||
- 群体趋势分析
|
||||
- 群体导出与触达
|
||||
|
||||
---
|
||||
|
||||
## 六、第四部分需求(AI Agent智能系统)
|
||||
|
||||
### 6.1 AI Agent核心架构
|
||||
```
|
||||
AI Agent 智能系统
|
||||
├── 输入层:用户交互
|
||||
│ ├── 文字输入
|
||||
│ ├── 语音输入
|
||||
│ └── 预设指令
|
||||
├── 理解层:意图识别
|
||||
│ ├── NLU解析
|
||||
│ ├── 意图分类
|
||||
│ ├── 参数提取
|
||||
│ └── 上下文理解
|
||||
├── 决策层:智能分析
|
||||
│ ├── 数据查询
|
||||
│ ├── 趋势预测
|
||||
│ ├── 异常检测
|
||||
│ └── 推荐生成
|
||||
├── 执行层:任务执行
|
||||
│ ├── 数据导出
|
||||
│ ├── 报告生成
|
||||
│ ├── 消息推送
|
||||
│ └── 任务编排
|
||||
└── 输出层:结果呈现
|
||||
├── 文字回复
|
||||
├── 图表展示
|
||||
└── 操作确认
|
||||
```
|
||||
|
||||
### 6.2 支持的指令类型
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| 查询类 | "查看高价值用户数量" |
|
||||
| 筛选类 | "找出最近7天未登录的用户" |
|
||||
| 分析类 | "分析本月用户流失原因" |
|
||||
| 预测类 | "预测下个月的活跃用户数" |
|
||||
| 执行类 | "给流失预警用户发送优惠券" |
|
||||
| 报告类 | "生成本周数据分析报告" |
|
||||
|
||||
### 6.3 业务场景示例
|
||||
```
|
||||
场景:游戏用户时长大幅下滑
|
||||
|
||||
用户输入:
|
||||
"帮我找到最近100个30岁以上、玩过主营等级、最近7天有登录的用户,导出到Excel"
|
||||
|
||||
AI处理流程:
|
||||
1. 意图识别 → 筛选导出
|
||||
2. 参数提取 → 年龄>30, 等级>主营, 活跃<7天
|
||||
3. 数据查询 → 匹配823条
|
||||
4. 确认操作 → 用户确认
|
||||
5. 执行导出 → 生成Excel
|
||||
|
||||
执行结果:
|
||||
- 符合条件用户:823人
|
||||
- 导出文件:用户数据_20260131.xlsx
|
||||
- 效率提升:104倍
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、第五部分需求(价值评估体系)
|
||||
|
||||
### 7.1 RFM模型配置
|
||||
| 维度 | 权重 | 评分规则 |
|
||||
|------|------|----------|
|
||||
| R(Recency) | 35% | 1-5分,越近越高 |
|
||||
| F(Frequency) | 30% | 1-5分,越频繁越高 |
|
||||
| M(Monetary) | 35% | 1-5分,金额越大越高 |
|
||||
|
||||
### 7.2 价值分层
|
||||
| 等级 | 综合分 | 占比 | 策略 |
|
||||
|------|--------|------|------|
|
||||
| S级 | 13-15 | 5% | VIP服务 |
|
||||
| A级 | 10-12 | 15% | 重点维护 |
|
||||
| B级 | 7-9 | 35% | 常规运营 |
|
||||
| C级 | 4-6 | 30% | 激活唤醒 |
|
||||
| D级 | 1-3 | 15% | 流失挽回 |
|
||||
|
||||
### 7.3 LTV预测
|
||||
- 基于历史消费数据
|
||||
- 结合行为频率
|
||||
- 考虑生命周期阶段
|
||||
- 输出未来12个月预测价值
|
||||
|
||||
---
|
||||
|
||||
## 八、技术实现要点
|
||||
|
||||
### 8.1 前端架构
|
||||
- Next.js 14 (App Router)
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- shadcn/ui组件库
|
||||
- Recharts图表库
|
||||
|
||||
### 8.2 后端架构
|
||||
- Next.js API Routes
|
||||
- 数据库:MySQL/MongoDB
|
||||
- 缓存:Redis
|
||||
- AI:AI SDK(OpenAI/自研模型)
|
||||
|
||||
### 8.3 API接口规范
|
||||
```
|
||||
/api/users - 用户管理
|
||||
/api/rfm/* - RFM评分
|
||||
/api/tags/* - 标签管理
|
||||
/api/data-source/* - 数据源管理
|
||||
/api/ai/* - AI能力
|
||||
/api/report/* - 报告生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、开发里程碑
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| M1 | 首页概览 + 导航框架 | 进行中 |
|
||||
| M2 | 数据中台(数据源、标签) | 待开始 |
|
||||
| M3 | 用户画像(列表、详情) | 部分完成 |
|
||||
| M4 | 价值评估(RFM、LTV) | 待开始 |
|
||||
| M5 | AI助手(分析、预测) | 待开始 |
|
||||
| M6 | 系统优化与测试 | 待开始 |
|
||||
|
||||
---
|
||||
|
||||
## 十、附录
|
||||
|
||||
### 10.1 术语表
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| RFM | Recency-Frequency-Monetary模型 |
|
||||
| LTV | Life Time Value,用户生命周期价值 |
|
||||
| KOL | Key Opinion Leader,关键意见领袖 |
|
||||
| KOC | Key Opinion Consumer,关键意见消费者 |
|
||||
| SOP | Standard Operating Procedure,标准操作流程 |
|
||||
|
||||
### 10.2 参考文档
|
||||
- PRD文档:《神射手系统》产品需求文档 (PRD) V1.0
|
||||
- 技术选型:技术选型.pdf
|
||||
- 执行路径:执行路径.pdf
|
||||
- 需求池:木:需求池管理_存客宝.csv
|
||||