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:
311
app/rfm/page.tsx
Normal file
311
app/rfm/page.tsx
Normal 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,source(chat_logs 用分号分隔)
|
||||
</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onUploadCsv(f)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user