微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

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,9 @@
// WXS 格式化工具 — WXML 中不能调用 JS 方法,需通过 WXS 桥接
function toFixed(num, digits) {
if (num === undefined || num === null) return '--'
return num.toFixed(digits)
}
module.exports = {
toFixed: toFixed,
}

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,624 @@
// TODO: 联调时删除此文件,改为真实 API 调用
// ============================================================
// 类型定义
// ============================================================
export type TaskType = 'callback' | 'priority_recall' | 'relationship'
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 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: 'priority_recall',
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],
aiAnalysis: {
summary: '张伟是高价值回访客户,近期消费频次稳定,建议维持关系并推荐新课程。',
suggestions: ['推荐斯诺克进阶课程', '赠送体验券增强粘性', '了解近期打球偏好变化'],
},
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 数据贴近 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: 'Amy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。建议适当调整排班。',
tagType: 'coach',
tagLabel: '助教Amy',
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: 'Amy本周请假2天处理家事已安排泡芙和小燕分担她的客户。回来后需要跟进客户交接情况。',
tagType: 'coach',
tagLabel: '助教Amy',
createdAt: '2024-11-22 10:15',
},
{
id: 'note-012',
content: 'Amy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。',
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',
},
{
id: 'msg-002',
role: 'assistant',
content: '张伟最近一个月消费了 3 次,总金额 ¥1,060。消费频次稳定主要偏好中式台球 1v1 课程。',
timestamp: '2026-03-05T14:00:05+08:00',
referenceCard: {
type: 'customer',
title: '张伟 — 消费概览',
summary: '近 30 天消费 3 次,总额 ¥1,060',
data: {
'最近到店': '2026-03-05',
'偏好项目': '中式台球 1v1',
'常约助教': '王芳',
'爱心评分': '9.2',
},
},
},
{
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-default.png',
role: '助教',
storeName: '星辰台球俱乐部',
}

View File

@@ -0,0 +1,24 @@
/**
* 星级评分转换工具函数
* 纯函数,无 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
}

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,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,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
}