feat: sync Sidebar and BottomNav, standardize user profile API

Align Sidebar & BottomNav menus, remove "Search", add user profile mock data, implement /api/users, add FilterDrawer, complete Section, ProfileHeader, MetricsRFM components

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
v0
2025-08-08 07:00:12 +00:00
parent 4eed69520c
commit f0a6a364f2
85 changed files with 3318 additions and 6786 deletions

311
app/rfm/page.tsx Normal file
View File

@@ -0,0 +1,311 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import RfmBadge from "@/components/rfm/rfm-badge"
import { Download, BarChart3, Sparkles, Upload, SlidersHorizontal } from 'lucide-react'
type AnalyzeResult = {
user_id: string
rfm_score: { R: number; F: number; M: number; total: number; grade: "S" | "A" | "B" | "C" | "D" }
tags: {
emotion?: "积极" | "中性" | "消极"
behavior?: string[]
intent?: "弱意图" | "中等意图" | "强意图"
lifecycle?: "新用户" | "活跃用户" | "沉睡用户" | "流失风险"
value?: "高" | "中" | "低"
}
created_at: string
updated_at: string
}
export default function RfmPage() {
const [form, setForm] = useState({
user_id: "wxid_demo_01",
last_active: new Date().toISOString(),
interactions: 12,
amount: 880,
chat_logs: "想了解一下价格;今天有活动吗",
source: "wechat",
useAI: false,
})
const [weights, setWeights] = useState<{ R: number; F: number; M: number }>({ R: 0.5, F: 0.3, M: 0.2 })
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<AnalyzeResult | null>(null)
const [summary, setSummary] = useState<any>(null)
useEffect(() => {
;(async () => {
const res = await fetch("/api/rfm/weights")
const data = await res.json()
if (data.success) setWeights(data.data)
})()
}, [])
const analyze = async () => {
setLoading(true)
try {
const res = await fetch("/api/rfm/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
chat_logs: form.chat_logs.split(";").map((s) => s.trim()).filter(Boolean),
}),
})
const data = await res.json()
if (data.success) {
setResult(data.data[0])
}
} finally {
setLoading(false)
}
}
const loadSummary = async () => {
const res = await fetch("/api/rfm/group_summary")
const data = await res.json()
if (data.success) setSummary(data.data)
}
const exportCsv = () => {
window.open("/api/rfm/dump_csv", "_blank")
}
const onUploadCsv = async (file: File) => {
const text = await file.text()
const lines = text.split(/\r?\n/).filter(Boolean)
if (lines.length <= 1) return
const header = lines[0].split(",").map((s) => s.trim())
const payload = lines.slice(1).map((line) => {
const cols = line.split(",")
const row: any = {}
header.forEach((h, i) => (row[h] = cols[i]))
return {
user_id: row.user_id,
last_active: row.last_active,
interactions: Number(row.interactions || 0),
amount: Number(row.amount || 0),
chat_logs: (row.chat_logs || "").split(";").filter(Boolean),
source: row.source || "import",
}
})
await fetch("/api/rfm/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
await loadSummary()
}
const saveWeights = async () => {
// normalize via API
const res = await fetch("/api/rfm/weights", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
R: Math.max(0.0001, weights.R),
F: Math.max(0.0001, weights.F),
M: Math.max(0.0001, weights.M),
}),
})
const data = await res.json()
if (data.success) setWeights(data.data)
}
return (
<div className="container mx-auto px-4 py-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">RFM </h1>
<div className="flex gap-2">
<Button variant="outline" onClick={loadSummary}>
<BarChart3 className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={exportCsv}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
</div>
</div>
{/* 权重配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
R/F/M
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-3">
{(["R", "F", "M"] as const).map((k) => (
<div key={k} className="space-y-2">
<Label htmlFor={`w-${k}`}>{k}</Label>
<Input
id={`w-${k}`}
type="number"
min={0}
step="0.05"
value={weights[k]}
onChange={(e) => setWeights((p) => ({ ...p, [k]: Number(e.target.value) }))}
/>
</div>
))}
<div className="col-span-3 flex justify-end">
<Button onClick={saveWeights}></Button>
</div>
</CardContent>
</Card>
{/* 在线分析 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
线
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user_id">ID</Label>
<Input
id="user_id"
value={form.user_id}
onChange={(e) => setForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="wxid_xxx 或手机号散列等"
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_active"></Label>
<Input
id="last_active"
type="datetime-local"
value={new Date(form.last_active).toISOString().slice(0, 16)}
onChange={(e) => setForm((p) => ({ ...p, last_active: new Date(e.target.value).toISOString() }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="interactions"></Label>
<Input
id="interactions"
type="number"
value={form.interactions}
onChange={(e) => setForm((p) => ({ ...p, interactions: Number(e.target.value) }))}
min={0}
/>
</div>
<div className="space-y-2">
<Label htmlFor="amount"></Label>
<Input
id="amount"
type="number"
value={form.amount}
onChange={(e) => setForm((p) => ({ ...p, amount: Number(e.target.value) }))}
min={0}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="chat_logs"></Label>
<Textarea
id="chat_logs"
rows={3}
value={form.chat_logs}
onChange={(e) => setForm((p) => ({ ...p, chat_logs: e.target.value }))}
placeholder="想了解一下价格; 今天有活动吗"
/>
</div>
<div className="space-y-2">
<Label htmlFor="source"></Label>
<Input
id="source"
value={form.source}
onChange={(e) => setForm((p) => ({ ...p, source: e.target.value }))}
placeholder="wechat / douyin / xhs / form"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<input
id="useAI"
type="checkbox"
checked={form.useAI}
onChange={(e) => setForm((p) => ({ ...p, useAI: e.target.checked }))}
/>
<Label htmlFor="useAI">AI标签增强//</Label>
</div>
<Button onClick={analyze} disabled={loading}>
{loading ? "分析中..." : "开始分析并入库"}
</Button>
</div>
{result && (
<div className="mt-4 p-4 border rounded-lg space-y-3">
<div className="flex items-center gap-2">
<RfmBadge grade={result.rfm_score.grade} />
<Badge variant="secondary">R: {result.rfm_score.R}</Badge>
<Badge variant="secondary">F: {result.rfm_score.F}</Badge>
<Badge variant="secondary">M: {result.rfm_score.M}</Badge>
<Badge variant="outline">Total: {result.rfm_score.total}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{result.tags.value && <Badge className="bg-indigo-100 text-indigo-800">{result.tags.value}</Badge>}
{result.tags.intent && <Badge className="bg-purple-100 text-purple-800">{result.tags.intent}</Badge>}
{result.tags.emotion && <Badge className="bg-pink-100 text-pink-800">{result.tags.emotion}</Badge>}
{result.tags.lifecycle && (
<Badge className="bg-amber-100 text-amber-800">{result.tags.lifecycle}</Badge>
)}
{(result.tags.behavior ?? []).map((b, i) => (
<Badge key={i} variant="secondary">
{b}
</Badge>
))}
</div>
</div>
)}
{summary && (
<div className="mt-4 p-4 border rounded-lg space-y-2">
<div className="font-medium"></div>
<div className="text-sm">: {JSON.stringify(summary.gradeCount)}</div>
<div className="text-sm">: {JSON.stringify(summary.valueCount)}</div>
<div className="text-sm">: {JSON.stringify(summary.lifecycleCount)}</div>
</div>
)}
</CardContent>
</Card>
{/* 批量导入 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-4 h-4" />
CSV
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
CSV user_id,last_active,interactions,amount,chat_logs,sourcechat_logs
</p>
<Input
type="file"
accept=".csv,text/csv"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onUploadCsv(f)
}}
/>
</CardContent>
</Card>
</div>
)
}