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

179
services/rfm-engine.ts Normal file
View File

@@ -0,0 +1,179 @@
export type RFMScore = { R: number; F: number; M: number; total: number; grade: "S" | "A" | "B" | "C" | "D" }
export type AnalyzeInput = {
user_id: string
last_active: string
interactions: number
amount: number
chat_logs?: string[]
source?: string
}
export type AnalyzeResult = {
user_id: string
rfm_score: RFMScore
tags: {
emotion?: "积极" | "中性" | "消极"
behavior?: string[]
intent?: "弱意图" | "中等意图" | "强意图"
lifecycle?: "新用户" | "活跃用户" | "沉睡用户" | "流失风险"
value?: "高" | "中" | "低"
}
weights: { R: number; F: number; M: number }
created_at: string
updated_at: string
}
type GroupSummary = {
gradeCount: Record<RFMScore["grade"], number>
valueCount: Record<"高" | "中" | "低", number>
lifecycleCount: Record<string, number>
}
// 内存存储(后续可替换为 Mongo/ES
const store = new Map<string, AnalyzeResult>()
let weights = { R: 0.5, F: 0.3, M: 0.2 }
export function getWeights() {
return weights
}
export function setWeights(w: Partial<typeof weights>) {
weights = { ...weights, ...w }
}
// utils
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function daysFromNow(iso: string) {
const d = new Date(iso).getTime()
const now = Date.now()
return Math.max(0, Math.floor((now - d) / (1000 * 60 * 60 * 24)))
}
// 评分
function scoreR(lastActiveISO: string): number {
const days = daysFromNow(lastActiveISO)
if (days <= 1) return 5
if (days <= 3) return 4
if (days <= 7) return 3
if (days <= 30) return 2
return 1
}
function scoreF(interactions: number): number {
if (interactions >= 30) return 5
if (interactions >= 15) return 4
if (interactions >= 7) return 3
if (interactions >= 3) return 2
return 1
}
function scoreM(amount: number): number {
if (amount >= 5000) return 5
if (amount >= 2000) return 4
if (amount >= 800) return 3
if (amount >= 200) return 2
return 1
}
function gradeFromTotal(t: number): RFMScore["grade"] {
if (t >= 4.5) return "S"
if (t >= 3.8) return "A"
if (t >= 3.0) return "B"
if (t >= 2.2) return "C"
return "D"
}
function analyzeEmotion(chat?: string[]): "积极" | "中性" | "消极" | undefined {
if (!chat || chat.length === 0) return undefined
const joined = chat.join(" ")
if (/[好棒|满意|喜欢|👍|推荐]/.test(joined)) return "积极"
if (/[差|不行|失望|退款|投诉]/.test(joined)) return "消极"
return "中性"
}
function analyzeIntent(chat?: string[], interactions?: number): "弱意图" | "中等意图" | "强意图" {
const hasBuyWords = chat?.some((t) => /(购买|下单|价格|优惠|库存)/.test(t)) ?? false
if (hasBuyWords && (interactions ?? 0) >= 20) return "强意图"
if (hasBuyWords || (interactions ?? 0) >= 10) return "中等意图"
return "弱意图"
}
function lifecycleByR(R: number): AnalyzeResult["tags"]["lifecycle"] {
if (R >= 5) return "活跃用户"
if (R >= 3) return "新用户"
if (R === 2) return "沉睡用户"
return "流失风险"
}
function valueByScore(total: number): "高" | "中" | "低" {
if (total >= 4.0) return "高"
if (total >= 2.8) return "中"
return "低"
}
export function computeRFM(input: AnalyzeInput): RFMScore {
const R = scoreR(input.last_active)
const F = scoreF(input.interactions)
const M = scoreM(input.amount)
const total = clamp(R * weights.R + F * weights.F + M * weights.M, 1, 5)
const grade = gradeFromTotal(total)
return { R, F, M, total: Number(total.toFixed(2)), grade }
}
export function analyzeUser(input: AnalyzeInput): AnalyzeResult {
const rfm = computeRFM(input)
const tags: AnalyzeResult["tags"] = {
emotion: analyzeEmotion(input.chat_logs),
behavior: [],
intent: analyzeIntent(input.chat_logs, input.interactions),
lifecycle: lifecycleByR(rfm.R),
value: valueByScore(rfm.total),
}
if (input.interactions >= 20) tags.behavior?.push("高频互动")
if (input.amount >= 1000) tags.behavior?.push("高消费偏好")
if (input.source === "wechat") tags.behavior?.push("微信渠道")
if (input.source === "douyin") tags.behavior?.push("短视频渠道")
const now = new Date().toISOString()
const result: AnalyzeResult = {
user_id: input.user_id,
rfm_score: rfm,
tags,
weights,
created_at: store.has(input.user_id) ? store.get(input.user_id)!.created_at : now,
updated_at: now,
}
store.set(input.user_id, result)
return result
}
export function getUserTags(user_id: string): AnalyzeResult | null {
return store.get(user_id) ?? null
}
export function getGroupSummary(): GroupSummary {
const grades: GroupSummary["gradeCount"] = { S: 0, A: 0, B: 0, C: 0, D: 0 }
const values: GroupSummary["valueCount"] = { : 0, : 0, : 0 }
const lifecycle: Record<string, number> = {}
store.forEach((r) => {
grades[r.rfm_score.grade]++
if (r.tags.value) values[r.tags.value]++
const lc = r.tags.lifecycle ?? "未知"
lifecycle[lc] = (lifecycle[lc] || 0) + 1
})
return { gradeCount: grades, valueCount: values, lifecycleCount: lifecycle }
}
export function dumpCsv(): string {
const headers = ["user_id", "R", "F", "M", "total", "grade", "emotion", "intent", "lifecycle", "value"].join(",")
const rows: string[] = [headers]
store.forEach((r) => {
rows.push(
[
r.user_id,
r.rfm_score.R,
r.rfm_score.F,
r.rfm_score.M,
r.rfm_score.total,
r.rfm_score.grade,
r.tags.emotion ?? "",
r.tags.intent ?? "",
r.tags.lifecycle ?? "",
r.tags.value ?? "",
].join(","),
)
})
return rows.join("\n")
}