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

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

View File

@@ -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-36 大板块 + 环比开关) |
| `/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)

View File

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

View File

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

View File

@@ -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.2AI 图标配色系统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()
},
})

View File

@@ -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 {
// 微信基础库支持 TextDecoder2.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
},
/** 滚动到底部 */

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 */

View File

@@ -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() {

View File

@@ -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(() => {

View File

@@ -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}}"

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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()
},
})

View File

@@ -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 悬浮按钮 -->

View File

@@ -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?.()
},
/** 重试加载 */

View File

@@ -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' }),

View File

@@ -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' }),
})
},

View File

@@ -1,31 +1,18 @@
// AI_CHANGELOG
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | fetchSkillTypes() fallback 数据
// value 从 all/chinese/snooker 改为 ALL/BILLIARD/SNOOKERAPI 响应映射从
// 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_codefallback 与后端一致
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: '打赏课' },
]
)
}