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:
@@ -64,6 +64,10 @@ apps/miniprogram/
|
||||
| `pages/reviewing/reviewing` | 审核中等待页 |
|
||||
| `pages/no-permission/no-permission` | 无权限提示页 |
|
||||
| `pages/task-list/task-list` | 任务列表页(H5 原型 1:1 重写,四种任务类型分组) |
|
||||
| `pages/notes/notes` | 备注管理页(备注 CRUD + 关联任务上下文) |
|
||||
| `pages/chat/chat` | AI 对话页(CHAT-2b/3/4,按上下文进入对话) |
|
||||
| `pages/chat-history/chat-history` | 对话历史列表页(CHAT-1) |
|
||||
| `pages/board-coach/board-coach` | 助教看板页(BOARD-1,排序×技能×时间筛选) |
|
||||
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) |
|
||||
| `pages/logs/logs` | 日志页(框架默认) |
|
||||
|
||||
@@ -137,6 +141,15 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
|
||||
| `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 |
|
||||
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 |
|
||||
| `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD |
|
||||
| `/api/xcx/config/skill-types` | GET | 项目类型筛选器配置(CONFIG-1) |
|
||||
| `/api/xcx/board/coaches` | GET | 助教看板(BOARD-1,排序×技能×时间筛选) |
|
||||
| `/api/xcx/board/customers` | GET | 客户看板(BOARD-2,维度×项目筛选 + 分页) |
|
||||
| `/api/xcx/board/finance` | GET | 财务看板(BOARD-3,6 大板块 + 环比开关) |
|
||||
| `/api/xcx/chat/history` | GET | CHAT-1 对话历史列表 |
|
||||
| `/api/xcx/chat/{chat_id}/messages` | GET | CHAT-2a 通过 chatId 查消息 |
|
||||
| `/api/xcx/chat/messages` | GET | CHAT-2b 通过上下文查消息(contextType + contextId) |
|
||||
| `/api/xcx/chat/{chat_id}/messages` | POST | CHAT-3 发送消息(同步回复) |
|
||||
| `/api/xcx/chat/stream` | POST | CHAT-4 SSE 流式对话 |
|
||||
| `/api/xcx-test` | GET | MVP 全链路验证 |
|
||||
|
||||
> 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// AI_CHANGELOG
|
||||
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SKILL_OPTIONS value 从
|
||||
// all/chinese/snooker/mahjong/karaoke 改为 ALL/BILLIARD/SNOOKER/MAHJONG/KTV,
|
||||
// 与后端枚举和数据库 category_code 一致。
|
||||
|
||||
// 助教看板页 — 排序×技能×时间三重筛选,4 种维度卡片
|
||||
// TODO: 联调时替换 mock 数据为真实 API 调用
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
@@ -26,12 +31,14 @@ const SORT_OPTIONS = [
|
||||
{ value: 'task_desc', text: '任务完成最多' },
|
||||
]
|
||||
|
||||
// CHANGE 2026-03-20 | R3 修复:value 改为数据库 category_code,与后端枚举一致。
|
||||
// 后续应改为调用 fetchSkillTypes() API 动态获取,此处作为 fallback。
|
||||
const SKILL_OPTIONS = [
|
||||
{ value: 'all', text: '不限' },
|
||||
{ value: 'chinese', text: '🎱 中式/追分' },
|
||||
{ value: 'snooker', text: '斯诺克' },
|
||||
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
|
||||
{ value: 'karaoke', text: '🎤 团建/K歌' },
|
||||
{ value: 'ALL', text: '不限' },
|
||||
{ value: 'BILLIARD', text: '🎱 中式/追分' },
|
||||
{ value: 'SNOOKER', text: '斯诺克' },
|
||||
{ value: 'MAHJONG', text: '🀄 麻将/棋牌' },
|
||||
{ value: 'KTV', text: '🎤 团建/K歌' },
|
||||
]
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
@@ -230,26 +237,30 @@ Page({
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
const data = MOCK_COACHES
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
try {
|
||||
const data = MOCK_COACHES
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
// 格式化数字字段为展示字符串
|
||||
const enriched = data.map((c) => ({
|
||||
...c,
|
||||
perfHoursLabel: formatHours(c.perfHours),
|
||||
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
|
||||
salaryLabel: formatMoney(c.salary),
|
||||
salaryPerfHoursLabel: formatHours(c.salaryPerfHours),
|
||||
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
|
||||
svAmountLabel: formatMoney(c.svAmount),
|
||||
svCustomerCountLabel: formatCount(c.svCustomerCount, '人'),
|
||||
svConsumeLabel: formatMoney(c.svConsume),
|
||||
taskRecallLabel: formatCount(c.taskRecall, '次'),
|
||||
taskCallbackLabel: formatCount(c.taskCallback, '次'),
|
||||
}))
|
||||
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
// 格式化数字字段为展示字符串
|
||||
const enriched = data.map((c) => ({
|
||||
...c,
|
||||
perfHoursLabel: formatHours(c.perfHours),
|
||||
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
|
||||
salaryLabel: formatMoney(c.salary),
|
||||
salaryPerfHoursLabel: formatHours(c.salaryPerfHours),
|
||||
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
|
||||
svAmountLabel: formatMoney(c.svAmount),
|
||||
svCustomerCountLabel: formatCount(c.svCustomerCount, '人'),
|
||||
svConsumeLabel: formatMoney(c.svConsume),
|
||||
taskRecallLabel: formatCount(c.taskRecall, '次'),
|
||||
taskCallbackLabel: formatCount(c.taskCallback, '次'),
|
||||
}))
|
||||
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
|
||||
}, 400)
|
||||
},
|
||||
|
||||
|
||||
@@ -260,17 +260,21 @@ Page({
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
const data = MOCK_CUSTOMERS
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
try {
|
||||
const data = MOCK_CUSTOMERS
|
||||
if (!data || data.length === 0) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
allCustomers: data,
|
||||
customers: data,
|
||||
totalCount: data.length,
|
||||
pageState: 'normal',
|
||||
})
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
this.setData({
|
||||
allCustomers: data,
|
||||
customers: data,
|
||||
totalCount: data.length,
|
||||
pageState: 'normal',
|
||||
})
|
||||
}, 400)
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { mockChatHistory } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
import { fetchChatHistory } from '../../services/api'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
|
||||
/** VI 规范 §6.2:AI 图标配色系统(6种) */
|
||||
@@ -42,24 +41,25 @@ Page({
|
||||
},
|
||||
|
||||
/** 加载数据 */
|
||||
loadData() {
|
||||
async loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const sorted = sortByTimestamp(mockChatHistory)
|
||||
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
|
||||
...item,
|
||||
timeLabel: formatRelativeTime(item.timestamp),
|
||||
iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)],
|
||||
}))
|
||||
const res = await fetchChatHistory()
|
||||
const list: ChatHistoryDisplay[] = (res.items || []).map((item: any) => ({
|
||||
id: String(item.id),
|
||||
title: item.title || '',
|
||||
lastMessage: item.lastMessage || '',
|
||||
timestamp: item.timestamp || '',
|
||||
customerName: item.customerName,
|
||||
timeLabel: formatRelativeTime(item.timestamp),
|
||||
iconGradient: ICON_GRADIENTS[Math.floor(Math.random() * ICON_GRADIENTS.length)],
|
||||
}))
|
||||
|
||||
this.setData({
|
||||
list,
|
||||
pageState: list.length === 0 ? 'empty' : 'normal',
|
||||
})
|
||||
}, 400)
|
||||
this.setData({
|
||||
list,
|
||||
pageState: list.length === 0 ? 'empty' : 'normal',
|
||||
})
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
@@ -82,8 +82,8 @@ Page({
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 600)
|
||||
async onPullDownRefresh() {
|
||||
await this.loadData()
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
// pages/chat/chat.ts — AI 对话页
|
||||
import { mockChatMessages } from '../../utils/mock-data'
|
||||
import type { ChatMessage } from '../../utils/mock-data'
|
||||
import { simulateStreamOutput } from '../../utils/chat'
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由,替换 mock 为真实 API
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.2: 替换 simulateStreamOutput 为真实 SSE 连接
|
||||
import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
|
||||
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
|
||||
import { API_BASE } from '../../utils/config'
|
||||
|
||||
/** API 返回的消息项类型 */
|
||||
interface ApiMessageItem {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
createdAt: string
|
||||
referenceCard?: {
|
||||
type: 'customer' | 'record'
|
||||
title: string
|
||||
summary: string
|
||||
data: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 referenceCard.data (Record<string,string>) 转为数组供 WXML wx:for 渲染 */
|
||||
function toDataList(data?: Record<string, string>): Array<{ key: string; value: string }> {
|
||||
@@ -11,14 +26,17 @@ function toDataList(data?: Record<string, string>): Array<{ key: string; value:
|
||||
}
|
||||
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片)*/
|
||||
function enrichMessages(msgs: ChatMessage[]) {
|
||||
function enrichMessages(msgs: ApiMessageItem[]) {
|
||||
return msgs.map((m, i) => ({
|
||||
...m,
|
||||
timeLabel: formatRelativeTime(m.timestamp),
|
||||
imTimeLabel: formatIMTime(m.timestamp),
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.createdAt,
|
||||
timeLabel: formatRelativeTime(m.createdAt),
|
||||
imTimeLabel: formatIMTime(m.createdAt),
|
||||
showTimeDivider: shouldShowTimeDivider(
|
||||
i === 0 ? null : msgs[i - 1].timestamp,
|
||||
m.timestamp,
|
||||
i === 0 ? null : msgs[i - 1].createdAt,
|
||||
m.createdAt,
|
||||
),
|
||||
referenceCard: m.referenceCard
|
||||
? { ...m.referenceCard, dataList: toDataList(m.referenceCard.data) }
|
||||
@@ -26,12 +44,114 @@ function enrichMessages(msgs: ChatMessage[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Mock AI 回复模板 */
|
||||
const mockAIReplies = [
|
||||
'根据数据分析,这位客户近期消费频次有所下降,建议安排一次主动回访,了解具体原因。',
|
||||
'好的,我已经记录了你的需求。下次回访时我会提醒你。',
|
||||
'这位客户偏好中式台球,最近对斯诺克也产生了兴趣,可以推荐相关课程。',
|
||||
]
|
||||
/** 从 Storage 获取 access_token(与 request.ts 保持一致) */
|
||||
function getAccessToken(): string | undefined {
|
||||
const app = getApp<IAppOption>()
|
||||
return app.globalData.token || wx.getStorageSync('token') || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 行缓冲解析器
|
||||
* 微信 onChunkReceived 返回的 ArrayBuffer 可能跨事件边界切割,
|
||||
* 需要缓冲不完整的行,只处理完整的 SSE 事件。
|
||||
*/
|
||||
class SSEParser {
|
||||
private buffer = ''
|
||||
private currentEvent = ''
|
||||
private currentData = ''
|
||||
|
||||
constructor(
|
||||
private onMessage: (token: string) => void,
|
||||
private onDone: (messageId: number, createdAt: string) => void,
|
||||
private onError: (message: string) => void,
|
||||
) {}
|
||||
|
||||
/** 将 ArrayBuffer 解码为 UTF-8 字符串(兼容微信环境) */
|
||||
private decodeChunk(chunk: ArrayBuffer): string {
|
||||
// 微信基础库支持 TextDecoder(2.19.2+)
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8').decode(chunk)
|
||||
}
|
||||
// 降级:手动解码 UTF-8 字节
|
||||
const bytes = new Uint8Array(chunk)
|
||||
let str = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i])
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(escape(str))
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
/** 接收一个 chunk 并解析完整的 SSE 事件 */
|
||||
feed(chunk: ArrayBuffer): void {
|
||||
this.buffer += this.decodeChunk(chunk)
|
||||
const lines = this.buffer.split('\n')
|
||||
// 最后一个元素可能是不完整的行,保留在 buffer 中
|
||||
this.buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/\r$/, '')
|
||||
if (trimmed === '') {
|
||||
// 空行 = 事件分隔符,派发当前事件
|
||||
this.dispatch()
|
||||
} else if (trimmed.startsWith('event:')) {
|
||||
this.currentEvent = trimmed.slice(6).trim()
|
||||
} else if (trimmed.startsWith('data:')) {
|
||||
this.currentData = trimmed.slice(5).trim()
|
||||
}
|
||||
// 忽略其他行(如 id:、retry:、注释等)
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (!this.currentEvent || !this.currentData) {
|
||||
this.currentEvent = ''
|
||||
this.currentData = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(this.currentData)
|
||||
switch (this.currentEvent) {
|
||||
case 'message':
|
||||
if (data.token != null) this.onMessage(data.token)
|
||||
break
|
||||
case 'done':
|
||||
this.onDone(data.messageId, data.createdAt)
|
||||
break
|
||||
case 'error':
|
||||
this.onError(data.message || 'AI 服务暂时不可用')
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,忽略此事件
|
||||
}
|
||||
|
||||
this.currentEvent = ''
|
||||
this.currentData = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 展示用消息类型 */
|
||||
type DisplayMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: {
|
||||
type: string
|
||||
title: string
|
||||
summary: string
|
||||
data: Record<string, string>
|
||||
dataList?: Array<{ key: string; value: string }>
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -40,12 +160,7 @@ Page({
|
||||
/** 状态栏高度 */
|
||||
statusBarHeight: 0,
|
||||
/** 消息列表 */
|
||||
messages: [] as Array<ChatMessage & {
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
showTimeDivider?: boolean
|
||||
referenceCard?: ChatMessage['referenceCard'] & { dataList?: Array<{ key: string; value: string }> }
|
||||
}>,
|
||||
messages: [] as DisplayMessage[],
|
||||
/** 输入框内容 */
|
||||
inputText: '',
|
||||
/** AI 正在流式回复 */
|
||||
@@ -56,7 +171,9 @@ Page({
|
||||
scrollToId: '',
|
||||
/** 页面顶部引用卡片(从其他页面跳转时) */
|
||||
referenceCard: null as { title: string; summary: string } | null,
|
||||
/** 客户 ID */
|
||||
/** 当前对话 ID(后端返回,用于发送消息) */
|
||||
chatId: '',
|
||||
/** 客户 ID(兼容旧逻辑) */
|
||||
customerId: '',
|
||||
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
|
||||
inputBarBottom: 0,
|
||||
@@ -65,41 +182,80 @@ Page({
|
||||
/** 消息计数器,用于生成唯一 ID */
|
||||
_msgCounter: 0,
|
||||
|
||||
/** 当前 SSE 请求任务(用于中断/重试) */
|
||||
_sseTask: null as WechatMiniprogram.RequestTask | null,
|
||||
|
||||
/** 最后一次发送的用户消息内容(用于重试) */
|
||||
_lastUserContent: '',
|
||||
|
||||
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
|
||||
// 优先级:historyId → taskId → customerId → coachId → general
|
||||
onLoad(options) {
|
||||
const sysInfo = wx.getWindowInfo()
|
||||
const customerId = options?.customerId || ''
|
||||
this.setData({
|
||||
customerId,
|
||||
statusBarHeight: sysInfo.statusBarHeight || 44,
|
||||
})
|
||||
this.loadMessages(customerId)
|
||||
|
||||
if (options?.historyId) {
|
||||
// 从 chat-history 跳转:直接用 historyId 作为 chatId 加载历史消息
|
||||
this.setData({ chatId: options.historyId })
|
||||
this.loadMessages(options.historyId)
|
||||
} else if (options?.taskId) {
|
||||
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
|
||||
this.loadMessagesByContext('task', options.taskId)
|
||||
} else if (options?.customerId) {
|
||||
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.setData({ customerId: options.customerId })
|
||||
this.loadMessagesByContext('customer', options.customerId)
|
||||
} else if (options?.coachId) {
|
||||
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.loadMessagesByContext('coach', options.coachId)
|
||||
} else {
|
||||
// 无参数入口:始终新建通用对话
|
||||
this.loadMessagesByContext('general', '')
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载消息(Mock) */
|
||||
loadMessages(customerId: string) {
|
||||
/** 通过 chatId 加载历史消息(historyId 入口) */
|
||||
async loadMessages(chatId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const messages = enrichMessages(mockChatMessages)
|
||||
this._msgCounter = messages.length
|
||||
const res = await fetchChatMessages(chatId)
|
||||
this.setData({ chatId: String(res.chatId) })
|
||||
const messages = enrichMessages(res.items as ApiMessageItem[])
|
||||
this._msgCounter = messages.length
|
||||
this.setData({
|
||||
pageState: messages.length === 0 ? 'empty' : 'normal',
|
||||
messages,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
// 如果携带 customerId,显示引用卡片
|
||||
const referenceCard = customerId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${customerId} 的相关信息` }
|
||||
: null
|
||||
/** 通过上下文类型加载消息(task/customer/coach/general 入口) */
|
||||
async loadMessagesByContext(contextType: string, contextId: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
try {
|
||||
const res = await fetchChatMessagesByContext(contextType, contextId)
|
||||
// 缓存后端返回的 chatId,后续发送消息使用
|
||||
this.setData({ chatId: String(res.chatId) })
|
||||
const messages = enrichMessages(res.items as ApiMessageItem[])
|
||||
this._msgCounter = messages.length
|
||||
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
// customerId 入口时显示引用卡片
|
||||
const referenceCard = contextType === 'customer' && contextId
|
||||
? { title: '客户详情', summary: `正在查看客户 ${contextId} 的相关信息` }
|
||||
: null
|
||||
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
}, 500)
|
||||
const isEmpty = messages.length === 0 && !referenceCard
|
||||
this.setData({
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
messages,
|
||||
referenceCard,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
@@ -107,12 +263,29 @@ Page({
|
||||
|
||||
/** 返回上一页 */
|
||||
onBack() {
|
||||
// 中断进行中的 SSE 连接
|
||||
if (this._sseTask) {
|
||||
this._sseTask.abort()
|
||||
this._sseTask = null
|
||||
}
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
/** 页面卸载时清理 SSE 连接 */
|
||||
onUnload() {
|
||||
if (this._sseTask) {
|
||||
this._sseTask.abort()
|
||||
this._sseTask = null
|
||||
}
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
onRetry() {
|
||||
this.loadMessages(this.data.customerId)
|
||||
if (this.data.chatId) {
|
||||
this.loadMessages(this.data.chatId)
|
||||
} else {
|
||||
this.loadMessagesByContext('general', '')
|
||||
}
|
||||
},
|
||||
|
||||
/** 软键盘弹出:输入栏上移至键盘顶部 */
|
||||
@@ -141,9 +314,9 @@ Page({
|
||||
const now = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const userMsg = {
|
||||
const userMsg: DisplayMessage = {
|
||||
id: `msg-user-${this._msgCounter}`,
|
||||
role: 'user' as const,
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: now,
|
||||
timeLabel: '刚刚',
|
||||
@@ -160,23 +333,30 @@ Page({
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
// 保存用户消息内容,用于 SSE 发送和重试
|
||||
this._lastUserContent = text
|
||||
|
||||
// CHANGE 2026-03-20 | T8.2: 使用真实 SSE 替代 mock
|
||||
setTimeout(() => {
|
||||
this.triggerAIReply()
|
||||
}, 300)
|
||||
},
|
||||
|
||||
/** 触发 AI 流式回复 */
|
||||
// CHANGE 2026-03-20 | T8.2: 真实 SSE 流式连接替代 mock
|
||||
/** 触发 AI 流式回复(通过 SSE 连接后端 /api/xcx/chat/stream) */
|
||||
triggerAIReply() {
|
||||
const chatId = this.data.chatId
|
||||
const content = this._lastUserContent
|
||||
if (!chatId || !content) return
|
||||
|
||||
this._msgCounter++
|
||||
const aiMsgId = `msg-ai-${this._msgCounter}`
|
||||
const replyText = mockAIReplies[this._msgCounter % mockAIReplies.length]
|
||||
|
||||
const aiNow = new Date().toISOString()
|
||||
const prevMsgs = this.data.messages
|
||||
const prevTs = prevMsgs.length > 0 ? prevMsgs[prevMsgs.length - 1].timestamp : null
|
||||
const aiMsg = {
|
||||
const aiMsg: DisplayMessage = {
|
||||
id: aiMsgId,
|
||||
role: 'assistant' as const,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: aiNow,
|
||||
timeLabel: '刚刚',
|
||||
@@ -190,23 +370,97 @@ Page({
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
})
|
||||
|
||||
this.scrollToBottom()
|
||||
|
||||
const aiIndex = messages.length - 1
|
||||
simulateStreamOutput(replyText, (partial: string) => {
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: partial,
|
||||
streamingContent: partial,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
}).then(() => {
|
||||
this.setData({
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
let fullContent = ''
|
||||
|
||||
const parser = new SSEParser(
|
||||
// onMessage: 逐 token 追加
|
||||
(token: string) => {
|
||||
fullContent += token
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: fullContent,
|
||||
streamingContent: fullContent,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
},
|
||||
// onDone: 流结束,更新消息 ID 和时间
|
||||
(messageId: number, createdAt: string) => {
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].id`]: String(messageId),
|
||||
[`messages[${aiIndex}].timestamp`]: createdAt,
|
||||
[`messages[${aiIndex}].timeLabel`]: formatRelativeTime(createdAt),
|
||||
[`messages[${aiIndex}].imTimeLabel`]: formatIMTime(createdAt),
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
this._sseTask = null
|
||||
},
|
||||
// onError: 显示错误
|
||||
(message: string) => {
|
||||
// 将错误信息作为 AI 回复内容展示
|
||||
const errorContent = fullContent || message
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].content`]: errorContent,
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
this._sseTask = null
|
||||
wx.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||
},
|
||||
)
|
||||
|
||||
// 构建认证 header
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 使用 wx.request + enableChunked 接收 SSE
|
||||
// enableChunked 和 onChunkReceived 在基础库 2.20.2+ 可用,
|
||||
// 当前 typings 未包含,使用 as any 绕过类型检查
|
||||
const requestTask = wx.request({
|
||||
url: `${API_BASE}/api/xcx/chat/stream`,
|
||||
method: 'POST',
|
||||
data: { chatId: Number(chatId), content },
|
||||
header: headers,
|
||||
enableChunked: true,
|
||||
responseType: 'arraybuffer',
|
||||
success: () => {
|
||||
// enableChunked 模式下 success 在连接关闭时触发
|
||||
// 如果此时仍在 streaming 状态,说明连接正常关闭但未收到 done 事件
|
||||
if (this.data.isStreaming) {
|
||||
this.setData({ isStreaming: false, streamingContent: '' })
|
||||
this._sseTask = null
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络错误或连接中断
|
||||
if (this.data.isStreaming) {
|
||||
const errorContent = fullContent || '连接中断,请重试'
|
||||
this.setData({
|
||||
[`messages[${aiIndex}].content`]: errorContent,
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
})
|
||||
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
|
||||
}
|
||||
this._sseTask = null
|
||||
},
|
||||
} as WechatMiniprogram.RequestOption)
|
||||
|
||||
// 监听 chunked 数据(SSE 事件流)
|
||||
// onChunkReceived 在 typings 中未定义,使用 as any
|
||||
;(requestTask as any).onChunkReceived((res: { data: ArrayBuffer }) => {
|
||||
parser.feed(res.data)
|
||||
})
|
||||
|
||||
this._sseTask = requestTask
|
||||
},
|
||||
|
||||
/** 滚动到底部 */
|
||||
|
||||
@@ -98,6 +98,20 @@
|
||||
<view class="bubble bubble-assistant">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
|
||||
<view class="inline-ref-card inline-ref-card--assistant" wx:if="{{item.referenceCard}}">
|
||||
<view class="inline-ref-header">
|
||||
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
|
||||
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
|
||||
</view>
|
||||
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
|
||||
<view class="inline-ref-data">
|
||||
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
|
||||
<text class="ref-data-key">{{entry.key}}</text>
|
||||
<text class="ref-data-value">{{entry.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -245,6 +245,12 @@ page {
|
||||
background-color: #ecf2fe;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* AI 侧引用卡片:左对齐,挂在 AI 气泡下方 */
|
||||
.inline-ref-card--assistant {
|
||||
max-width: 100%;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.inline-ref-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockCoaches } from '../../utils/mock-data'
|
||||
import { fetchCoachDetail } from '../../services/api'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
|
||||
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
|
||||
@@ -328,77 +328,75 @@ Page({
|
||||
}, sparkTriggerDelay)
|
||||
},
|
||||
|
||||
loadData(id: string) {
|
||||
async loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// TODO: 替换为真实 API 调用 GET /api/coaches/:id
|
||||
const basicCoach = mockCoaches.find((c) => c.id === id)
|
||||
const detail: CoachDetail = basicCoach
|
||||
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
|
||||
: mockCoachDetail
|
||||
try {
|
||||
// 从真实 API 获取助教基础信息
|
||||
const basicCoach = await fetchCoachDetail(id)
|
||||
const detail: CoachDetail = basicCoach
|
||||
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
|
||||
: mockCoachDetail
|
||||
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
|
||||
const perf = detail.performance
|
||||
const perfCards = [
|
||||
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
|
||||
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
|
||||
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
|
||||
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
|
||||
]
|
||||
|
||||
const perfGap = perf.perfTarget - perf.perfCurrent
|
||||
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
|
||||
|
||||
// 进度条组件数据
|
||||
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock,实际由接口返回
|
||||
const maxHours = 220
|
||||
const totalHours = perf.monthlyHours
|
||||
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
|
||||
// 当前档位
|
||||
let pbCurrentTier = 0
|
||||
for (let i = 1; i < tierNodes.length; i++) {
|
||||
if (totalHours >= tierNodes[i]) pbCurrentTier = i
|
||||
else break
|
||||
}
|
||||
|
||||
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
|
||||
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
perfCards,
|
||||
perfCurrent: perf.perfCurrent,
|
||||
perfTarget: perf.perfTarget,
|
||||
perfGap,
|
||||
perfPercent,
|
||||
visibleTasks: mockVisibleTasks,
|
||||
hiddenTasks: mockHiddenTasks,
|
||||
abandonedTasks: mockAbandonedTasks,
|
||||
topCustomers: mockTopCustomers,
|
||||
serviceRecords: mockServiceRecords,
|
||||
historyMonths: mockHistoryMonths,
|
||||
sortedNotes: sorted,
|
||||
pbFilledPct,
|
||||
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
|
||||
pbCurrentTier,
|
||||
pbTicks: buildTicks(tierNodes, maxHours),
|
||||
pbShineDurMs: calcShineDur(pbFilledPct),
|
||||
pbSparkDurMs: SPARK_DUR_MS,
|
||||
})
|
||||
|
||||
this.switchIncomeTab('this')
|
||||
// 数据加载完成后启动动画循环
|
||||
setTimeout(() => this._startAnimLoop(), 300)
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const perf = detail.performance
|
||||
const perfCards = [
|
||||
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
|
||||
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
|
||||
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
|
||||
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
|
||||
]
|
||||
|
||||
const perfGap = perf.perfTarget - perf.perfCurrent
|
||||
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
|
||||
|
||||
// 进度条组件数据
|
||||
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock,实际由接口返回
|
||||
const maxHours = 220
|
||||
const totalHours = perf.monthlyHours
|
||||
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
|
||||
// 当前档位
|
||||
let pbCurrentTier = 0
|
||||
for (let i = 1; i < tierNodes.length; i++) {
|
||||
if (totalHours >= tierNodes[i]) pbCurrentTier = i
|
||||
else break
|
||||
}
|
||||
|
||||
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
|
||||
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
perfCards,
|
||||
perfCurrent: perf.perfCurrent,
|
||||
perfTarget: perf.perfTarget,
|
||||
perfGap,
|
||||
perfPercent,
|
||||
visibleTasks: mockVisibleTasks,
|
||||
hiddenTasks: mockHiddenTasks,
|
||||
abandonedTasks: mockAbandonedTasks,
|
||||
topCustomers: mockTopCustomers,
|
||||
serviceRecords: mockServiceRecords,
|
||||
historyMonths: mockHistoryMonths,
|
||||
sortedNotes: sorted,
|
||||
pbFilledPct,
|
||||
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
|
||||
pbCurrentTier,
|
||||
pbTicks: buildTicks(tierNodes, maxHours),
|
||||
pbShineDurMs: calcShineDur(pbFilledPct),
|
||||
pbSparkDurMs: SPARK_DUR_MS,
|
||||
})
|
||||
|
||||
this.switchIncomeTab('this')
|
||||
// 数据加载完成后启动动画循环
|
||||
setTimeout(() => this._startAnimLoop(), 300)
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 切换收入明细 Tab */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockCustomerDetail } from "../../utils/mock-data"
|
||||
import { fetchCustomerDetail } from '../../services/api'
|
||||
|
||||
interface ConsumptionRecord {
|
||||
id: string
|
||||
@@ -248,15 +248,35 @@ Page({
|
||||
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
|
||||
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
|
||||
this.setData({ aiColor })
|
||||
this.loadDetail()
|
||||
const id = options?.id || options?.customerId || ''
|
||||
this.loadDetail(id)
|
||||
},
|
||||
|
||||
loadDetail() {
|
||||
this.setData({ pageState: "normal" })
|
||||
async loadDetail(id?: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
try {
|
||||
if (id) {
|
||||
const detail = await fetchCustomerDetail(id)
|
||||
if (detail) {
|
||||
this.setData({
|
||||
detail: {
|
||||
...this.data.detail,
|
||||
id: detail.id ?? id,
|
||||
name: detail.name || this.data.detail.name,
|
||||
phone: detail.phone || this.data.detail.phone,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
this.setData({ pageState: 'normal' })
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.loadDetail()
|
||||
const id = this.data.detail?.id || ''
|
||||
this.loadDetail(id)
|
||||
},
|
||||
|
||||
/** 查看/隐藏手机号 */
|
||||
@@ -276,11 +296,19 @@ Page({
|
||||
},
|
||||
|
||||
onViewServiceRecords() {
|
||||
wx.navigateTo({ url: "/pages/customer-service-records/customer-service-records" })
|
||||
const customerId = this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
onStartChat() {
|
||||
wx.navigateTo({ url: "/pages/chat/chat" })
|
||||
const customerId = this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/chat/chat?customerId=${customerId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
onAddNote() {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data'
|
||||
import type { ConsumptionRecord } from '../../utils/mock-data'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
// CHANGE 2026-03-20 | RNS1.4 T10.2: 月份切换改为按月请求 API,移除 mock 全量加载+本地过滤
|
||||
import { fetchCustomerRecords, fetchCustomerDetail } from '../../services/api'
|
||||
|
||||
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
|
||||
interface ServiceRecord extends ConsumptionRecord {
|
||||
interface ServiceRecord {
|
||||
id: string
|
||||
date: string
|
||||
project: string
|
||||
duration: number
|
||||
amount: number
|
||||
coachName: string
|
||||
/** 台桌号,如 "A12号台" */
|
||||
table: string
|
||||
/** 课程类型标签,如 "基础课" */
|
||||
@@ -13,7 +18,7 @@ interface ServiceRecord extends ConsumptionRecord {
|
||||
/** 卡片类型:course=普通课,recharge=充值提成 */
|
||||
recordType: 'course' | 'recharge'
|
||||
/** 折算后小时数(原始数字,组件负责加 h 后缀) */
|
||||
duration: number
|
||||
durationHours: number
|
||||
/** 折算前小时数(原始数字,组件负责加 h 后缀) */
|
||||
durationRaw: number
|
||||
/** 到手金额(原始数字,组件负责加 ¥ 前缀) */
|
||||
@@ -23,7 +28,7 @@ interface ServiceRecord extends ConsumptionRecord {
|
||||
/** 商品/饮品描述 */
|
||||
drinks: string
|
||||
/** 显示用日期,如 "2月5日 15:00 - 17:00" */
|
||||
date: string
|
||||
displayDate: string
|
||||
}
|
||||
|
||||
Page({
|
||||
@@ -37,9 +42,9 @@ Page({
|
||||
/** 客户名首字 */
|
||||
customerInitial: '',
|
||||
/** 客户电话(脱敏) */
|
||||
customerPhone: '139****5678',
|
||||
customerPhone: '',
|
||||
/** 客户电话(完整,查看后显示) */
|
||||
customerPhoneFull: '13900005678',
|
||||
customerPhoneFull: '',
|
||||
/** 手机号是否已展开 */
|
||||
phoneVisible: false,
|
||||
/** 累计服务次数 */
|
||||
@@ -49,119 +54,142 @@ Page({
|
||||
/** 当前月份标签 */
|
||||
monthLabel: '',
|
||||
/** 当前年 */
|
||||
currentYear: 2026,
|
||||
currentYear: new Date().getFullYear(),
|
||||
/** 当前月 */
|
||||
currentMonth: 2,
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
/** 最小年月(数据起始) */
|
||||
minYearMonth: 202601,
|
||||
minYearMonth: 202501,
|
||||
/** 最大年月(当前月) */
|
||||
maxYearMonth: 202602,
|
||||
maxYearMonth: new Date().getFullYear() * 100 + (new Date().getMonth() + 1),
|
||||
/** 是否可切换上月 */
|
||||
canPrev: true,
|
||||
/** 是否可切换下月 */
|
||||
canNext: false,
|
||||
/** 月度统计 */
|
||||
monthCount: '6次',
|
||||
monthHours: '11.5h',
|
||||
monthCount: '0次',
|
||||
monthHours: '0h',
|
||||
monthRelation: '0.85',
|
||||
/** 当前月的服务记录 */
|
||||
records: [] as ServiceRecord[],
|
||||
/** 所有记录(原始) */
|
||||
allRecords: [] as ConsumptionRecord[],
|
||||
/** 是否还有更多 */
|
||||
hasMore: false,
|
||||
/** 加载更多中 */
|
||||
loadingMore: false,
|
||||
/** 月份数据加载中(用于月份切换时的 loading 状态) */
|
||||
monthLoading: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const id = options?.customerId || options?.id || ''
|
||||
this.setData({ customerId: id })
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
/** 加载数据 */
|
||||
loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用
|
||||
const customer = mockCustomers.find((c) => c.id === id)
|
||||
const detail = customer
|
||||
? { ...mockCustomerDetail, id: customer.id, name: customer.name }
|
||||
: mockCustomerDetail
|
||||
|
||||
const allRecords = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
|
||||
const name = detail.name || '客户'
|
||||
|
||||
this.setData({
|
||||
customerName: name,
|
||||
customerInitial: name[0] || '?',
|
||||
allRecords,
|
||||
totalServiceCount: allRecords.length,
|
||||
})
|
||||
|
||||
this.updateMonthView()
|
||||
}, 400)
|
||||
},
|
||||
|
||||
/** 根据当前月份筛选并更新视图 */
|
||||
updateMonthView() {
|
||||
const { currentYear, currentMonth, allRecords } = this.data
|
||||
const monthLabel = `${currentYear}年${currentMonth}月`
|
||||
|
||||
// 筛选当月记录
|
||||
const monthPrefix = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
|
||||
const monthRecords = allRecords.filter((r) => r.date.startsWith(monthPrefix))
|
||||
|
||||
// 转换为展示格式(对齐 task-detail ServiceRecord,复用 service-record-card 组件)
|
||||
// income / duration 均传原始数字,由组件统一加 ¥ 和 h
|
||||
const records: ServiceRecord[] = monthRecords.map((r) => {
|
||||
const d = new Date(r.date)
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const dateLabel = `${month}月${day}日`
|
||||
const timeRange = this.generateTimeRange(r.duration)
|
||||
const isRecharge = r.project.includes('充值')
|
||||
return {
|
||||
...r,
|
||||
table: this.getTableNo(r.id),
|
||||
type: this.getTypeLabel(r.project),
|
||||
typeClass: this.getTypeClass(r.project) as 'basic' | 'vip' | 'tip' | 'recharge',
|
||||
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
|
||||
duration: isRecharge ? 0 : parseFloat((r.duration / 60).toFixed(1)),
|
||||
durationRaw: 0,
|
||||
income: r.amount,
|
||||
isEstimate: false,
|
||||
drinks: '',
|
||||
date: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
|
||||
}
|
||||
// 默认当前年月
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth() + 1
|
||||
this.setData({
|
||||
customerId: id,
|
||||
currentYear,
|
||||
currentMonth,
|
||||
maxYearMonth: currentYear * 100 + currentMonth,
|
||||
})
|
||||
this.loadCustomerInfo(id)
|
||||
this.loadMonthRecords(id, currentYear, currentMonth)
|
||||
},
|
||||
|
||||
// 月度统计
|
||||
const totalMinutes = monthRecords.reduce((sum, r) => sum + r.duration, 0)
|
||||
const monthCount = monthRecords.length + '次'
|
||||
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
|
||||
/** 加载客户基本信息(头部展示) */
|
||||
async loadCustomerInfo(id: string) {
|
||||
try {
|
||||
const detail = await fetchCustomerDetail(id)
|
||||
if (detail) {
|
||||
const name = detail.name || '客户'
|
||||
this.setData({
|
||||
customerName: name,
|
||||
customerInitial: name[0] || '?',
|
||||
customerPhone: detail.phone ? detail.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
|
||||
customerPhoneFull: detail.phone || '',
|
||||
// totalServiceCount 由真实 API 返回;mock 类型无此字段,安全取值
|
||||
totalServiceCount: (detail as any).totalServiceCount ?? 0,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[customer-service-records] loadCustomerInfo failed:', err)
|
||||
// 客户信息加载失败不阻塞记录展示
|
||||
}
|
||||
},
|
||||
|
||||
// 边界判断
|
||||
const yearMonth = currentYear * 100 + currentMonth
|
||||
const canPrev = yearMonth > this.data.minYearMonth
|
||||
const canNext = yearMonth < this.data.maxYearMonth
|
||||
|
||||
const isEmpty = records.length === 0 && allRecords.length === 0
|
||||
/** 按月加载服务记录(核心方法) */
|
||||
async loadMonthRecords(customerId: string, year: number, month: number) {
|
||||
const monthLabel = `${year}年${month}月`
|
||||
|
||||
// 清空列表 → 显示 loading
|
||||
this.setData({
|
||||
monthLabel,
|
||||
records,
|
||||
monthCount,
|
||||
monthHours,
|
||||
canPrev,
|
||||
canNext,
|
||||
pageState: isEmpty ? 'empty' : 'normal',
|
||||
records: [],
|
||||
monthLoading: true,
|
||||
pageState: 'loading',
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetchCustomerRecords({ customerId, year, month })
|
||||
const rawRecords: any[] = res.records || []
|
||||
|
||||
// 转换为展示格式(对齐 service-record-card 组件)
|
||||
const records: ServiceRecord[] = rawRecords.map((r: any) => {
|
||||
const d = new Date(r.date)
|
||||
const m = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const dateLabel = `${m}月${day}日`
|
||||
const timeRange = this.generateTimeRange(r.duration || 0)
|
||||
const isRecharge = (r.project || '').includes('充值')
|
||||
return {
|
||||
id: r.id || '',
|
||||
date: r.date || '',
|
||||
project: r.project || '',
|
||||
duration: r.duration || 0,
|
||||
amount: r.amount || 0,
|
||||
coachName: r.coachName || '',
|
||||
table: r.table || this.getTableNo(r.id || ''),
|
||||
type: this.getTypeLabel(r.project || ''),
|
||||
typeClass: this.getTypeClass(r.project || '') as 'basic' | 'vip' | 'tip' | 'recharge',
|
||||
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
|
||||
durationHours: isRecharge ? 0 : parseFloat(((r.duration || 0) / 60).toFixed(1)),
|
||||
durationRaw: 0,
|
||||
income: r.amount || 0,
|
||||
isEstimate: r.isEstimate || false,
|
||||
drinks: r.drinks || '',
|
||||
displayDate: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
|
||||
}
|
||||
})
|
||||
|
||||
// 月度统计
|
||||
const totalMinutes = rawRecords.reduce((sum: number, r: any) => sum + (r.duration || 0), 0)
|
||||
const monthCount = records.length + '次'
|
||||
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
|
||||
|
||||
// 边界判断
|
||||
const yearMonth = year * 100 + month
|
||||
const canPrev = yearMonth > this.data.minYearMonth
|
||||
const canNext = yearMonth < this.data.maxYearMonth
|
||||
|
||||
this.setData({
|
||||
records,
|
||||
monthCount,
|
||||
monthHours,
|
||||
canPrev,
|
||||
canNext,
|
||||
hasMore: res.hasMore || false,
|
||||
monthLoading: false,
|
||||
pageState: 'normal',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[customer-service-records] loadMonthRecords failed:', err)
|
||||
this.setData({
|
||||
monthLoading: false,
|
||||
pageState: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/** 生成模拟时间段 */
|
||||
/** 生成模拟时间段(后端未返回具体时段时的 fallback) */
|
||||
generateTimeRange(durationMin: number): string {
|
||||
const startHour = 14 + Math.floor(Math.random() * 6)
|
||||
const endMin = startHour * 60 + durationMin
|
||||
@@ -187,7 +215,7 @@ Page({
|
||||
return 'basic'
|
||||
},
|
||||
|
||||
/** 模拟台号 */
|
||||
/** 模拟台号(后端未返回台号时的 fallback) */
|
||||
getTableNo(id: string): string {
|
||||
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
|
||||
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
|
||||
@@ -196,44 +224,47 @@ Page({
|
||||
|
||||
/** 切换到上一月 */
|
||||
onPrevMonth() {
|
||||
if (!this.data.canPrev) return
|
||||
let { currentYear, currentMonth } = this.data
|
||||
if (!this.data.canPrev || this.data.monthLoading) return
|
||||
let { currentYear, currentMonth, customerId } = this.data
|
||||
currentMonth--
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12
|
||||
currentYear--
|
||||
}
|
||||
this.setData({ currentYear, currentMonth })
|
||||
this.updateMonthView()
|
||||
this.loadMonthRecords(customerId, currentYear, currentMonth)
|
||||
},
|
||||
|
||||
/** 切换到下一月 */
|
||||
onNextMonth() {
|
||||
if (!this.data.canNext) return
|
||||
let { currentYear, currentMonth } = this.data
|
||||
if (!this.data.canNext || this.data.monthLoading) return
|
||||
let { currentYear, currentMonth, customerId } = this.data
|
||||
currentMonth++
|
||||
if (currentMonth > 12) {
|
||||
currentMonth = 1
|
||||
currentYear++
|
||||
}
|
||||
this.setData({ currentYear, currentMonth })
|
||||
this.updateMonthView()
|
||||
this.loadMonthRecords(customerId, currentYear, currentMonth)
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData(this.data.customerId)
|
||||
const { customerId, currentYear, currentMonth } = this.data
|
||||
this.loadCustomerInfo(customerId)
|
||||
this.loadMonthRecords(customerId, currentYear, currentMonth)
|
||||
setTimeout(() => wx.stopPullDownRefresh(), 600)
|
||||
},
|
||||
|
||||
/** 重试 */
|
||||
onRetry() {
|
||||
this.loadData(this.data.customerId)
|
||||
const { customerId, currentYear, currentMonth } = this.data
|
||||
this.loadMonthRecords(customerId, currentYear, currentMonth)
|
||||
},
|
||||
|
||||
/** 触底加载 */
|
||||
onReachBottom() {
|
||||
// Mock 阶段数据有限,不做分页
|
||||
// 按月请求模式下暂不分页,后续可扩展
|
||||
if (this.data.loadingMore || !this.data.hasMore) return
|
||||
this.setData({ loadingMore: true })
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -82,8 +82,14 @@
|
||||
<!-- 记录列表(service-record-card 组件)-->
|
||||
<view class="records-container">
|
||||
|
||||
<!-- 月份数据加载中 -->
|
||||
<view class="month-loading" wx:if="{{monthLoading}}">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="month-loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 无当月记录 -->
|
||||
<view class="no-month-data" wx:if="{{records.length === 0}}">
|
||||
<view class="no-month-data" wx:elif="{{records.length === 0}}">
|
||||
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
|
||||
<text class="no-month-text">本月暂无服务记录</text>
|
||||
</view>
|
||||
@@ -91,12 +97,12 @@
|
||||
<service-record-card
|
||||
wx:for="{{records}}"
|
||||
wx:key="id"
|
||||
time="{{item.date}}"
|
||||
time="{{item.displayDate}}"
|
||||
course-label="{{item.type}}"
|
||||
type-class="{{item.typeClass}}"
|
||||
type="{{item.recordType}}"
|
||||
table-no="{{item.table}}"
|
||||
hours="{{item.duration}}"
|
||||
hours="{{item.durationHours}}"
|
||||
hours-raw="{{item.durationRaw}}"
|
||||
drinks="{{item.drinks}}"
|
||||
income="{{item.income}}"
|
||||
|
||||
@@ -253,6 +253,20 @@ page {
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* 月份数据加载中 */
|
||||
.month-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.month-loading-text {
|
||||
font-size: 26rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
/* 无当月数据 */
|
||||
.no-month-data {
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { mockUserProfile } from '../../utils/mock-data'
|
||||
import { fetchMe } from '../../services/api'
|
||||
import { getMenuRoute, navigateTo } from '../../utils/router'
|
||||
|
||||
// TODO: 联调时替换为真实 API 获取用户信息
|
||||
Page({
|
||||
data: {
|
||||
userInfo: mockUserProfile,
|
||||
userInfo: null as any,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 同步 custom-tab-bar 选中态
|
||||
const tabBar = this.getTabBar?.()
|
||||
if (tabBar) tabBar.setData({ active: 'my' })
|
||||
// TODO: 联调时在此刷新用户信息
|
||||
this.loadUserInfo()
|
||||
},
|
||||
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
const info = await fetchMe()
|
||||
this.setData({ userInfo: info })
|
||||
} catch {
|
||||
wx.showToast({ title: '加载用户信息失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
onMenuTap(e: WechatMiniprogram.TouchEvent) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockNotes } from '../../utils/mock-data'
|
||||
import { fetchNotes, deleteNote } from '../../services/api'
|
||||
import type { Note } from '../../utils/mock-data'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
|
||||
@@ -7,12 +7,21 @@ interface NoteDisplay extends Note {
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
/** 每页条数 */
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
notes: [] as NoteDisplay[],
|
||||
/** 系统状态栏高度(px),用于自定义导航栏顶部偏移 */
|
||||
statusBarHeight: 20,
|
||||
/** 当前页码 */
|
||||
page: 1,
|
||||
/** 是否正在加载更多 */
|
||||
isLoadingMore: false,
|
||||
/** 是否还有更多数据 */
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -21,24 +30,52 @@ Page({
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData() {
|
||||
this.setData({ pageState: 'loading' })
|
||||
/** 首次加载 / 刷新:重置分页,从第 1 页开始 */
|
||||
async loadData() {
|
||||
this.setData({ pageState: 'loading', page: 1, hasMore: true })
|
||||
|
||||
setTimeout(() => {
|
||||
// TODO: 替换为真实 API 调用 GET /api/xcx/notes
|
||||
try {
|
||||
const notes: NoteDisplay[] = mockNotes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
this.setData({
|
||||
pageState: notes.length > 0 ? 'normal' : 'empty',
|
||||
notes,
|
||||
})
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
}, 400)
|
||||
try {
|
||||
const res = await fetchNotes({ page: 1, pageSize: PAGE_SIZE })
|
||||
const notes: NoteDisplay[] = res.notes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
const hasMore = res.hasMore !== false && notes.length >= PAGE_SIZE
|
||||
this.setData({
|
||||
pageState: notes.length > 0 ? 'normal' : 'empty',
|
||||
notes,
|
||||
hasMore,
|
||||
})
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 触底加载更多 */
|
||||
async onReachBottom() {
|
||||
if (this.data.isLoadingMore || !this.data.hasMore) return
|
||||
|
||||
const nextPage = this.data.page + 1
|
||||
this.setData({ isLoadingMore: true })
|
||||
|
||||
try {
|
||||
const res = await fetchNotes({ page: nextPage, pageSize: PAGE_SIZE })
|
||||
const newNotes: NoteDisplay[] = res.notes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
const hasMore = res.hasMore !== false && newNotes.length >= PAGE_SIZE
|
||||
this.setData({
|
||||
notes: [...this.data.notes, ...newNotes],
|
||||
page: nextPage,
|
||||
hasMore,
|
||||
isLoadingMore: false,
|
||||
})
|
||||
} catch {
|
||||
// 加载更多失败时不改变页面状态,仅停止 loading
|
||||
this.setData({ isLoadingMore: false })
|
||||
wx.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 返回上一页 */
|
||||
@@ -58,19 +95,27 @@ Page({
|
||||
title: '删除备注',
|
||||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||||
confirmColor: '#e34d59',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteNote(noteId)
|
||||
} catch {
|
||||
// 静默:mock 阶段可能失败,不影响 UI
|
||||
}
|
||||
const notes = this.data.notes.filter((n) => n.id !== noteId)
|
||||
this.setData({ notes })
|
||||
this.setData({
|
||||
notes,
|
||||
pageState: notes.length > 0 ? 'normal' : 'empty',
|
||||
})
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/** 下拉刷新 */
|
||||
onPullDownRefresh() {
|
||||
this.loadData()
|
||||
/** 下拉刷新:重置分页重新加载 */
|
||||
async onPullDownRefresh() {
|
||||
await this.loadData()
|
||||
wx.stopPullDownRefresh()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -49,8 +49,12 @@
|
||||
</view>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="list-footer">
|
||||
<text class="footer-text">— 已加载全部记录 —</text>
|
||||
<view class="list-footer" wx:if="{{isLoadingMore}}">
|
||||
<t-loading theme="circular" size="32rpx" />
|
||||
<text class="footer-text">加载中...</text>
|
||||
</view>
|
||||
<view class="list-footer" wx:elif="{{!hasMore}}">
|
||||
<text class="footer-text">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mockPerformanceRecords } from '../../utils/mock-data'
|
||||
import type { PerformanceRecord } from '../../utils/mock-data'
|
||||
import { fetchPerformanceRecords } from '../../services/api'
|
||||
import { nameToAvatarColor } from '../../utils/avatar-color'
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
import { formatHours } from '../../utils/time'
|
||||
@@ -57,7 +57,7 @@ Page({
|
||||
dateGroups: [] as DateGroup[],
|
||||
|
||||
/** 所有记录(用于筛选) */
|
||||
allRecords: [] as PerformanceRecord[],
|
||||
allRecords: [] as any[],
|
||||
|
||||
/** 分页 */
|
||||
page: 1,
|
||||
@@ -79,149 +79,82 @@ Page({
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData(cb?: () => void) {
|
||||
async loadData(cb?: () => void) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// TODO: 替换为真实 API,按月份请求
|
||||
const allRecords = mockPerformanceRecords
|
||||
try {
|
||||
const { currentYear, currentMonth, page, pageSize } = this.data
|
||||
const res = await fetchPerformanceRecords({
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
|
||||
const dateGroups: DateGroup[] = [
|
||||
{
|
||||
date: '2月7日',
|
||||
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
|
||||
totalIncome: 510,
|
||||
records: [
|
||||
{ id: 'r1', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
{ id: 'r2', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '16:00-18:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
|
||||
{ id: 'r3', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月6日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r4', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r5', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '15:30-17:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月5日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 320,
|
||||
records: [
|
||||
{ id: 'r6', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r7', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '14:00-16:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月4日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r8', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '19:00-21:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r9', customerName: '吴女士', avatarChar: '吴', avatarColor: nameToAvatarColor('吴'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月3日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r10', customerName: '郑先生', avatarChar: '郑', avatarColor: nameToAvatarColor('郑'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
|
||||
{ id: 'r11', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月2日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r12', customerName: '林先生', avatarChar: '林', avatarColor: nameToAvatarColor('林'), timeRange: '19:00-21:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
|
||||
{ id: 'r13', customerName: '何女士', avatarChar: '何', avatarColor: nameToAvatarColor('何'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP3号房', income: 190 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2月1日',
|
||||
totalHours: 6.0, totalHoursLabel: formatHours(6.0), totalIncomeLabel: formatMoney(510),
|
||||
totalIncome: 510,
|
||||
records: [
|
||||
{ id: 'r14', customerName: '王先生', avatarChar: '王', avatarColor: nameToAvatarColor('王'), timeRange: '20:30-22:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
{ id: 'r15', customerName: '马先生', avatarChar: '马', avatarColor: nameToAvatarColor('马'), timeRange: '16:00-18:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '8号台', income: 160 },
|
||||
{ id: 'r16', customerName: '罗女士', avatarChar: '罗', avatarColor: nameToAvatarColor('罗'), timeRange: '12:30-14:30', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r17', customerName: '梁先生', avatarChar: '梁', avatarColor: nameToAvatarColor('梁'), timeRange: '10:00-12:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r18', customerName: '宋女士', avatarChar: '宋', avatarColor: nameToAvatarColor('宋'), timeRange: '8:30-10:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
{ id: 'r19', customerName: '谢先生', avatarChar: '谢', avatarColor: nameToAvatarColor('谢'), timeRange: '7:00-8:00', hours: 1.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月31日',
|
||||
totalHours: 5.5, totalHoursLabel: formatHours(5.5), totalIncome: 470, totalIncomeLabel: formatMoney(470),
|
||||
records: [
|
||||
{ id: 'r20', customerName: '韩女士', avatarChar: '韩', avatarColor: nameToAvatarColor('韩'), timeRange: '21:00-23:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '4号台', income: 160 },
|
||||
{ id: 'r21', customerName: '唐先生', avatarChar: '唐', avatarColor: nameToAvatarColor('唐'), timeRange: '18:30-20:30', hours: 2.0, hoursRaw: 2.5, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r22', customerName: '冯女士', avatarChar: '冯', avatarColor: nameToAvatarColor('冯'), timeRange: '14:00-16:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '6号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月30日',
|
||||
totalHours: 3.5, totalHoursLabel: formatHours(3.5), totalIncomeLabel: formatMoney(280),
|
||||
totalIncome: 280,
|
||||
records: [
|
||||
{ id: 'r23', customerName: '张先生', avatarChar: '张', avatarColor: nameToAvatarColor('张'), timeRange: '19:30-21:30', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: 160 },
|
||||
{ id: 'r24', customerName: '刘先生', avatarChar: '刘', avatarColor: nameToAvatarColor('刘'), timeRange: '14:30-16:00', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月29日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 320,
|
||||
records: [
|
||||
{ id: 'r25', customerName: '陈女士', avatarChar: '陈', avatarColor: nameToAvatarColor('陈'), timeRange: '20:00-22:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: 160 },
|
||||
{ id: 'r26', customerName: '李女士', avatarChar: '李', avatarColor: nameToAvatarColor('李'), timeRange: '13:00-15:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: 190 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月28日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r27', customerName: '赵先生', avatarChar: '赵', avatarColor: nameToAvatarColor('赵'), timeRange: '19:00-21:00', hours: 2.0, hoursRaw: 2.5, courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: 160 },
|
||||
{ id: 'r28', customerName: '董先生', avatarChar: '董', avatarColor: nameToAvatarColor('董'), timeRange: '15:00-17:00', hours: 2.0, courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: 160 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '1月27日',
|
||||
totalHours: 4.0, totalHoursLabel: formatHours(4.0), totalIncomeLabel: formatMoney(320),
|
||||
totalIncome: 350,
|
||||
records: [
|
||||
{ id: 'r29', customerName: '孙先生', avatarChar: '孙', avatarColor: nameToAvatarColor('孙'), timeRange: '20:00-22:00', hours: 2.0, courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: 190 },
|
||||
{ id: 'r30', customerName: '黄女士', avatarChar: '黄', avatarColor: nameToAvatarColor('黄'), timeRange: '14:30-16:30', hours: 1.5, courseType: '打赏课', courseTypeClass: 'tag-tip', location: '打赏', income: 120 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
this.setData({
|
||||
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
|
||||
allRecords,
|
||||
dateGroups,
|
||||
totalCount: 32,
|
||||
totalHours: 59.0,
|
||||
totalIncome: 4720,
|
||||
totalCountLabel: formatCount(32, '笔'),
|
||||
totalHoursLabel: formatHours(59.0),
|
||||
totalHoursRawLabel: formatHours(63.5),
|
||||
totalIncomeLabel: formatMoney(4720),
|
||||
hasMore: false,
|
||||
const records = res.records || []
|
||||
// 按日期分组(后端返回的记录已按日期排序)
|
||||
const groupMap = new Map<string, DateGroup>()
|
||||
for (const r of records) {
|
||||
const dateKey = r.date || '未知日期'
|
||||
if (!groupMap.has(dateKey)) {
|
||||
groupMap.set(dateKey, {
|
||||
date: dateKey,
|
||||
totalHours: 0,
|
||||
totalIncome: 0,
|
||||
totalHoursLabel: '',
|
||||
totalIncomeLabel: '',
|
||||
records: [],
|
||||
})
|
||||
}
|
||||
const group = groupMap.get(dateKey)!
|
||||
const hours = (r as any).hours ?? 0
|
||||
const income = r.amount ?? 0
|
||||
group.totalHours += hours
|
||||
group.totalIncome += income
|
||||
group.records.push({
|
||||
id: r.id,
|
||||
customerName: r.customerName,
|
||||
avatarChar: r.customerName?.charAt(0) || '?',
|
||||
avatarColor: nameToAvatarColor(r.customerName || ''),
|
||||
timeRange: (r as any).timeRange || '',
|
||||
hours,
|
||||
hoursRaw: (r as any).hoursRaw,
|
||||
courseType: r.type || '',
|
||||
courseTypeClass: `tag-${(r.category || 'basic').toLowerCase()}`,
|
||||
location: (r as any).location || '',
|
||||
income,
|
||||
})
|
||||
} catch (_err) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
|
||||
cb?.()
|
||||
}, 500)
|
||||
const dateGroups = Array.from(groupMap.values()).map(g => ({
|
||||
...g,
|
||||
totalHoursLabel: formatHours(g.totalHours),
|
||||
totalIncomeLabel: formatMoney(g.totalIncome),
|
||||
}))
|
||||
|
||||
// 汇总统计
|
||||
const totalCount = records.length
|
||||
const totalHours = dateGroups.reduce((s, g) => s + g.totalHours, 0)
|
||||
const totalIncome = dateGroups.reduce((s, g) => s + g.totalIncome, 0)
|
||||
|
||||
this.setData({
|
||||
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
|
||||
allRecords: records,
|
||||
dateGroups,
|
||||
totalCount,
|
||||
totalHours,
|
||||
totalIncome,
|
||||
totalCountLabel: formatCount(totalCount, '笔'),
|
||||
totalHoursLabel: formatHours(totalHours),
|
||||
totalHoursRawLabel: '',
|
||||
totalIncomeLabel: formatMoney(totalIncome),
|
||||
hasMore: res.hasMore ?? false,
|
||||
})
|
||||
} catch (_err) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
|
||||
cb?.()
|
||||
},
|
||||
|
||||
/** 重试加载 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mockTaskDetails } from '../../utils/mock-data'
|
||||
import type { TaskDetail, Note } from '../../utils/mock-data'
|
||||
import { fetchTaskDetail } from '../../services/api'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
import { formatMoney } from '../../utils/money'
|
||||
@@ -130,55 +130,45 @@ Page({
|
||||
this.loadData(id)
|
||||
},
|
||||
|
||||
loadData(id: string) {
|
||||
async loadData(id: string) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const detail = mockTaskDetails.find((t) => t.id === id) || mockTaskDetails[0]
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
|
||||
// 根据任务类型设置 Banner 背景
|
||||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
if (detail.taskType === 'high_priority') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
} else if (detail.taskType === 'priority_recall') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
|
||||
} else if (detail.taskType === 'relationship') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||||
} else if (detail.taskType === 'callback') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
|
||||
}
|
||||
|
||||
// 添加更多 mock 备注
|
||||
const mockNotes: Note[] = [
|
||||
{ id: 'note-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-10T16:30', score: 10 },
|
||||
{ id: 'note-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-03-05T14:20', score: 7.5 },
|
||||
{ id: 'note-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-28T18:45', score: 6 },
|
||||
{ id: 'note-4', content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-20T21:15', score: 9.5 },
|
||||
{ id: 'note-5', content: '完成高优先召回任务。客户反馈最近工作太忙,这周末会来店里。', tagType: 'customer', tagLabel: '客户:王先生', createdAt: '2026-02-15T10:30', score: 8 },
|
||||
]
|
||||
|
||||
// 附加 timeLabel 字段
|
||||
const notesWithLabel = mockNotes.map((n) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
const sorted = sortByTimestamp(notesWithLabel) as (Note & { timeLabel: string })[]
|
||||
this.updateRelationshipDisplay(detail.heartScore)
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
sortedNotes: sorted,
|
||||
debugHeartScore: detail.heartScore,
|
||||
bannerBgSvg,
|
||||
})
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
try {
|
||||
const detail = await fetchTaskDetail(id)
|
||||
if (!detail) {
|
||||
this.setData({ pageState: 'empty' })
|
||||
return
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 根据任务类型设置 Banner 背景
|
||||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
if (detail.taskType === 'high_priority') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||||
} else if (detail.taskType === 'priority_recall') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
|
||||
} else if (detail.taskType === 'relationship') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||||
} else if (detail.taskType === 'callback') {
|
||||
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
|
||||
}
|
||||
|
||||
// 附加 timeLabel 字段
|
||||
const notes = detail.notes || []
|
||||
const notesWithLabel = notes.map((n: Note) => ({
|
||||
...n,
|
||||
timeLabel: formatRelativeTime(n.createdAt),
|
||||
}))
|
||||
const sorted = sortByTimestamp(notesWithLabel) as (Note & { timeLabel: string })[]
|
||||
this.updateRelationshipDisplay(detail.heartScore)
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail,
|
||||
sortedNotes: sorted,
|
||||
debugHeartScore: detail.heartScore,
|
||||
bannerBgSvg,
|
||||
})
|
||||
} catch (_e) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 更新关系等级显示 */
|
||||
@@ -340,16 +330,19 @@ Page({
|
||||
|
||||
/** 问问助手 */
|
||||
onAskAssistant() {
|
||||
const customerId = this.data.detail?.id || ''
|
||||
// CHANGE 2026-03-20 | T12.2: 从 task-detail 进入 chat 应传 taskId(非 customerId),
|
||||
// 使 chat 页面使用 contextType=task 入口,同一 taskId 始终复用同一对话
|
||||
const taskId = this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/chat/chat?customerId=${customerId}`,
|
||||
url: `/pages/chat/chat?taskId=${taskId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 查看全部服务记录 */
|
||||
onViewAllRecords() {
|
||||
const customerId = this.data.detail?.id || ''
|
||||
// CHANGE 2026-03-20 | T12.2: 使用后端返回的 customerId 而非 task id
|
||||
const customerId = (this.data.detail as any)?.customerId || this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
| 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 |
|
||||
| 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap(标签恢复 CSS 渐变实现) |
|
||||
*/
|
||||
import { mockTasks, mockPerformance } from '../../utils/mock-data'
|
||||
import type { Task } from '../../utils/mock-data'
|
||||
import { fetchTasks } from '../../services/api'
|
||||
import { formatMoney } from '../../utils/money'
|
||||
import { formatDeadline } from '../../utils/time'
|
||||
|
||||
@@ -356,27 +356,12 @@ Page({
|
||||
wx.showToast({ title: '没有更多了', icon: 'none' })
|
||||
},
|
||||
|
||||
loadData(cb?: () => void) {
|
||||
async loadData(cb?: () => void) {
|
||||
this.setData({ pageState: 'loading', stampAnimated: false })
|
||||
|
||||
setTimeout(() => {
|
||||
/* CHANGE 2026-03-13 | mock 数据贴近 H5 原型:7 条任务(2 置顶 + 3 一般 + 2 已放弃) */
|
||||
const allTasks: Task[] = [
|
||||
...mockTasks,
|
||||
{
|
||||
id: 'task-007',
|
||||
customerName: '孙丽',
|
||||
customerAvatar: '/assets/images/avatar-default.png',
|
||||
taskType: 'callback',
|
||||
taskTypeLabel: '客户回访',
|
||||
deadline: '2026-03-06',
|
||||
heartScore: 3.5,
|
||||
hobbies: [],
|
||||
isPinned: false,
|
||||
hasNote: false,
|
||||
status: 'abandoned',
|
||||
},
|
||||
]
|
||||
try {
|
||||
const res = await fetchTasks()
|
||||
const allTasks: Task[] = res.tasks || []
|
||||
|
||||
const enriched = allTasks.map(enrichTask)
|
||||
|
||||
@@ -386,8 +371,8 @@ Page({
|
||||
const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length
|
||||
|
||||
const perfData = buildPerfData()
|
||||
const perf = mockPerformance
|
||||
const bannerTitle = `${perf.currentTier}`
|
||||
const perf = res.performance
|
||||
const bannerTitle = perf ? `${perf.currentTier}` : ''
|
||||
const bannerMetrics: Array<{ label: string; value: string }> = []
|
||||
|
||||
this.setData({
|
||||
@@ -399,7 +384,7 @@ Page({
|
||||
bannerTitle,
|
||||
bannerMetrics,
|
||||
perfData,
|
||||
hasMore: true,
|
||||
hasMore: res.hasMore ?? false,
|
||||
})
|
||||
|
||||
if (perfData.tierCompleted) {
|
||||
@@ -407,9 +392,11 @@ Page({
|
||||
this.setData({ stampAnimated: true })
|
||||
}, 300)
|
||||
}
|
||||
} catch {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
|
||||
cb?.()
|
||||
}, 600)
|
||||
cb?.()
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
@@ -496,8 +483,9 @@ Page({
|
||||
onCtxAI() {
|
||||
const target = this.data.contextMenuTarget
|
||||
this.setData({ contextMenuVisible: false })
|
||||
// CHANGE 2026-03-20 | T12.2: 修正路由从 ai-chat 到 chat,传 taskId 使用 task 上下文入口
|
||||
wx.navigateTo({
|
||||
url: `/pages/ai-chat/ai-chat?taskId=${target.id}&customerName=${target.customerName}`,
|
||||
url: `/pages/chat/chat?taskId=${target.id}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
// AI_CHANGELOG
|
||||
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | fetchSkillTypes() fallback 数据
|
||||
// value 从 all/chinese/snooker 改为 ALL/BILLIARD/SNOOKER;API 响应映射从
|
||||
// data.skills 改为直接 map data 数组(后端返回 [{key,label,emoji,cls}])。
|
||||
// - 2026-03-20 | Prompt: RNS1.4 T12.1 移除 mock 数据残留 | 移除所有 mock 导入、
|
||||
// USE_REAL_API 开关、delay() 辅助函数和 mock fallback 分支,全部直连真实 API。
|
||||
|
||||
/**
|
||||
* Service 层 — 统一数据请求入口
|
||||
*
|
||||
* 页面只调用 service 函数,不直接引用 mock 数据或 wx.request。
|
||||
* 联调时只需修改此文件内部实现(mock → request),页面代码不动。
|
||||
*
|
||||
* 当前阶段:全部走 mock
|
||||
* 联调阶段:逐个替换为 request() 调用
|
||||
* 所有函数均直连真实后端 API。
|
||||
*/
|
||||
|
||||
import { request } from '../utils/request'
|
||||
|
||||
// ============================================
|
||||
// Mock 数据导入(联调时逐步删除)
|
||||
// ============================================
|
||||
import {
|
||||
mockTasks,
|
||||
mockTaskDetails,
|
||||
mockNotes,
|
||||
mockPerformance,
|
||||
mockPerformanceRecords,
|
||||
mockBoardFinance,
|
||||
mockCustomers,
|
||||
mockCoaches,
|
||||
mockCustomerDetail,
|
||||
mockChatMessages,
|
||||
mockChatHistory,
|
||||
} from '../utils/mock-data'
|
||||
import type {
|
||||
Task,
|
||||
TaskDetail,
|
||||
@@ -35,28 +22,11 @@ import type {
|
||||
BoardFinanceData,
|
||||
CustomerCard,
|
||||
CoachCard,
|
||||
CustomerDetail as MockCustomerDetail,
|
||||
ChatMessage,
|
||||
ChatHistoryItem,
|
||||
CustomerDetail,
|
||||
} from '../utils/mock-data'
|
||||
|
||||
// ============================================
|
||||
// 内部工具
|
||||
// ============================================
|
||||
|
||||
/** 模拟网络延迟(联调时删除) */
|
||||
function delay(ms = 400): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否使用真实 API(联调开关)
|
||||
* 联调时改为 true,或按模块逐个开启
|
||||
*/
|
||||
const USE_REAL_API = false
|
||||
|
||||
// ============================================
|
||||
// 认证模块(已对接真实 API)
|
||||
// 认证模块
|
||||
// ============================================
|
||||
|
||||
/** 查询当前用户信息 */
|
||||
@@ -81,94 +51,58 @@ export async function fetchTasks(params: FetchTasksParams = {}): Promise<{
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}> {
|
||||
if (USE_REAL_API) {
|
||||
const data = await request({
|
||||
url: '/api/xcx/tasks',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
// TODO: 联调时映射 snake_case → camelCase
|
||||
return data
|
||||
}
|
||||
|
||||
await delay()
|
||||
const filtered = params.status
|
||||
? mockTasks.filter(t => t.status === params.status)
|
||||
: mockTasks
|
||||
return {
|
||||
tasks: filtered,
|
||||
performance: mockPerformance,
|
||||
total: filtered.length,
|
||||
hasMore: false,
|
||||
}
|
||||
return request({
|
||||
url: '/api/xcx/tasks',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取任务详情 */
|
||||
export async function fetchTaskDetail(taskId: string): Promise<TaskDetail | null> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
await delay()
|
||||
return mockTaskDetails.find(t => t.id === taskId) || mockTaskDetails[0] || null
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 放弃任务 */
|
||||
export async function abandonTask(taskId: string, reason: string): Promise<void> {
|
||||
if (USE_REAL_API) {
|
||||
await request({
|
||||
url: `/api/xcx/tasks/${taskId}/abandon`,
|
||||
method: 'POST',
|
||||
data: { reason },
|
||||
needAuth: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
await delay(300)
|
||||
await request({
|
||||
url: `/api/xcx/tasks/${taskId}/abandon`,
|
||||
method: 'POST',
|
||||
data: { reason },
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消放弃(恢复任务) */
|
||||
export async function restoreTask(taskId: string): Promise<void> {
|
||||
if (USE_REAL_API) {
|
||||
await request({
|
||||
url: `/api/xcx/tasks/${taskId}/restore`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
await delay(300)
|
||||
await request({
|
||||
url: `/api/xcx/tasks/${taskId}/restore`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 置顶任务 */
|
||||
export async function pinTask(taskId: string): Promise<{ isPinned: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}/pin`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay(300)
|
||||
return { isPinned: true }
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}/pin`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消置顶任务 */
|
||||
export async function unpinTask(taskId: string): Promise<{ isPinned: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}/unpin`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay(300)
|
||||
return { isPinned: false }
|
||||
return request({
|
||||
url: `/api/xcx/tasks/${taskId}/unpin`,
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -181,11 +115,7 @@ export async function fetchNotes(params: { page?: number; pageSize?: number } =
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}> {
|
||||
if (USE_REAL_API) {
|
||||
return request({ url: '/api/xcx/notes', method: 'GET', data: params, needAuth: true })
|
||||
}
|
||||
await delay()
|
||||
return { notes: mockNotes, total: mockNotes.length, hasMore: false }
|
||||
return request({ url: '/api/xcx/notes', method: 'GET', data: params, needAuth: true })
|
||||
}
|
||||
|
||||
/** 新增备注 */
|
||||
@@ -196,20 +126,12 @@ export async function createNote(data: {
|
||||
ratingServiceWillingness?: number
|
||||
ratingRevisitLikelihood?: number
|
||||
}): Promise<{ id: string; createdAt: string }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({ url: '/api/xcx/notes', method: 'POST', data, needAuth: true })
|
||||
}
|
||||
await delay(300)
|
||||
return { id: `note-${Date.now()}`, createdAt: new Date().toISOString() }
|
||||
return request({ url: '/api/xcx/notes', method: 'POST', data, needAuth: true })
|
||||
}
|
||||
|
||||
/** 删除备注 */
|
||||
export async function deleteNote(noteId: string): Promise<void> {
|
||||
if (USE_REAL_API) {
|
||||
await request({ url: `/api/xcx/notes/${noteId}`, method: 'DELETE', needAuth: true })
|
||||
return
|
||||
}
|
||||
await delay(200)
|
||||
await request({ url: `/api/xcx/notes/${noteId}`, method: 'DELETE', needAuth: true })
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -221,11 +143,7 @@ export async function fetchPerformanceOverview(params: {
|
||||
year: number
|
||||
month: number
|
||||
}): Promise<PerformanceData> {
|
||||
if (USE_REAL_API) {
|
||||
return request({ url: '/api/xcx/performance', method: 'GET', data: params, needAuth: true })
|
||||
}
|
||||
await delay()
|
||||
return mockPerformance
|
||||
return request({ url: '/api/xcx/performance', method: 'GET', data: params, needAuth: true })
|
||||
}
|
||||
|
||||
/** 绩效明细(按月) */
|
||||
@@ -235,16 +153,12 @@ export async function fetchPerformanceRecords(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<{ records: PerformanceRecord[]; hasMore: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: '/api/xcx/performance/records',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return { records: mockPerformanceRecords, hasMore: false }
|
||||
return request({
|
||||
url: '/api/xcx/performance/records',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -252,16 +166,12 @@ export async function fetchPerformanceRecords(params: {
|
||||
// ============================================
|
||||
|
||||
/** 客户详情 */
|
||||
export async function fetchCustomerDetail(customerId: string): Promise<MockCustomerDetail | null> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/customers/${customerId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return mockCustomerDetail || null
|
||||
export async function fetchCustomerDetail(customerId: string): Promise<CustomerDetail | null> {
|
||||
return request({
|
||||
url: `/api/xcx/customers/${customerId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 客户服务记录 */
|
||||
@@ -271,18 +181,13 @@ export async function fetchCustomerRecords(params: {
|
||||
month?: number
|
||||
table?: string
|
||||
}): Promise<{ records: any[]; hasMore: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
const { customerId, ...rest } = params
|
||||
return request({
|
||||
url: `/api/xcx/customers/${customerId}/records`,
|
||||
method: 'GET',
|
||||
data: rest,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
// 联调前返回空,页面内联 mock 数据仍生效
|
||||
return { records: [], hasMore: false }
|
||||
const { customerId, ...rest } = params
|
||||
return request({
|
||||
url: `/api/xcx/customers/${customerId}/records`,
|
||||
method: 'GET',
|
||||
data: rest,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -295,17 +200,13 @@ export async function fetchBoardCoaches(params: {
|
||||
sort?: string
|
||||
time?: string
|
||||
} = {}): Promise<CoachCard[]> {
|
||||
if (USE_REAL_API) {
|
||||
const data = await request({
|
||||
url: '/api/xcx/board/coaches',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
return data.items
|
||||
}
|
||||
await delay()
|
||||
return mockCoaches
|
||||
const data = await request({
|
||||
url: '/api/xcx/board/coaches',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
return data.items
|
||||
}
|
||||
|
||||
// CHANGE 2026-03-19 | RNS1.3 T13: 增加 page/pageSize 参数支持分页
|
||||
@@ -316,17 +217,13 @@ export async function fetchBoardCustomers(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<CustomerCard[]> {
|
||||
if (USE_REAL_API) {
|
||||
const data = await request({
|
||||
url: '/api/xcx/board/customers',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
return data.items
|
||||
}
|
||||
await delay()
|
||||
return mockCustomers
|
||||
const data = await request({
|
||||
url: '/api/xcx/board/customers',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
return data.items
|
||||
}
|
||||
|
||||
// CHANGE 2026-03-19 | RNS1.3 T14: 扩展参数为 time/area/compare
|
||||
@@ -336,16 +233,12 @@ export async function fetchBoardFinance(params: {
|
||||
area?: string
|
||||
compare?: number
|
||||
} = {}): Promise<BoardFinanceData> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: '/api/xcx/board/finance',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return mockBoardFinance
|
||||
return request({
|
||||
url: '/api/xcx/board/finance',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -354,15 +247,11 @@ export async function fetchBoardFinance(params: {
|
||||
|
||||
/** 助教详情 */
|
||||
export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/coaches/${coachId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return mockCoaches.find(c => c.id === coachId) || mockCoaches[0] || null
|
||||
return request({
|
||||
url: `/api/xcx/coaches/${coachId}`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -373,80 +262,73 @@ export async function fetchCoachDetail(coachId: string): Promise<CoachCard | nul
|
||||
export async function fetchChatHistory(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<{ items: ChatHistoryItem[]; total: number; hasMore: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: '/api/xcx/chat/history',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return { items: mockChatHistory, total: mockChatHistory.length, hasMore: false }
|
||||
} = {}): Promise<{ items: any[]; total: number; page: number; pageSize: number }> {
|
||||
return request({
|
||||
url: '/api/xcx/chat/history',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 对话消息列表 */
|
||||
/** 对话消息列表(通过 chatId) */
|
||||
export async function fetchChatMessages(chatId: string, params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}): Promise<{ messages: ChatMessage[]; total: number; hasMore: boolean }> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/chat/${chatId}/messages`,
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay()
|
||||
return { messages: mockChatMessages, total: mockChatMessages.length, hasMore: false }
|
||||
} = {}): Promise<{ chatId: number; items: any[]; total: number; page: number; pageSize: number }> {
|
||||
return request({
|
||||
url: `/api/xcx/chat/${chatId}/messages`,
|
||||
method: 'GET',
|
||||
data: params,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 对话消息列表(通过上下文类型和 ID,自动查找/创建对话) */
|
||||
export async function fetchChatMessagesByContext(
|
||||
contextType: string,
|
||||
contextId: string,
|
||||
params: { page?: number; pageSize?: number } = {},
|
||||
): Promise<{ chatId: number; items: any[]; total: number; page: number; pageSize: number }> {
|
||||
return request({
|
||||
url: '/api/xcx/chat/messages',
|
||||
method: 'GET',
|
||||
data: { contextType, contextId, ...params },
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 发送消息 */
|
||||
export async function sendChatMessage(chatId: string, content: string): Promise<{
|
||||
userMessage: { id: string; content: string; createdAt: string }
|
||||
aiReply: { id: string; content: string; createdAt: string }
|
||||
userMessage: { id: number; content: string; createdAt: string }
|
||||
aiReply: { id: number; content: string; createdAt: string }
|
||||
}> {
|
||||
if (USE_REAL_API) {
|
||||
return request({
|
||||
url: `/api/xcx/chat/${chatId}/messages`,
|
||||
method: 'POST',
|
||||
data: { content },
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
await delay(800)
|
||||
return {
|
||||
userMessage: { id: `msg-${Date.now()}`, content, createdAt: new Date().toISOString() },
|
||||
aiReply: {
|
||||
id: `msg-${Date.now() + 1}`,
|
||||
content: '这是 AI 助手的模拟回复,联调后将替换为真实响应。',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
return request({
|
||||
url: `/api/xcx/chat/${chatId}/messages`,
|
||||
method: 'POST',
|
||||
data: { content },
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 配置模块
|
||||
// ============================================
|
||||
|
||||
/** 技能类型列表(REQ-1) */
|
||||
/** 项目类型筛选器列表(CONFIG-1) */
|
||||
// CHANGE 2026-03-20 | R3 修复:value 改为数据库 category_code,fallback 与后端一致
|
||||
export async function fetchSkillTypes(): Promise<Array<{ value: string; text: string; icon?: string }>> {
|
||||
if (USE_REAL_API) {
|
||||
const data = await request({
|
||||
url: '/api/xcx/config/skill-types',
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
const data = await request({
|
||||
url: '/api/xcx/config/skill-types',
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
// 后端返回 [{key, label, emoji, cls}, ...],映射为前端 {value, text, icon}
|
||||
return (data as Array<{key: string; label: string; emoji: string; cls: string}>).map(
|
||||
(item: {key: string; label: string; emoji: string}) => ({
|
||||
value: item.key,
|
||||
text: item.label,
|
||||
icon: item.emoji || undefined,
|
||||
})
|
||||
return data.skills
|
||||
}
|
||||
// 联调前返回硬编码值,与 board-coach 页面 SKILL_OPTIONS 一致
|
||||
return [
|
||||
{ value: '', text: '全部' },
|
||||
{ value: 'chinese', text: '中🎱' },
|
||||
{ value: 'snooker', text: '🎯斯诺克' },
|
||||
{ value: 'group', text: '小组课' },
|
||||
{ value: 'tip', text: '打赏课' },
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user