feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs

This commit is contained in:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -0,0 +1,189 @@
/**
* AI 图标配色管理器
* 统一管理小程序前端的 AI 图标配色策略
* 基于 VI-DESIGN-SYSTEM.md 6.6-6.7 节
*/
/**
* AI 配色方案定义
* 每种配色包含:主色、浅色、深色三个层级
*/
export const AI_COLOR_SCHEMES = {
red: {
name: 'red',
label: 'Red',
from: '#e74c3c',
to: '#f39c9c',
fromDeep: '#c0392b',
toDeep: '#e74c3c',
className: 'ai-color-red',
},
orange: {
name: 'orange',
label: 'Orange',
from: '#e67e22',
to: '#f5c77e',
fromDeep: '#ca6c17',
toDeep: '#e67e22',
className: 'ai-color-orange',
},
yellow: {
name: 'yellow',
label: 'Yellow',
from: '#d4a017',
to: '#f7dc6f',
fromDeep: '#b8860b',
toDeep: '#d4a017',
className: 'ai-color-yellow',
},
blue: {
name: 'blue',
label: 'Blue',
from: '#2980b9',
to: '#7ec8e3',
fromDeep: '#1a5276',
toDeep: '#2980b9',
className: 'ai-color-blue',
},
indigo: {
name: 'indigo',
label: 'Indigo',
from: '#667eea',
to: '#a78bfa',
fromDeep: '#4a5fc7',
toDeep: '#667eea',
className: 'ai-color-indigo',
},
purple: {
name: 'purple',
label: 'Purple',
from: '#764ba2',
to: '#c084fc',
fromDeep: '#5b3080',
toDeep: '#764ba2',
className: 'ai-color-purple',
},
} as const;
export type AiColorName = keyof typeof AI_COLOR_SCHEMES;
/**
* 页面级 AI 配色建议
* 参考 VI-DESIGN-SYSTEM.md 6.7 节
*/
export const PAGE_AI_COLOR_RECOMMENDATIONS: Record<string, AiColorName | 'random'> = {
'task-list': 'random', // 每日新鲜感
'task-detail': 'indigo', // 通用默认
'task-detail-callback': 'orange', // 与 banner 主题色呼应
'task-detail-relationship': 'purple', // 与 banner 粉色系呼应
'performance': 'blue', // 数据分析感
'customer-detail': 'purple', // 黑金页面,紫色点缀
'board-coach': 'red', // coral banner 配红色 AI
'board-customer': 'yellow', // 黑金页面,黄色点缀
'board-finance': 'purple', // 已正确应用
'notes': 'indigo', // 通用默认
'reviewing': 'orange', // 审核流程,橙色提示
'apply': 'blue', // 申请流程,蓝色信息
'login': 'indigo', // 登录页,靛色默认
'no-permission': 'red', // 权限提示,红色警示
};
/**
* 获取随机 AI 配色
* @returns 随机选择的配色方案
*/
export function getRandomAiColor(): typeof AI_COLOR_SCHEMES[AiColorName] {
const colors = Object.values(AI_COLOR_SCHEMES);
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* 获取指定名称的 AI 配色
* @param colorName - 配色名称
* @returns 配色方案,如果不存在则返回 indigo默认
*/
export function getAiColor(colorName: AiColorName | string): typeof AI_COLOR_SCHEMES[AiColorName] {
return AI_COLOR_SCHEMES[colorName as AiColorName] || AI_COLOR_SCHEMES.indigo;
}
/**
* 获取页面推荐的 AI 配色
* @param pageName - 页面名称(通常为 route.name 或自定义标识)
* @returns 配色方案
*/
export function getPageAiColor(pageName: string): typeof AI_COLOR_SCHEMES[AiColorName] {
const recommendation = PAGE_AI_COLOR_RECOMMENDATIONS[pageName];
if (recommendation === 'random') {
return getRandomAiColor();
}
if (recommendation) {
return getAiColor(recommendation);
}
// 未配置的页面默认使用 indigo
return AI_COLOR_SCHEMES.indigo;
}
/**
* 页面初始化 AI 配色
* 在页面 onLoad 时调用,自动设置 data.aiColor 和页面类名
*
* @example
* ```typescript
* Page({
* onLoad() {
* const { aiColor, className } = initPageAiColor('task-list');
* this.setData({ aiColor });
* // 可选:添加页面类名以应用 CSS 变量
* wx.pageContainer?.classList.add(className);
* }
* })
* ```
*/
export function initPageAiColor(pageName: string) {
const colorScheme = getPageAiColor(pageName);
return {
aiColor: colorScheme.name,
className: colorScheme.className,
colorScheme,
};
}
/**
* 获取 AI 配色的 CSS 变量值
* 用于动态设置样式
*
* @example
* ```typescript
* const cssVars = getAiColorCssVars('purple');
* // { '--ai-from': '#764ba2', '--ai-to': '#c084fc', ... }
* ```
*/
export function getAiColorCssVars(colorName: AiColorName | string): Record<string, string> {
const color = getAiColor(colorName);
return {
'--ai-from': color.from,
'--ai-to': color.to,
'--ai-from-deep': color.fromDeep,
'--ai-to-deep': color.toDeep,
};
}
/**
* 获取所有可用的 AI 配色列表
* @returns 配色名称数组
*/
export function getAllAiColors(): AiColorName[] {
return Object.keys(AI_COLOR_SCHEMES) as AiColorName[];
}
/**
* 验证配色名称是否有效
* @param colorName - 配色名称
* @returns 是否有效
*/
export function isValidAiColor(colorName: string): colorName is AiColorName {
return colorName in AI_COLOR_SCHEMES;
}

View File

@@ -0,0 +1,44 @@
/**
* AI 图标随机配色工具
* 6 种配色方案,页面 onLoad 时随机选取一种,统一应用到该页面所有 AI 标识
* 色值来源docs/h5_ui/css/ai-icons.css
*/
export interface AiColorVars {
'--ai-from': string
'--ai-to': string
'--ai-from-deep': string
'--ai-to-deep': string
}
export interface AiColorResult {
className: string
vars: AiColorVars
}
type ColorKey = 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple'
const AI_COLOR_SCHEMES: Record<ColorKey, AiColorVars> = {
red: { '--ai-from': '#e74c3c', '--ai-to': '#f39c9c', '--ai-from-deep': '#c0392b', '--ai-to-deep': '#e74c3c' },
orange: { '--ai-from': '#e67e22', '--ai-to': '#f5c77e', '--ai-from-deep': '#ca6c17', '--ai-to-deep': '#e67e22' },
yellow: { '--ai-from': '#d4a017', '--ai-to': '#f7dc6f', '--ai-from-deep': '#b8860b', '--ai-to-deep': '#d4a017' },
blue: { '--ai-from': '#2980b9', '--ai-to': '#7ec8e3', '--ai-from-deep': '#1a5276', '--ai-to-deep': '#2980b9' },
indigo: { '--ai-from': '#667eea', '--ai-to': '#a78bfa', '--ai-from-deep': '#4a5fc7', '--ai-to-deep': '#667eea' },
purple: { '--ai-from': '#764ba2', '--ai-to': '#c084fc', '--ai-from-deep': '#5b3080', '--ai-to-deep': '#764ba2' },
}
const COLOR_KEYS: ColorKey[] = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
/**
* 随机选取一种 AI 配色方案
* 返回 className用于 WXML class 绑定)和 varsCSS 变量值)
*/
export function getRandomAiColor(): AiColorResult {
const key = COLOR_KEYS[Math.floor(Math.random() * COLOR_KEYS.length)]
return {
className: `ai-color-${key}`,
vars: AI_COLOR_SCHEMES[key],
}
}
export { AI_COLOR_SCHEMES }

View File

@@ -0,0 +1,51 @@
/**
* 头像颜色工具
* 基于 VI 设计系统 §8 头像颜色系统
*
* 用法:
* import { nameToAvatarColor } from '../../utils/avatar-color'
* const avatarColor = nameToAvatarColor('王先生') // => 'blue'
* // wxml: class="avatar-{{rec.avatarColor}}"
*/
/** 24 色标准色板 key 列表(与 app.wxss .avatar-{key} 一一对应) */
export const AVATAR_PALETTE = [
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'slate',
'coral',
'mint',
'lavender',
'gold',
'crimson',
'ocean',
] as const
export type AvatarColorKey = typeof AVATAR_PALETTE[number]
/**
* 根据名字首字(或任意字符串)稳定映射到头像颜色 key。
* 相同输入永远返回相同颜色,适合用于客户/助教头像。
*
* @param name 姓名或任意标识符(取第一个字符的 charCode
* @returns AvatarColorKey
*/
export function nameToAvatarColor(name: string): AvatarColorKey {
const code = (name?.charCodeAt(0) ?? 0)
return AVATAR_PALETTE[code % AVATAR_PALETTE.length]
}

View File

@@ -0,0 +1,49 @@
/**
* 对话工具函数
* simulateStreamOutput 为异步函数(使用 setTimeout
* simulateStreamOutputSync 为同步纯函数(用于测试)
*/
/**
* 模拟流式输出:逐字追加调用 callback
* @param text 完整文本
* @param callback 每次追加后的回调,参数为当前已输出的子串
* @returns Promise输出完成后 resolve
*/
export function simulateStreamOutput(
text: string,
callback: (partial: string) => void
): Promise<void> {
return new Promise((resolve) => {
if (text.length === 0) {
callback('')
resolve()
return
}
let index = 0
const tick = () => {
index++
callback(text.slice(0, index))
if (index < text.length) {
setTimeout(tick, 50)
} else {
resolve()
}
}
setTimeout(tick, 50)
})
}
/**
* 同步版流式输出:返回逐字追加的子串数组(用于测试)
* @param text 完整文本
* @returns 逐步增长的子串数组,如 "abc" → ["a", "ab", "abc"]
*/
export function simulateStreamOutputSync(text: string): string[] {
const result: string[] = []
for (let i = 1; i <= text.length; i++) {
result.push(text.slice(0, i))
}
return result
}

View File

@@ -0,0 +1,26 @@
/**
* 环境配置
*
* 根据小程序运行环境自动切换 API 地址:
* - develop开发版→ 本机
* - trial体验版→ 测试环境
* - release正式版→ 正式环境
*/
function getApiBase(): string {
const accountInfo = wx.getAccountInfoSync()
const envVersion = accountInfo.miniProgram.envVersion
switch (envVersion) {
case "develop":
return "http://127.0.0.1:8000"
case "trial":
return "https://api.langlangzhuoqiu.cn"
case "release":
return "https://api.langlangzhuoqiu.cn"
default:
return "https://api.langlangzhuoqiu.cn"
}
}
export const API_BASE = getApiBase()

View File

@@ -0,0 +1,23 @@
/**
* 筛选工具函数
* 纯函数,无 wx.* 依赖
*/
/**
* 按条件对象筛选列表
* condition 中每个 key-value 对都必须匹配,即 AND 逻辑
* @param list 对象数组
* @param condition 筛选条件key-value 对
* @returns 满足所有条件的子数组
*/
export function filterByCondition<T extends Record<string, any>>(
list: T[],
condition: Record<string, any>
): T[] {
const keys = Object.keys(condition)
if (keys.length === 0) return [...list]
return list.filter((item) =>
keys.every((key) => item[key] === condition[key])
)
}

View File

@@ -0,0 +1,83 @@
// WXS 格式化工具 — WXML 中不能调用 JS 方法,需通过 WXS 桥接
// 规范docs/miniprogram-dev/design-system/DISPLAY-STANDARDS.md
/** 数字保留 N 位小数;空值返回 '--' */
function toFixed(num, digits) {
if (num === undefined || num === null) return '--'
return num.toFixed(digits)
}
/** 空值兜底null/undefined/'' 统一返回 '--' */
function safe(val) {
if (val === undefined || val === null || val === '') return '--'
return val
}
/**
* 金额格式化WXS 版)
* ¥12,680 / -¥368 / ¥0 / --
*/
function money(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '¥0'
var abs = Math.round(Math.abs(value))
var s = abs.toString()
var result = ''
var count = 0
for (var i = s.length - 1; i >= 0; i--) {
if (count > 0 && count % 3 === 0) result = ',' + result
result = s[i] + result
count++
}
return (value < 0 ? '-¥' : '¥') + result
}
/**
* 计数格式化WXS 版)
* 零值返回 '--'≥1000 加千分位;自动拼接单位
*/
function count(value, unit) {
if (value === undefined || value === null || value === 0) return '--'
var s = value.toString()
if (value >= 1000) {
var result = ''
var c = 0
for (var i = s.length - 1; i >= 0; i--) {
if (c > 0 && c % 3 === 0) result = ',' + result
result = s[i] + result
c++
}
return result + unit
}
return s + unit
}
/**
* 百分比展示WXS 版)
* 保留1位小数超过100%正常展示;空值返回 '--'
*/
function percent(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '0%'
return value.toFixed(1) + '%'
}
/**
* 课时格式化WXS 版)
* 整数 → Nh非整数 → N.Nh0 → 0h空 → --
*/
function hours(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '0h'
if (value % 1 === 0) return value + 'h'
return value.toFixed(1) + 'h'
}
module.exports = {
toFixed: toFixed,
safe: safe,
money: money,
count: count,
percent: percent,
hours: hours,
}

View File

@@ -0,0 +1,21 @@
/**
* 爱心评分映射工具函数
* 纯函数,无 wx.* 依赖
*/
/**
* 将 0-10 分数映射为爱心 Emoji
* >8.5 → 💖, >7 → 🧡, >5 → 💛, ≤5 → 💙
* 超出 [0,10] 范围的值会被 clamp
* @param score 评分 0-10
* @returns 爱心 Emoji
*/
export function getHeartEmoji(score: number): string {
// clamp 到 [0, 10]
const clamped = Math.min(10, Math.max(0, score))
if (clamped > 8.5) return '💖'
if (clamped > 7) return '🧡'
if (clamped > 5) return '💛'
return '💙'
}

View File

@@ -0,0 +1,755 @@
// TODO: 联调时删除此文件,改为真实 API 调用
// ============================================================
// 类型定义
// ============================================================
export type TaskType = 'callback' | 'priority_recall' | 'relationship' | 'high_priority'
/** 备注评分记录 */
export interface Note {
id: string
content: string
tagType: 'customer' | 'coach' | 'system'
tagLabel: string
createdAt: string
/** 满意度评分 0-100 表示未评分 */
score?: number
/** 格式化时间标签(由前端附加) */
timeLabel?: string
}
export interface Task {
id: string
customerName: string
customerAvatar: string
taskType: TaskType
taskTypeLabel: string
deadline: string
heartScore: number
hobbies: string[]
isPinned: boolean
hasNote: boolean
status: 'pending' | 'completed' | 'abandoned'
}
export interface TaskDetail extends Task {
aiAnalysis: { summary: string; suggestions: string[] }
notes: Note[]
lastVisitDate?: string
lastSpendAmount?: number
callbackReason?: string
daysAbsent?: number
spendTrend?: 'up' | 'down' | 'flat'
churnRisk?: 'high' | 'medium' | 'low'
preferences?: string[]
consumptionHabits?: string
socialPreference?: string
}
export interface Note {
id: string
/** 备注正文 */
content: string
/** 标签类型customer=客户备注coach=助教备注 */
tagType: 'customer' | 'coach'
/** 标签文案,如"客户:王先生"、"助教:小燕" */
tagLabel: string
/** 创建时间,展示用格式 */
createdAt: string
}
export interface RetentionClue {
id: string
/** 线索大类 */
category: 'customer_basic' | 'consumption' | 'play_pref' | 'promo_pref' | 'social' | 'feedback'
/** 摘要 */
summary: string
/** 详情 */
detail?: string
/** 线索来源manual=助教手动ai_consumption=系统自动ai_note=备注分析 */
source: 'manual' | 'ai_consumption' | 'ai_note'
/** 记录助教ID用于判断是否为"我" */
recordedByAssistantId?: string
/** 记录助教姓名 */
recordedByName?: string
/** 创建时间 */
createdAt: string
}
export interface PerformanceData {
monthlyIncome: number
incomeChange: number
currentTier: string
nextTierGap: number
todayServiceCount: number
weekServiceCount: number
monthServiceCount: number
}
export interface PerformanceRecord {
id: string
customerName: string
amount: number
date: string
type: string
category: string
}
export interface FinanceMetric {
title: string
value: number
unit: string
trend: 'up' | 'down' | 'flat'
trendValue: string
helpText?: string
}
export interface BoardFinanceData {
metrics: FinanceMetric[]
timeRange: string
filterOptions: Array<{ value: string; text: string }>
}
export interface CustomerCard {
id: string
name: string
avatar: string
heartScore: number
tags: string[]
keyMetric: { label: string; value: string }
}
export interface CoachCard {
id: string
name: string
avatar: string
level: string
keyMetrics: Array<{ label: string; value: string }>
}
export interface CustomerDetail {
id: string
name: string
avatar: string
tags: string[]
heartScore: number
phone: string
spiIndex: number
consumptionRecords: ConsumptionRecord[]
}
export interface ConsumptionRecord {
id: string
date: string
project: string
duration: number
amount: number
coachName: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
referenceCard?: {
type: 'customer' | 'record'
title: string
summary: string
data: Record<string, string>
}
}
export interface ChatHistoryItem {
id: string
title: string
lastMessage: string
timestamp: string
customerName?: string
}
export interface UserProfile {
name: string
avatar: string
role: string
storeName: string
}
// ============================================================
// Mock 数据 — 任务模块
// ============================================================
export const mockTasks: Task[] = [
{
id: 'task-001',
customerName: '张伟',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '回访',
deadline: '2026-03-10',
heartScore: 9.2,
hobbies: ['chinese', 'karaoke'],
isPinned: true,
hasNote: true,
status: 'pending',
},
{
id: 'task-002',
customerName: '李娜',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'high_priority',
taskTypeLabel: '高优先召回',
deadline: '2026-03-08',
heartScore: 6.5,
hobbies: ['snooker'],
isPinned: false,
hasNote: false,
status: 'pending',
},
{
id: 'task-003',
customerName: '王磊',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'relationship',
taskTypeLabel: '关系构建',
deadline: '2026-03-12',
heartScore: 7.8,
hobbies: ['chinese', 'mahjong'],
isPinned: false,
hasNote: true,
status: 'pending',
},
{
id: 'task-004',
customerName: '赵敏',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '回访',
deadline: '2026-03-09',
heartScore: 8.8,
hobbies: ['chinese'],
isPinned: false,
hasNote: false,
status: 'pending',
},
{
id: 'task-005',
customerName: '陈浩',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'priority_recall',
taskTypeLabel: '优先召回',
deadline: '2026-03-07',
heartScore: 4.2,
hobbies: ['snooker', 'karaoke'],
isPinned: true,
hasNote: true,
status: 'pending',
},
{
id: 'task-006',
customerName: '刘洋',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'relationship',
taskTypeLabel: '关系构建',
deadline: '2026-03-15',
heartScore: 5.0,
hobbies: ['mahjong'],
isPinned: false,
hasNote: false,
status: 'completed',
},
]
export const mockTaskDetails: TaskDetail[] = [
{
...mockTasks[0],
customerName: '王先生',
taskTypeLabel: '高优先召回',
heartScore: 8.5,
aiAnalysis: {
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好。上次服务评价 5 星,多次指定您为服务助教。',
suggestions: ['询问近期是否有空,邀请体验新到的器材', '告知本周末有会员专属活动', '根据其偏好时段(晚间)推荐合适的时间'],
},
notes: [
{ id: 'note-h5-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-02-05' },
{ id: 'note-h5-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-01-20' },
{ id: 'note-h5-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-01-08' },
],
notes: [],
lastVisitDate: '2026-03-01',
lastSpendAmount: 380,
callbackReason: '上次体验课后未续费,需跟进意向',
},
{
...mockTasks[1],
aiAnalysis: {
summary: '李娜已 23 天未到店,消费趋势下降,存在流失风险。',
suggestions: ['电话关怀了解原因', '发送专属优惠券', '推荐闺蜜同行活动'],
},
notes: [],
daysAbsent: 23,
spendTrend: 'down',
churnRisk: 'high',
},
{
...mockTasks[2],
aiAnalysis: {
summary: '王磊是中台球爱好者,社交活跃,适合发展为核心会员。',
suggestions: ['邀请参加周末球友赛', '推荐 VIP 会员权益', '介绍同水平球友'],
},
notes: [],
preferences: ['中式台球', '麻将'],
consumptionHabits: '周末下午为主,偏好包厢',
socialPreference: '喜欢组队打球,社交型消费者',
},
]
// ============================================================
// Mock 数据 — 维客线索模块
// ============================================================
// 维客线索 MOCK 数据
// 说明:
// - 后端已过滤,客户端直接使用
// - 任务详情页:后端返回脱敏版本(移除 recordedByAssistantId 和 recordedByName
// - 客户详情页:后端返回完整版本(包含 recordedByAssistantId 和 recordedByName
//
// 以下是任务详情页的 MOCK 数据(脱敏版本)
export const mockRetentionClues: RetentionClue[] = [
{
id: 'clue-001',
category: 'consumption',
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好',
detail: '上次服务评价 5 星,多次指定您为服务助教。消费频次稳定,主要偏好中式台球 1v1 课程。',
source: 'ai_consumption',
createdAt: '2026-03-05 14:00',
},
{
id: 'clue-002',
category: 'play_pref',
summary: '中式台球爱好者,技术水平中等偏上',
detail: '对斯诺克也有兴趣,建议推荐进阶课程。',
source: 'manual',
recordedByAssistantId: 'assistant-001',
recordedByName: '王芳',
createdAt: '2026-03-01 10:30',
},
{
id: 'clue-003',
category: 'social',
summary: '社交活跃,经常带朋友来店',
detail: '可以发展为核心会员,推荐参加周末球友赛。',
source: 'ai_note',
// 后端脱敏:移除 recordedByAssistantId 和 recordedByName
createdAt: '2026-02-28 16:45',
},
{
id: 'clue-004',
category: 'promo_pref',
summary: '对储值活动敏感,倾向于大额充值',
detail: '上次充值 2000 元,选择尊享套餐。建议定期推送专属优惠。',
source: 'manual',
// 后端脱敏:移除 recordedByAssistantId 和 recordedByName因为不是当前用户
createdAt: '2026-02-25 09:15',
},
]
// 客户详情页的 MOCK 数据(完整版本,不脱敏)
export const mockRetentionCluesForCustomerDetail: RetentionClue[] = [
{
id: 'clue-001',
category: 'consumption',
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好',
detail: '上次服务评价 5 星,多次指定您为服务助教。消费频次稳定,主要偏好中式台球 1v1 课程。',
source: 'ai_consumption',
createdAt: '2026-03-05 14:00',
},
{
id: 'clue-002',
category: 'play_pref',
summary: '中式台球爱好者,技术水平中等偏上',
detail: '对斯诺克也有兴趣,建议推荐进阶课程。',
source: 'manual',
recordedByAssistantId: 'assistant-001',
recordedByName: '王芳',
createdAt: '2026-03-01 10:30',
},
{
id: 'clue-003',
category: 'social',
summary: '社交活跃,经常带朋友来店',
detail: '可以发展为核心会员,推荐参加周末球友赛。',
source: 'ai_note',
recordedByAssistantId: 'assistant-003',
recordedByName: '李明',
createdAt: '2026-02-28 16:45',
},
{
id: 'clue-004',
category: 'promo_pref',
summary: '对储值活动敏感,倾向于大额充值',
detail: '上次充值 2000 元,选择尊享套餐。建议定期推送专属优惠。',
source: 'manual',
recordedByAssistantId: 'assistant-002',
recordedByName: '刘强',
createdAt: '2026-02-25 09:15',
},
]
// ============================================================
// Mock 数据 — 备注模块
// ============================================================
// mock 数据贴近 H5 原型 notes.html 中的 12 条备注
export const mockNotes: Note[] = [
{
id: 'note-001',
content: '小燕本月表现优秀课时完成率达到120%客户评价全部5星。建议下月提升课时费标准同时安排更多VIP客户给她。',
tagType: 'coach',
tagLabel: '助教:小燕',
createdAt: '2024-11-27 16:00',
},
{
id: 'note-002',
content: '客户今天表示下周有朋友生日聚会想预约包厢。已告知包厢需要提前3天预约客户说会尽快确定时间再联系。',
tagType: 'customer',
tagLabel: '客户:王先生',
createdAt: '2024-11-27 15:30',
},
{
id: 'note-003',
content: '完成高优先召回任务。客户反馈最近工作太忙这周末会来店里。已提醒客户储值卡还有2000元余额下月到期需要续费。',
tagType: 'customer',
tagLabel: '客户:王先生',
createdAt: '2024-11-26 18:45',
},
{
id: 'note-004',
content: 'Lucy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。建议适当调整排班。',
tagType: 'coach',
tagLabel: '助教Lucy',
createdAt: '2024-11-26 11:30',
},
{
id: 'note-005',
content: '泡芙本月表现优秀课时完成率达到120%客户评价全部5星。建议下月提升课时费标准。',
tagType: 'customer',
tagLabel: '客户:陈女士',
createdAt: '2024-11-25 10:20',
},
{
id: 'note-006',
content: '泡芙的斯诺克教学水平有明显提升,最近几位客户反馈都很好。可以考虑让她带更多斯诺克方向的课程。',
tagType: 'coach',
tagLabel: '助教:泡芙',
createdAt: '2024-11-25 14:20',
},
{
id: 'note-007',
content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。客户表示会推荐朋友来店里体验。',
tagType: 'customer',
tagLabel: '客户:李女士',
createdAt: '2024-11-24 21:15',
},
{
id: 'note-008',
content: '小燕反馈近期有几位客户希望增加晚间时段的课程建议协调排班增加21:00-23:00时段的助教配置。',
tagType: 'coach',
tagLabel: '助教:小燕',
createdAt: '2024-11-24 09:00',
},
{
id: 'note-009',
content: '关系构建任务完成。与客户进行了30分钟的深入交流了解到客户是企业高管经常需要商务宴请场地。已记录客户需求后续可以推荐团建套餐。',
tagType: 'customer',
tagLabel: '客户:张先生',
createdAt: '2024-11-23 19:30',
},
{
id: 'note-010',
content: '客户今天充值了5000元选择的是尊享套餐。客户提到喜欢安静的环境以后尽量安排包厢。',
tagType: 'customer',
tagLabel: '客户:张先生',
createdAt: '2024-11-22 14:00',
},
{
id: 'note-011',
content: 'Lucy本周请假2天处理家事已安排泡芙和小燕分担她的客户。回来后需要跟进客户交接情况。',
tagType: 'coach',
tagLabel: '助教Lucy',
createdAt: '2024-11-22 10:15',
},
{
id: 'note-012',
content: 'Lucy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。',
tagType: 'customer',
tagLabel: '客户:李女士',
createdAt: '2024-11-21 09:45',
},
]
// ============================================================
// Mock 数据 — 绩效模块
// ============================================================
export const mockPerformance: PerformanceData = {
monthlyIncome: 12680,
incomeChange: 15.3,
currentTier: '银牌助教',
nextTierGap: 3320,
todayServiceCount: 4,
weekServiceCount: 18,
monthServiceCount: 67,
}
export const mockPerformanceRecords: PerformanceRecord[] = [
{ id: 'pr-001', customerName: '张伟', amount: 380, date: '2026-03-05', type: '课时', category: '中式台球' },
{ id: 'pr-002', customerName: '李娜', amount: 200, date: '2026-03-04', type: '散客', category: '斯诺克' },
{ id: 'pr-003', customerName: '王磊', amount: 1500, date: '2026-03-03', type: '储值', category: '会员充值' },
{ id: 'pr-004', customerName: '赵敏', amount: 280, date: '2026-03-02', type: '课时', category: '中式台球' },
{ id: 'pr-005', customerName: '陈浩', amount: 150, date: '2026-03-01', type: '散客', category: '中式台球' },
{ id: 'pr-006', customerName: '刘洋', amount: 320, date: '2026-02-28', type: '课时', category: '斯诺克' },
{ id: 'pr-007', customerName: '张伟', amount: 2000, date: '2026-02-25', type: '储值', category: '会员充值' },
]
// ============================================================
// Mock 数据 — 看板模块
// ============================================================
export const mockBoardFinance: BoardFinanceData = {
metrics: [
{
title: '本月营收',
value: 86500,
unit: '元',
trend: 'up',
trendValue: '+12.5%',
helpText: '包含课时费、散客消费、储值充值等所有收入',
},
{
title: '本月支出',
value: 34200,
unit: '元',
trend: 'down',
trendValue: '-3.2%',
helpText: '包含人工、水电、耗材等运营成本',
},
{
title: '净利润',
value: 52300,
unit: '元',
trend: 'up',
trendValue: '+22.1%',
},
{
title: '客单价',
value: 186,
unit: '元',
trend: 'flat',
trendValue: '+0.5%',
helpText: '本月平均每位客户消费金额',
},
],
timeRange: '2026-03',
filterOptions: [
{ value: 'today', text: '今日' },
{ value: 'week', text: '本周' },
{ value: 'month', text: '本月' },
{ value: 'quarter', text: '本季度' },
],
}
export const mockCustomers: CustomerCard[] = [
{
id: 'cust-001',
name: '张伟',
avatar: '/assets/images/avatar-default.png',
heartScore: 9.2,
tags: ['VIP', '中式台球'],
keyMetric: { label: '本月消费', value: '¥2,380' },
},
{
id: 'cust-002',
name: '李娜',
avatar: '/assets/images/avatar-default.png',
heartScore: 6.5,
tags: ['斯诺克'],
keyMetric: { label: '本月消费', value: '¥680' },
},
{
id: 'cust-003',
name: '王磊',
avatar: '/assets/images/avatar-default.png',
heartScore: 7.8,
tags: ['中式台球', '麻将'],
keyMetric: { label: '本月消费', value: '¥1,500' },
},
{
id: 'cust-004',
name: '赵敏',
avatar: '/assets/images/avatar-default.png',
heartScore: 8.8,
tags: ['中式台球', '新客'],
keyMetric: { label: '本月消费', value: '¥280' },
},
{
id: 'cust-005',
name: '陈浩',
avatar: '/assets/images/avatar-default.png',
heartScore: 4.2,
tags: ['斯诺克', 'K歌'],
keyMetric: { label: '本月消费', value: '¥150' },
},
]
export const mockCoaches: CoachCard[] = [
{
id: 'coach-001',
name: '李明',
avatar: '/assets/images/avatar-default.png',
level: '金牌助教',
keyMetrics: [
{ label: '本月课时', value: '86 节' },
{ label: '本月收入', value: '¥18,600' },
{ label: '服务客户', value: '32 人' },
],
},
{
id: 'coach-002',
name: '王芳',
avatar: '/assets/images/avatar-default.png',
level: '银牌助教',
keyMetrics: [
{ label: '本月课时', value: '67 节' },
{ label: '本月收入', value: '¥12,680' },
{ label: '服务客户', value: '25 人' },
],
},
{
id: 'coach-003',
name: '刘强',
avatar: '/assets/images/avatar-default.png',
level: '铜牌助教',
keyMetrics: [
{ label: '本月课时', value: '45 节' },
{ label: '本月收入', value: '¥8,200' },
{ label: '服务客户', value: '18 人' },
],
},
]
// ============================================================
// Mock 数据 — 客户详情模块
// ============================================================
export const mockCustomerDetail: CustomerDetail = {
id: 'cust-001',
name: '张伟',
avatar: '/assets/images/avatar-default.png',
tags: ['VIP', '中式台球', '老客户'],
heartScore: 9.2,
phone: '138****6789',
spiIndex: 87,
consumptionRecords: [
{ id: 'cr-001', date: '2026-03-05', project: '中式台球 1v1', duration: 90, amount: 380, coachName: '王芳' },
{ id: 'cr-002', date: '2026-03-01', project: '斯诺克练习', duration: 60, amount: 200, coachName: '李明' },
{ id: 'cr-003', date: '2026-02-25', project: '中式台球 1v1', duration: 120, amount: 480, coachName: '王芳' },
{ id: 'cr-004', date: '2026-02-20', project: '会员充值', duration: 0, amount: 2000, coachName: '-' },
{ id: 'cr-005', date: '2026-02-15', project: '中式台球小组课', duration: 90, amount: 280, coachName: '刘强' },
],
}
// ============================================================
// Mock 数据 — 对话模块
// ============================================================
export const mockChatMessages: ChatMessage[] = [
{
id: 'msg-001',
role: 'user',
content: '帮我看看张伟最近的消费情况',
timestamp: '2026-03-05T14:00:00+08:00',
referenceCard: {
type: 'customer',
title: '张伟 — 消费概览',
summary: '近 30 天消费 3 次,总额 ¥1,060',
data: {
'最近到店': '2026-03-05',
'偏好项目': '中式台球 1v1',
'常约助教': '王芳',
'爱心评分': '9.2',
},
},
},
{
id: 'msg-002',
role: 'assistant',
content: '张伟最近一个月消费了 3 次,总金额 ¥1,060。消费频次稳定主要偏好中式台球 1v1 课程。',
timestamp: '2026-03-05T14:00:05+08:00',
},
{
id: 'msg-003',
role: 'user',
content: '他适合推荐什么课程?',
timestamp: '2026-03-05T14:01:00+08:00',
},
{
id: 'msg-004',
role: 'assistant',
content: '根据张伟的消费习惯和技术水平,建议推荐以下课程:\n1. 斯诺克进阶课 — 拓展球类兴趣\n2. 中式台球高级技巧班 — 提升现有水平\n3. 周末球友赛 — 增强社交粘性',
timestamp: '2026-03-05T14:01:08+08:00',
},
{
id: 'msg-005',
role: 'user',
content: '好的,帮我记一下,下次回访时推荐斯诺克进阶课',
timestamp: '2026-03-05T14:02:00+08:00',
},
{
id: 'msg-006',
role: 'assistant',
content: '已记录。下次回访张伟时,我会提醒你推荐斯诺克进阶课程。',
timestamp: '2026-03-05T14:02:03+08:00',
},
]
export const mockChatHistory: ChatHistoryItem[] = [
{
id: 'chat-001',
title: '张伟消费分析',
lastMessage: '已记录。下次回访张伟时,我会提醒你推荐斯诺克进阶课程。',
timestamp: '2026-03-05T14:02:03+08:00',
customerName: '张伟',
},
{
id: 'chat-002',
title: '本周业绩汇总',
lastMessage: '本周总收入 ¥18,600环比上周增长 8.3%。',
timestamp: '2026-03-04T09:30:00+08:00',
},
{
id: 'chat-003',
title: '李娜召回策略',
lastMessage: '建议发送专属优惠券并电话关怀,了解未到店原因。',
timestamp: '2026-03-03T16:00:00+08:00',
customerName: '李娜',
},
{
id: 'chat-004',
title: '新客户接待建议',
lastMessage: '首次到店客户建议安排体验课,重点介绍会员权益。',
timestamp: '2026-03-02T11:20:00+08:00',
},
]
// ============================================================
// Mock 数据 — 用户信息
// ============================================================
export const mockUserProfile: UserProfile = {
name: '小燕',
avatar: '/assets/images/avatar-coach.png',
role: '助教',
storeName: '朗朗桌球',
}

View File

@@ -0,0 +1,65 @@
/**
* 金额 / 计数 / 百分比格式化工具
* 规范docs/miniprogram-dev/design-system/DISPLAY-STANDARDS.md
*/
/**
* 金额格式化
* ¥12,680 / -¥368 / ¥0 / --
* @param value 金额(元,整数)
*/
export function formatMoney(value: number | null | undefined): string {
if (value === null || value === undefined) return '--'
if (value === 0) return '¥0'
const abs = Math.round(Math.abs(value))
const formatted = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return value < 0 ? `${formatted}` : `¥${formatted}`
}
/**
* 计数格式化(带单位)
* 零值返回 '--'≥1000 加千分位
* @param value 数值
* @param unit 单位字符串,如 '笔' '次' '人'
*/
export function formatCount(
value: number | null | undefined,
unit: string,
): string {
if (value === null || value === undefined || value === 0) return '--'
const n =
value >= 1000
? value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
: String(value)
return `${n}${unit}`
}
/**
* 纯数字千分位格式化(无单位,用于非金额大数字)
* 零值返回 '--'
*/
export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined || value === 0) return '--'
return value >= 1000
? value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
: String(value)
}
/**
* 百分比展示允许超过100%
* 保留1位小数零值返回 '0%';空值返回 '--'
*/
export function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) return '--'
if (value === 0) return '0%'
return `${value.toFixed(1)}%`
}
/**
* 进度条 CSS 宽度字符串(截断至 [0, 100],防止溢出)
* 用法style="width: {{toProgressWidth(perfData.filledPct)}}"
*/
export function toProgressWidth(value: number | null | undefined): string {
if (value === null || value === undefined) return '0%'
return `${Math.min(100, Math.max(0, value)).toFixed(1)}%`
}

View File

@@ -0,0 +1,43 @@
/**
* 星级评分转换工具函数
* 纯函数,无 wx.* 依赖
*
* API 使用 0-10 分制UI 使用 0-5 星制(支持半星)
*/
/**
* 将 0-10 分数转换为 0-5 星级
* @param score 0-10 分数
* @returns 0-5 星级值
*/
export function scoreToStar(score: number): number {
return score / 2
}
/**
* 将 0-5 星级转换为 0-10 分数
* @param star 0-5 星级值
* @returns 0-10 分数
*/
export function starToScore(star: number): number {
return star * 2
}
/**
* 将 0-10 分数四舍五入到最近 0.5 星
* 用于后端评分 → 半星展示
* @param score 0-10 分数
* @returns 0-5 星(步长 0.5
*/
export function scoreToHalfStar(score: number): number {
return Math.round((score / 2) * 2) / 2
}
/**
* 判断是否为未评分状态
* score 为 null / undefined / 0 时视为未评分
* @returns true 时展示 '--' 替代星星
*/
export function isUnrated(score: number | null | undefined): boolean {
return score === null || score === undefined || score === 0
}

View File

@@ -0,0 +1,21 @@
/**
* 渲染辅助工具函数
* 纯函数,无 wx.* 依赖
*/
/**
* 从对象中提取指定字段,缺失字段填充 null
* @param item 数据对象
* @param fieldList 需要的字段名数组
* @returns 仅包含指定字段的新对象
*/
export function getRequiredFields(
item: Record<string, any>,
fieldList: string[]
): Record<string, any> {
const result: Record<string, any> = {}
for (const field of fieldList) {
result[field] = field in item ? item[field] : null
}
return result
}

View File

@@ -0,0 +1,204 @@
/**
* 统一请求封装
*
* - 自动附加 Authorization: Bearer <token>
* - 401 时自动尝试 refresh_token 刷新,刷新成功后重试原请求
* - 刷新失败时清除 token 并跳转 login 页面
* - BASE_URL 从 config.ts 读取(按运行环境自动切换)
*/
import { API_BASE } from "./config"
export interface RequestOptions {
url: string
method?: "GET" | "POST" | "PUT" | "DELETE"
data?: any
header?: Record<string, string>
/** 是否需要认证,默认 true */
needAuth?: boolean
}
/** 是否正在刷新 token防止并发刷新 */
let isRefreshing = false
/** 刷新期间排队的请求(等刷新完成后统一重试) */
let pendingQueue: Array<{
resolve: (value: any) => void
reject: (reason: any) => void
options: RequestOptions
}> = []
/**
* 从 globalData 或 Storage 获取 access_token
*/
function getAccessToken(): string | undefined {
const app = getApp<IAppOption>()
return app.globalData.token || wx.getStorageSync("token") || undefined
}
/**
* 从 globalData 或 Storage 获取 refresh_token
*/
function getRefreshToken(): string | undefined {
const app = getApp<IAppOption>()
return app.globalData.refreshToken || wx.getStorageSync("refreshToken") || undefined
}
/**
* 清除本地存储的所有 token
*/
function clearTokens(): void {
const app = getApp<IAppOption>()
app.globalData.token = undefined
app.globalData.refreshToken = undefined
wx.removeStorageSync("token")
wx.removeStorageSync("refreshToken")
}
/**
* 保存 token 到 globalData 和 Storage
*/
function saveTokens(accessToken: string, refreshToken: string): void {
const app = getApp<IAppOption>()
app.globalData.token = accessToken
app.globalData.refreshToken = refreshToken
wx.setStorageSync("token", accessToken)
wx.setStorageSync("refreshToken", refreshToken)
}
/**
* 跳转到登录页(使用 reLaunch 清空页面栈)
*/
function redirectToLogin(): void {
wx.reLaunch({ url: "/pages/login/login" })
}
/**
* 执行底层 wx.request返回 Promise
*/
function wxRequest(options: RequestOptions): Promise<any> {
return new Promise((resolve, reject) => {
wx.request({
url: `${API_BASE}${options.url}`,
method: options.method || "GET",
data: options.data,
header: options.header || {},
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject({ statusCode: res.statusCode, data: res.data })
}
},
fail(err) {
reject({ statusCode: 0, data: err })
},
})
})
}
/**
* 尝试用 refresh_token 刷新令牌
*
* 成功 → 保存新 token 并返回 true
* 失败 → 清除 token、跳转登录页、返回 false
*/
async function tryRefreshToken(): Promise<boolean> {
const rt = getRefreshToken()
if (!rt) {
clearTokens()
redirectToLogin()
return false
}
try {
const data = await wxRequest({
url: "/api/xcx/refresh",
method: "POST",
data: { refresh_token: rt },
header: { "Content-Type": "application/json" },
needAuth: false,
})
if (data.access_token && data.refresh_token) {
saveTokens(data.access_token, data.refresh_token)
return true
}
// 响应格式异常,视为刷新失败
clearTokens()
redirectToLogin()
return false
} catch {
clearTokens()
redirectToLogin()
return false
}
}
/**
* 处理排队中的请求:刷新成功后全部重试,失败则全部拒绝
*/
function flushPendingQueue(success: boolean): void {
const queue = [...pendingQueue]
pendingQueue = []
for (const item of queue) {
if (success) {
// 重试时会自动附加新 token
request(item.options).then(item.resolve, item.reject)
} else {
item.reject({ statusCode: 401, data: { detail: "刷新令牌失败" } })
}
}
}
/**
* 统一请求入口
*
* @param options 请求配置
* @returns 响应数据(已解析的 JSON
*/
export function request(options: RequestOptions): Promise<any> {
const needAuth = options.needAuth !== false
const headers: Record<string, string> = {
"Content-Type": "application/json",
...options.header,
}
// 自动附加 Authorization header
if (needAuth) {
const token = getAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
}
const finalOptions: RequestOptions = { ...options, header: headers }
return wxRequest(finalOptions).catch(async (err) => {
// 非 401 或不需要认证的请求,直接抛出
if (err.statusCode !== 401 || !needAuth) {
throw err
}
// 401 → 尝试刷新 token
if (isRefreshing) {
// 已有刷新请求在进行中,排队等待
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject, options })
})
}
isRefreshing = true
try {
const ok = await tryRefreshToken()
flushPendingQueue(ok)
if (!ok) {
throw { statusCode: 401, data: { detail: "刷新令牌失败" } }
}
// 刷新成功,用新 token 重试原请求
return request(options)
} finally {
isRefreshing = false
}
})
}
export default request

View File

@@ -0,0 +1,45 @@
/**
* 路由辅助工具函数
* getMenuRoute 为纯函数navigateTo/switchTab/navigateBack 封装 wx.* API
*/
/** 菜单 key → 页面路径映射 */
const MENU_ROUTE_MAP: Record<string, string> = {
'chat-history': '/pages/chat-history/chat-history',
'performance': '/pages/performance/performance',
'notes': '/pages/notes/notes',
'settings': '',
}
/**
* 获取菜单项对应的页面路径
* @param menuKey 菜单 key
* @returns 页面路径,未知 key 返回空字符串
*/
export function getMenuRoute(menuKey: string): string {
return MENU_ROUTE_MAP[menuKey] ?? ''
}
/**
* 跳转到普通页面
* @param url 页面路径
*/
export function navigateTo(url: string): void {
wx.navigateTo({ url })
}
/**
* 切换 TabBar 页面
* @param url TabBar 页面路径
*/
export function switchTab(url: string): void {
wx.switchTab({ url })
}
/**
* 返回上一页
* @param delta 返回层数,默认 1
*/
export function navigateBack(delta: number = 1): void {
wx.navigateBack({ delta })
}

View File

@@ -0,0 +1,52 @@
/**
* 排序工具函数
* 纯函数,无 wx.* 依赖,可在 Node.js 测试环境中使用
*/
/**
* 按时间戳字段降序排序(最新在前)
* @param list 带时间戳字段的对象数组
* @param field 时间戳字段名,默认 'timestamp'
* @returns 排序后的新数组(不修改原数组)
*/
export function sortByTimestamp<T extends Record<string, any>>(
list: T[],
field: string = 'timestamp'
): T[] {
return [...list].sort((a, b) => {
const ta = new Date(a[field]).getTime()
const tb = new Date(b[field]).getTime()
// NaN 排到末尾
if (isNaN(ta) && isNaN(tb)) return 0
if (isNaN(ta)) return 1
if (isNaN(tb)) return -1
return tb - ta
})
}
/**
* 按指定字段排序
* @param list 对象数组
* @param field 排序字段名
* @param order 排序方向,'asc' 升序 / 'desc' 降序(默认 'desc'
* @returns 排序后的新数组
*/
export function sortByField<T extends Record<string, any>>(
list: T[],
field: string,
order: 'asc' | 'desc' = 'desc'
): T[] {
return [...list].sort((a, b) => {
const va = a[field]
const vb = b[field]
let cmp: number
if (typeof va === 'number' && typeof vb === 'number') {
cmp = va - vb
} else {
cmp = String(va ?? '').localeCompare(String(vb ?? ''))
}
return order === 'asc' ? cmp : -cmp
})
}

View File

@@ -0,0 +1,58 @@
/**
* 任务类型配置
*/
export const TASK_TYPE_CONFIG = {
high_priority: {
label: '高优先召回',
color: 'red',
bgColor: '#fee2e2',
borderColor: '#fca5a5',
textColor: '#b91c1c',
bannerBg: '/assets/images/banner-bg-red-aurora.svg',
},
priority_recall: {
label: '优先召回',
color: 'orange',
bgColor: '#fed7aa',
borderColor: '#fdba74',
textColor: '#b45309',
bannerBg: '/assets/images/banner-bg-orange-aurora.svg',
},
relationship: {
label: '关系构建',
color: 'pink',
bgColor: '#fce7f3',
borderColor: '#f9a8d4',
textColor: '#be185d',
bannerBg: '/assets/images/banner-bg-pink-aurora.svg',
},
callback: {
label: '客户回访',
color: 'teal',
bgColor: '#ccfbf1',
borderColor: '#99f6e4',
textColor: '#0d9488',
bannerBg: '/assets/images/banner-bg-teal-aurora.svg',
},
} as const
/**
* 任务状态配置
*/
export const TASK_STATUS_CONFIG = {
normal: {
label: '进行中',
icon: '📋',
},
pinned: {
label: '已置顶',
icon: '📌',
},
abandoned: {
label: '已放弃',
icon: '❌',
},
} as const
export type TaskType = keyof typeof TASK_TYPE_CONFIG
export type TaskStatus = keyof typeof TASK_STATUS_CONFIG

View File

@@ -0,0 +1,36 @@
/**
* 任务类型映射工具函数
* 纯函数,无 wx.* 依赖
*/
export type TaskType = 'callback' | 'priority_recall' | 'relationship'
const TASK_TYPE_COLOR_MAP: Record<TaskType, string> = {
callback: '#0052d9',
priority_recall: '#e34d59',
relationship: '#00a870',
}
const TASK_TYPE_LABEL_MAP: Record<TaskType, string> = {
callback: '回访',
priority_recall: '优先召回',
relationship: '关系构建',
}
/**
* 获取任务类型对应的颜色
* @param type 任务类型
* @returns 颜色 hex 值,未知类型返回灰色
*/
export function getTaskTypeColor(type: TaskType): string {
return TASK_TYPE_COLOR_MAP[type] ?? '#8b8b8b'
}
/**
* 获取任务类型对应的中文标签
* @param type 任务类型
* @returns 中文标签,未知类型返回空字符串
*/
export function getTaskTypeLabel(type: TaskType): string {
return TASK_TYPE_LABEL_MAP[type] ?? ''
}

View File

@@ -0,0 +1,126 @@
/**
* 时间展示工具
* 规范docs/miniprogram-dev/design-system/DATETIME-DISPLAY-STANDARD.md
*
* 规则(由近及远):
* < 120s → 刚刚
* 2min ~ 59min → N分钟前
* 1h ~ 23h → N小时前
* 1d ~ 3d → N天前
* > 3d 同年 → MM-DD
* > 3d 跨年 → YYYY-MM-DD
*/
/**
* 将时间戳或 ISO 字符串格式化为相对时间文案
* @param value Unix 毫秒时间戳 或 ISO 8601 字符串(如 "2026-03-10T16:30:00Z"
* @returns 格式化文案如「刚刚」「2分钟前」「03-10」
*/
/**
* 课时格式化
* 整数 → Nh非整数保留1位 → N.Nh零值 → 0h空值 → --
*/
export function formatHours(hours: number | null | undefined): string {
if (hours === null || hours === undefined) return '--'
if (hours === 0) return '0h'
return hours % 1 === 0 ? `${hours}h` : `${hours.toFixed(1)}h`
}
/**
* 任务截止日期语义化格式化
* 规范docs/miniprogram-dev/design-system/DISPLAY-STANDARDS-2.md §7
*
* @returns { text, style }
* style: 'muted'(灰) | 'normal'(正常) | 'warning'(橙/今天) | 'danger'(红/逾期)
*/
export function formatDeadline(
deadline: string | null | undefined,
): { text: string; style: 'normal' | 'warning' | 'danger' | 'muted' } {
if (!deadline) return { text: '--', style: 'muted' }
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const target = new Date(deadline)
const targetDay = new Date(target.getFullYear(), target.getMonth(), target.getDate())
const diff = Math.round((targetDay.getTime() - today.getTime()) / 86400000)
if (diff < 0) return { text: `逾期 ${Math.abs(diff)}`, style: 'danger' }
if (diff === 0) return { text: '今天到期', style: 'warning' }
if (diff <= 7) return { text: `还剩 ${diff}`, style: 'normal' }
const m = String(target.getMonth() + 1).padStart(2, '0')
const d = String(target.getDate()).padStart(2, '0')
return { text: `${m}-${d}`, style: 'muted' }
}
export function formatRelativeTime(value: number | string | undefined | null): string {
if (value === undefined || value === null || value === '') return '--'
const normalized = typeof value === 'string'
? value.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2')
: value
const ts = typeof normalized === 'number' ? normalized : new Date(normalized).getTime()
if (isNaN(ts)) return '--'
const now = Date.now()
const diff = Math.floor((now - ts) / 1000) // 秒
// 未来时间(服务端时钟偏差)按「刚刚」处理
if (diff < 120) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
if (diff < 259200) return `${Math.floor(diff / 86400)}天前`
const date = new Date(ts)
const nowDate = new Date(now)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (year === nowDate.getFullYear()) return `${month}-${day}`
return `${year}-${month}-${day}`
}
/**
* IM 气泡内时间戳格式
* 今天内 → HH:mm
* 今年非今天 → MM-DD HH:mm
* 跨年 → YYYY-MM-DD HH:mm
*/
export function formatIMTime(value: number | string | undefined | null): string {
if (value === undefined || value === null || value === '') return ''
const normalized = typeof value === 'string'
? value.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2')
: value
const ts = typeof normalized === 'number' ? normalized : new Date(normalized).getTime()
if (isNaN(ts)) return ''
const date = new Date(ts)
const now = new Date()
const hh = String(date.getHours()).padStart(2, '0')
const mn = String(date.getMinutes()).padStart(2, '0')
const timeStr = `${hh}:${mn}`
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) return timeStr
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (date.getFullYear() === now.getFullYear()) return `${month}-${day} ${timeStr}`
return `${date.getFullYear()}-${month}-${day} ${timeStr}`
}
/**
* 判断相邻消息是否需要显示时间分割线(间隔 >= 5 分钟,首条始终显示)
*/
export function shouldShowTimeDivider(
prevTimestamp: number | string | null | undefined,
currTimestamp: number | string | undefined | null,
): boolean {
if (!prevTimestamp) return true
const normPrev = typeof prevTimestamp === 'string' ? prevTimestamp.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2') : prevTimestamp
const normCurr = typeof currTimestamp === 'string' ? (currTimestamp as string).replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2') : currTimestamp
const prev = typeof normPrev === 'number' ? normPrev : new Date(normPrev).getTime()
const curr = typeof normCurr === 'number' ? normCurr : new Date(normCurr as string).getTime()
if (isNaN(prev) || isNaN(curr)) return false
return curr - prev >= 5 * 60 * 1000
}

View File

@@ -0,0 +1,86 @@
// WXS 时间格式化桥接 — WXML 中不能调用 TS/JS 模块,需通过 WXS 桥接
// 规范docs/miniprogram-dev/design-system/DATETIME-DISPLAY-STANDARD.md
//
// 规则:
// < 120s -> 刚刚
// 2~59min -> N分钟前
// 1~23h -> N小时前
// 1~3d -> N天前
// >3d 同年 -> MM-DD
// >3d 跨年 -> YYYY-MM-DD
function relativeTime(value) {
if (value === undefined || value === null || value === '') return '--'
var ts
if (typeof value === 'number') {
ts = value
} else {
var parsed = getDate(value)
ts = parsed.getTime()
if (isNaN(ts)) return '--'
}
var now = getDate().getTime()
var diff = Math.floor((now - ts) / 1000)
if (diff < 120) return '刚刚'
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 259200) return Math.floor(diff / 86400) + '天前'
var date = getDate(ts)
var nowDate = getDate(now)
var year = date.getFullYear()
var nowYear = nowDate.getFullYear()
var month = date.getMonth() + 1
var day = date.getDate()
var mm = month < 10 ? '0' + month : '' + month
var dd = day < 10 ? '0' + day : '' + day
if (year === nowYear) return mm + '-' + dd
return year + '-' + mm + '-' + dd
}
// IM 气泡内时间戳格式
// 今天内 -> HH:mm
// 今年非今天 -> MM-DD HH:mm
// 跨年 -> YYYY-MM-DD HH:mm
function imTime(value) {
if (value === undefined || value === null || value === '') return ''
var ts
if (typeof value === 'number') {
ts = value
} else {
var parsed = getDate(value)
ts = parsed.getTime()
if (isNaN(ts)) return ''
}
var date = getDate(ts)
var now = getDate()
var hh = date.getHours()
var mn = date.getMinutes()
var hhStr = hh < 10 ? '0' + hh : '' + hh
var mnStr = mn < 10 ? '0' + mn : '' + mn
var timeStr = hhStr + ':' + mnStr
var isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) return timeStr
var month = date.getMonth() + 1
var day = date.getDate()
var mm = month < 10 ? '0' + month : '' + month
var dd = day < 10 ? '0' + day : '' + day
if (date.getFullYear() === now.getFullYear()) return mm + '-' + dd + ' ' + timeStr
return date.getFullYear() + '-' + mm + '-' + dd + ' ' + timeStr
}
module.exports = {
relativeTime: relativeTime,
imTime: imTime,
}

View File

@@ -0,0 +1,30 @@
/**
* 全局 UI 常量
* 集中管理 showModal / showToast 中重复使用的样式和文案
*/
import { GLOBAL_COLORS } from './vi-colors'
// ============================================
// Modal 样式
// ============================================
/** 危险操作确认按钮颜色(删除、退出等) */
export const CONFIRM_DANGER_COLOR = GLOBAL_COLORS.error // #e34d59
// ============================================
// Toast 文案
// ============================================
export const TOAST = {
/** 页面跳转失败9 处使用) */
NAV_FAILED: '页面跳转失败',
/** 已删除 */
DELETED: '已删除',
/** 备注已保存 */
NOTE_SAVED: '备注已保存',
/** 手机号码已复制 */
PHONE_COPIED: '手机号码已复制',
/** 获取状态失败 */
STATUS_FAILED: '获取状态失败',
} as const

View File

@@ -0,0 +1,19 @@
export const formatTime = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return (
[year, month, day].map(formatNumber).join('/') +
' ' +
[hour, minute, second].map(formatNumber).join(':')
)
}
const formatNumber = (n: number) => {
const s = n.toString()
return s[1] ? s : '0' + s
}

View File

@@ -0,0 +1,346 @@
/**
* VI 标准常量库
* 微信小程序前端配色系统
* 最后更新2026-03-17
*/
// ============================================
// 1. 任务分类配色
// ============================================
export const TASK_TYPE_COLORS = {
high_priority: {
name: '高优先召回',
priority: 0,
borderColor: '#dc2626',
gradientFrom: '#b91c1c',
gradientTo: '#dc2626',
condition: 'max(WBI,NCI) > 7',
},
priority_recall: {
name: '优先召回',
priority: 0,
borderColor: '#f97316',
gradientFrom: '#ea580c',
gradientTo: '#f97316',
condition: 'max(WBI,NCI) > 5',
},
callback: {
name: '客户回访',
priority: 1,
borderColor: '#14b8a6',
gradientFrom: '#0d9488',
gradientTo: '#14b8a6',
condition: '完成召回后未备注',
},
relationship: {
name: '关系构建',
priority: 2,
borderColor: '#f472b6',
gradientFrom: '#ec4899',
gradientTo: '#f472b6',
condition: 'RS < 6',
},
};
// ============================================
// 2. 客户标签配色6种
// ============================================
export const CUSTOMER_TAG_COLORS = {
basic_info: {
textColor: '#0052d9',
backgroundColor: '#ecf2fe',
borderColor: '#bfdbfe',
psychology: '蓝色 - 信任、基础信息',
},
consumption: {
textColor: '#00a870',
backgroundColor: '#e6f7f0',
borderColor: '#a7f3d0',
psychology: '绿色 - 增长、消费行为',
},
play_pref: {
textColor: '#ed7b2f',
backgroundColor: '#fff3e6',
borderColor: '#fed7aa',
psychology: '橙色 - 活力、娱乐偏好',
},
promo_pref: {
textColor: '#d4a017',
backgroundColor: '#fffbeb',
borderColor: '#fef3c7',
psychology: '金色 - 价值、优惠敏感度',
},
social: {
textColor: '#764ba2',
backgroundColor: '#f3e8ff',
borderColor: '#e9d5ff',
psychology: '紫色 - 社交、人脉关系',
},
feedback: {
textColor: '#e34d59',
backgroundColor: '#ffe6e8',
borderColor: '#fecdd3',
psychology: '红色 - 警示、关键信息',
},
};
// ============================================
// 3. 关系等级配色4种
// ============================================
export const RELATIONSHIP_LEVELS = {
excellent: {
name: '很好',
emoji: '💖',
scoreRange: '> 8.5',
gradientFrom: '#e91e63',
gradientTo: '#f472b6',
shadowColor: 'rgba(233,30,99,0.30)',
label: '优质客户',
},
good: {
name: '良好',
emoji: '🧡',
scoreRange: '6-8.5',
gradientFrom: '#ea580c',
gradientTo: '#fb923c',
shadowColor: 'rgba(234,88,12,0.30)',
label: '稳定客户',
},
normal: {
name: '一般',
emoji: '💛',
scoreRange: '3.5-6',
gradientFrom: '#eab308',
gradientTo: '#fbbf24',
shadowColor: 'rgba(234,179,8,0.30)',
label: '普通客户',
},
poor: {
name: '待发展',
emoji: '💙',
scoreRange: '< 3.5',
gradientFrom: '#64748b',
gradientTo: '#94a3b8',
shadowColor: 'rgba(100,116,139,0.30)',
label: '需维护客户',
},
};
// ============================================
// 4. 置顶/放弃状态
// ============================================
export const TASK_STATUS_COLORS = {
pinned: {
name: '置顶',
glowColor: '#f59e0b',
shadowLight: 'rgba(245, 158, 11, 0.12)',
shadowGlow: 'rgba(245, 158, 11, 0.08)',
shadowCSS: '0 5rpx 7rpx rgba(245, 158, 11, 0.12), 0 0 0 8rpx rgba(245, 158, 11, 0.08)',
},
abandoned: {
name: '放弃',
borderColor: '#d1d5db',
textColor: '#9ca3af',
opacity: 0.55,
},
};
// ============================================
// 5. 助教等级配色4种 + 星级)
// ============================================
export const COACH_LEVEL_COLORS = {
junior: {
name: '初级',
textColor: '#0052d9',
backgroundColor: '#ecf2fe',
borderColor: '#bfdbfe',
},
middle: {
name: '中级',
textColor: '#ed7b2f',
backgroundColor: '#fff3e6',
borderColor: '#fed7aa',
},
senior: {
name: '高级',
textColor: '#e91e63',
backgroundColor: '#ffe6e8',
borderColor: '#fecdd3',
},
star: {
name: '⭐ 星级',
textColor: '#fbbf24',
backgroundColor: '#fffef0',
borderColor: '#fef3c7',
},
};
// ============================================
// 6. 星星评分配色
// ============================================
export const STAR_RATING_COLORS = {
low: {
scoreRange: '1-2',
stars: '0.5-1',
color: '#e34d59',
label: '低满意度',
},
mediumLow: {
scoreRange: '3-4',
stars: '1.5-2',
color: '#ed7b2f',
label: '一般满意度',
},
medium: {
scoreRange: '5-6',
stars: '2.5-3',
color: '#eab308',
label: '中等满意度',
},
mediumHigh: {
scoreRange: '7-8',
stars: '3.5-4',
color: '#00a870',
label: '高满意度',
},
high: {
scoreRange: '9-10',
stars: '4.5-5',
color: '#e91e63',
label: '非常满意',
},
};
// ============================================
// 7. AI 图标随机配色库6种
// ============================================
export const AI_COLOR_SCHEMES = [
{
name: 'red',
from: '#e74c3c',
to: '#f39c9c',
fromDeep: '#c0392b',
toDeep: '#e74c3c',
className: 'ai-color-red',
},
{
name: 'orange',
from: '#e67e22',
to: '#f5c77e',
fromDeep: '#ca6c17',
toDeep: '#e67e22',
className: 'ai-color-orange',
},
{
name: 'yellow',
from: '#d4a017',
to: '#f7dc6f',
fromDeep: '#b8860b',
toDeep: '#d4a017',
className: 'ai-color-yellow',
},
{
name: 'blue',
from: '#2980b9',
to: '#7ec8e3',
fromDeep: '#1a5276',
toDeep: '#2980b9',
className: 'ai-color-blue',
},
{
name: 'indigo',
from: '#667eea',
to: '#a78bfa',
fromDeep: '#4a5fc7',
toDeep: '#667eea',
className: 'ai-color-indigo',
},
{
name: 'purple',
from: '#764ba2',
to: '#c084fc',
fromDeep: '#5b3080',
toDeep: '#764ba2',
className: 'ai-color-purple',
},
];
// ============================================
// 8. 全局颜色变量CSS Variables
// ============================================
export const GLOBAL_COLORS = {
// 主色系
primary: '#0052d9',
primaryLight: '#ecf2fe',
success: '#00a870',
warning: '#ed7b2f',
error: '#e34d59',
// 灰度系13级
gray1: '#f3f3f3',
gray2: '#eeeeee',
gray3: '#e7e7e7',
gray4: '#dcdcdc',
gray5: '#c5c5c5',
gray6: '#a6a6a6',
gray7: '#8b8b8b',
gray8: '#777777',
gray9: '#5e5e5e',
gray10: '#4b4b4b',
gray11: '#393939',
gray12: '#2c2c2c',
gray13: '#242424',
};
// ============================================
// 工具函数
// ============================================
/**
* 获取随机AI配色
*/
export function getRandomAiColor() {
return AI_COLOR_SCHEMES[Math.floor(Math.random() * AI_COLOR_SCHEMES.length)];
}
/**
* 根据任务类型获取颜色
*/
export function getTaskTypeColor(taskType: keyof typeof TASK_TYPE_COLORS) {
return TASK_TYPE_COLORS[taskType];
}
/**
* 根据客户标签获取颜色
*/
export function getCustomerTagColor(tagName: keyof typeof CUSTOMER_TAG_COLORS) {
return CUSTOMER_TAG_COLORS[tagName];
}
/**
* 根据关系分数获取等级和颜色
*/
export function getRelationshipLevel(score: number) {
if (score > 8.5) return RELATIONSHIP_LEVELS.excellent;
if (score >= 6) return RELATIONSHIP_LEVELS.good;
if (score >= 3.5) return RELATIONSHIP_LEVELS.normal;
return RELATIONSHIP_LEVELS.poor;
}
/**
* 根据评分获取星星颜色
*/
export function getStarRatingColor(score: number) {
if (score <= 2) return STAR_RATING_COLORS.low;
if (score <= 4) return STAR_RATING_COLORS.mediumLow;
if (score <= 6) return STAR_RATING_COLORS.medium;
if (score <= 8) return STAR_RATING_COLORS.mediumHigh;
return STAR_RATING_COLORS.high;
}
/**
* 根据助教等级获取颜色
*/
export function getCoachLevelColor(level: keyof typeof COACH_LEVEL_COLORS) {
return COACH_LEVEL_COLORS[level];
}