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>
180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
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")
|
||
}
|