Files
shensheshou/app/rfm/page.tsx
v0 f0a6a364f2 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>
2025-08-08 07:00:12 +00:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}