包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
/* AI_CHANGELOG
|
||
| 日期 | Prompt | 变更 |
|
||
|------|--------|------|
|
||
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
|
||
*/
|
||
import { checkPageAccess } from '../../utils/auth-guard'
|
||
import type { TaskDetail, Note } from '../../utils/mock-data'
|
||
import { fetchTaskDetail, fetchTaskByMember, createNote, deleteNote } from '../../services/api'
|
||
import { sortByTimestamp } from '../../utils/sort'
|
||
import { formatRelativeTime } from '../../utils/time'
|
||
import { formatStorageLevel } from '../../utils/storage-level'
|
||
|
||
/** 维客线索项 */
|
||
interface RetentionClue {
|
||
tag: string
|
||
tagColor: 'primary' | 'success' | 'purple' | 'error'
|
||
emoji: string
|
||
text: string
|
||
source: string
|
||
desc?: string
|
||
expanded?: boolean
|
||
}
|
||
|
||
/** 服务记录项 */
|
||
interface ServiceRecord {
|
||
table: string
|
||
type: string
|
||
typeClass: 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
|
||
/** 卡片类型:course=普通课,recharge=充值提成 */
|
||
recordType?: 'course' | 'recharge'
|
||
duration: number // 折算后课时(小时,number)
|
||
durationRaw?: number // 折算前课时(小时,number,可选)
|
||
income: number // 收入(元,整数)
|
||
/** 是否预估金额 */
|
||
isEstimate?: boolean
|
||
drinks: string
|
||
date: string
|
||
}
|
||
|
||
/** 服务汇总 */
|
||
interface ServiceSummary {
|
||
totalHours: number
|
||
totalIncome: number
|
||
avgIncome: number
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
|
||
detail: null as TaskDetail | null,
|
||
sortedNotes: [] as Note[],
|
||
noteModalVisible: false,
|
||
|
||
// --- 维客线索 ---
|
||
retentionClues: [
|
||
{ tag: '', tagColor: 'primary', emoji: '', text: '', source: '', expanded: false },
|
||
{ tag: '', tagColor: 'success', emoji: '', text: '', source: '', desc: '', expanded: false },
|
||
{ tag: '', tagColor: 'error', emoji: '', text: '', source: '', desc: '', expanded: false },
|
||
] as RetentionClue[],
|
||
|
||
// --- 话术参考 ---
|
||
talkingPoints: [
|
||
'',
|
||
'',
|
||
'',
|
||
] as string[],
|
||
copiedIndex: -1,
|
||
|
||
// --- 近期服务记录 ---
|
||
serviceSummary: { totalHours: 0, totalIncome: 0, avgIncome: 0 } as ServiceSummary,
|
||
serviceRecords: [
|
||
{ table: '', type: '', typeClass: 'basic', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
|
||
{ table: '', type: '', typeClass: 'vip', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
|
||
{ table: '', type: '', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
|
||
] as ServiceRecord[],
|
||
|
||
// --- 放弃弹窗 ---
|
||
abandonModalVisible: false,
|
||
|
||
// --- 手机号显示 ---
|
||
phoneVisible: false,
|
||
|
||
// --- 储值等级 ---
|
||
storageLevel: '',
|
||
|
||
// --- 关系等级相关 ---
|
||
relationLevel: 'poor' as 'poor' | 'normal' | 'good' | 'excellent',
|
||
relationLevelText: '',
|
||
relationColor: '',
|
||
|
||
// --- Banner 背景(根据任务类型动态切换)---
|
||
bannerBgSvg: '/assets/images/banner-bg-red-aurora.svg',
|
||
|
||
// --- 调试面板 ---
|
||
showDebugPanel: false,
|
||
debugTaskType: 'high_priority_recall',
|
||
debugHeartScore: 8.5,
|
||
debugShowExpandBtn: true, // 调试:备注弹窗是否显示展开/收起按钮
|
||
|
||
// --- AI 配色 ---
|
||
aiColor: 'indigo' as 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple',
|
||
},
|
||
|
||
onLoad(options: { id?: string; memberId?: string }) {
|
||
const id = options?.id || ''
|
||
const memberId = options?.memberId || ''
|
||
// 随机 AI 配色
|
||
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
|
||
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
|
||
this.setData({ aiColor })
|
||
if (id) {
|
||
this.loadData(id)
|
||
} else if (memberId) {
|
||
// CHANGE 2026-03-25 | 绩效页跳转:无 task_id 时按 member_id 查询最高优先级任务
|
||
this.loadByMember(memberId)
|
||
} else {
|
||
this.setData({ pageState: 'empty' })
|
||
}
|
||
},
|
||
|
||
onShow() {
|
||
// 权限守卫:检查登录状态、账号禁用、角色权限
|
||
checkPageAccess('pages/task-detail/task-detail')
|
||
},
|
||
|
||
async loadData(id: string) {
|
||
this.setData({ pageState: 'loading' })
|
||
wx.showLoading({ title: '加载中...', mask: true })
|
||
try {
|
||
const detail = await fetchTaskDetail(id)
|
||
if (!detail) {
|
||
this.setData({ pageState: 'empty' })
|
||
return
|
||
}
|
||
|
||
// 根据任务类型设置 Banner 背景
|
||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||
if (detail.taskType === 'high_priority_recall') {
|
||
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_building') {
|
||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||
} else if (detail.taskType === 'follow_up_visit') {
|
||
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 })[]
|
||
// G4: 根据 balance 计算储值等级
|
||
const storageLevel = formatStorageLevel(detail.balance)
|
||
|
||
this.updateRelationshipDisplay(detail.heartScore)
|
||
this.setData({
|
||
pageState: 'normal',
|
||
detail,
|
||
sortedNotes: sorted,
|
||
debugHeartScore: detail.heartScore,
|
||
bannerBgSvg,
|
||
storageLevel,
|
||
// 从 detail 中提取嵌套模块数据到 data 顶层,供 WXML 直接绑定
|
||
retentionClues: (detail.retentionClues || []) as RetentionClue[],
|
||
talkingPoints: detail.talkingPoints || [],
|
||
serviceSummary: detail.serviceSummary || { totalHours: 0, totalIncome: 0, avgIncome: 0 },
|
||
// CHANGE 2026-03-27 | 清洗 serviceRecords:null 值转为组件期望的默认值
|
||
// 小程序组件 Number/String property 收到 null 时行为不确定,需显式转换
|
||
serviceRecords: (detail.serviceRecords || []).map((r: any) => ({
|
||
...r,
|
||
durationRaw: r.durationRaw ?? 0,
|
||
drinks: r.drinks ?? '',
|
||
})),
|
||
})
|
||
} catch (_e) {
|
||
this.setData({ pageState: 'error' })
|
||
} finally {
|
||
wx.hideLoading()
|
||
}
|
||
},
|
||
|
||
/** 按 member_id 查询最高优先级任务,再加载详情 */
|
||
async loadByMember(memberId: string) {
|
||
// CHANGE 2026-03-25 | 修复:fetchTaskByMember 已返回完整详情,
|
||
// 直接用返回的 id 走 loadData 标准流程,必须 await 避免 finally 竞态
|
||
try {
|
||
const detail = await fetchTaskByMember(memberId)
|
||
if (!detail || !detail.id) {
|
||
this.setData({ pageState: 'empty' })
|
||
return
|
||
}
|
||
await this.loadData(String(detail.id))
|
||
} catch (_e) {
|
||
this.setData({ pageState: 'empty' })
|
||
}
|
||
},
|
||
|
||
/** 更新关系等级显示 */
|
||
updateRelationshipDisplay(score: number) {
|
||
let level: 'poor' | 'normal' | 'good' | 'excellent' = 'poor'
|
||
let text = '待发展'
|
||
let color = '#64748b' // 蓝灰色,区别于蓝色爱心
|
||
|
||
if (score > 8.5) {
|
||
level = 'excellent'
|
||
text = '很好'
|
||
color = '#e91e63' // 深粉色,区别于粉红爱心
|
||
} else if (score >= 6) {
|
||
level = 'good'
|
||
text = '良好'
|
||
color = '#ea580c' // 深橙色,区别于橙色爱心
|
||
} else if (score >= 3.5) {
|
||
level = 'normal'
|
||
text = '一般'
|
||
color = '#eab308' // 金黄色,区别于黄色爱心
|
||
}
|
||
|
||
this.setData({
|
||
relationLevel: level,
|
||
relationLevelText: text,
|
||
relationColor: color,
|
||
})
|
||
},
|
||
|
||
/** 返回 */
|
||
onBack() {
|
||
wx.navigateBack()
|
||
},
|
||
|
||
/** 重试 */
|
||
onRetry() {
|
||
const id = this.data.detail?.id || ''
|
||
this.loadData(id)
|
||
},
|
||
|
||
/** 查看/隐藏手机号 */
|
||
onTogglePhone() {
|
||
this.setData({ phoneVisible: !this.data.phoneVisible })
|
||
},
|
||
|
||
/** 复制手机号 */
|
||
onCopyPhone() {
|
||
const phone = this.data.detail?.customerPhone || ''
|
||
if (!phone) {
|
||
wx.showToast({ title: '暂无手机号', icon: 'none' })
|
||
return
|
||
}
|
||
wx.setClipboardData({
|
||
data: phone,
|
||
success: () => {
|
||
wx.showToast({ title: '手机号码已复制', icon: 'none' })
|
||
},
|
||
})
|
||
},
|
||
|
||
/** 展开/收起维客线索描述 */
|
||
onToggleClue(e: WechatMiniprogram.BaseEvent) {
|
||
const idx = e.currentTarget.dataset.index as number
|
||
const key = `retentionClues[${idx}].expanded`
|
||
const current = this.data.retentionClues[idx]?.expanded || false
|
||
this.setData({ [key]: !current })
|
||
},
|
||
|
||
/** 复制话术 */
|
||
onCopySpeech(e: WechatMiniprogram.BaseEvent) {
|
||
const idx = e.currentTarget.dataset.index as number
|
||
const text = this.data.talkingPoints[idx]
|
||
if (!text) return
|
||
wx.setClipboardData({
|
||
data: text,
|
||
success: () => {
|
||
this.setData({ copiedIndex: idx })
|
||
setTimeout(() => this.setData({ copiedIndex: -1 }), 2000)
|
||
},
|
||
})
|
||
},
|
||
|
||
/** 打开备注弹窗 */
|
||
onAddNote() {
|
||
this.setData({ noteModalVisible: true })
|
||
},
|
||
|
||
/** 备注弹窗确认 */
|
||
async onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
|
||
const { content, serviceScore, returnScore } = e.detail
|
||
const detail = this.data.detail
|
||
if (!detail) return
|
||
|
||
this.setData({ noteModalVisible: false })
|
||
wx.showLoading({ title: '保存中...', mask: true })
|
||
|
||
try {
|
||
// CHANGE 2026-03-27 | 联调:调真实后端 API 创建备注
|
||
const result = await createNote({
|
||
targetId: detail.customerId ?? Number(detail.id),
|
||
content,
|
||
taskId: detail.id ? Number(detail.id) : undefined,
|
||
ratingServiceWillingness: serviceScore > 0 ? serviceScore : undefined,
|
||
ratingRevisitLikelihood: returnScore > 0 ? returnScore : undefined,
|
||
})
|
||
wx.hideLoading()
|
||
wx.showToast({ title: '备注已保存', icon: 'success' })
|
||
|
||
// 用后端返回的数据构建 Note 对象,追加到列表
|
||
const newNote: Note = {
|
||
id: String(result.id),
|
||
content: result.content || content,
|
||
tagType: result.type || 'normal',
|
||
tagLabel: result.type === 'follow_up' ? '回访' : '普通',
|
||
createdAt: result.createdAt || new Date().toLocaleString('zh-CN', { hour12: false }),
|
||
score: result.aiScore || undefined,
|
||
}
|
||
this.setData({ sortedNotes: [{ ...newNote, timeLabel: '刚刚' }, ...this.data.sortedNotes] })
|
||
|
||
// CHANGE 2026-03-27 | AI 评分轮询:后台异步等待 aiScore 返回后自动更新星星
|
||
if (!result.aiScore) {
|
||
this._pollAiScore(String(result.id), 0)
|
||
}
|
||
} catch (err) {
|
||
wx.hideLoading()
|
||
console.error('[task-detail] createNote failed:', err)
|
||
wx.showToast({ title: '保存失败', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
/** CHANGE 2026-03-27 | AI 评分轮询:每 4 秒查一次,最多 5 次(20 秒) */
|
||
_pollAiScore(noteId: string, attempt: number) {
|
||
if (attempt >= 5) return
|
||
setTimeout(async () => {
|
||
try {
|
||
const token = wx.getStorageSync('token')
|
||
const detail = this.data.detail
|
||
if (!detail || !token) return
|
||
// 查 task_detail 接口获取最新备注列表
|
||
const taskDetail = await fetchTaskDetail(String(detail.id))
|
||
if (!taskDetail?.notes) { this._pollAiScore(noteId, attempt + 1); return }
|
||
const found = taskDetail.notes.find((n: any) => String(n.id) === noteId)
|
||
if ((found as any)?.aiScore || found?.score) {
|
||
// 更新 sortedNotes 中对应备注的 score
|
||
const updated = this.data.sortedNotes.map(n =>
|
||
String(n.id) === noteId ? { ...n, score: (found as any).aiScore || found!.score } : n
|
||
)
|
||
this.setData({ sortedNotes: updated })
|
||
} else {
|
||
this._pollAiScore(noteId, attempt + 1)
|
||
}
|
||
} catch {
|
||
this._pollAiScore(noteId, attempt + 1)
|
||
}
|
||
}, 4000)
|
||
},
|
||
|
||
/** 备注弹窗取消 */
|
||
onNoteCancel() {
|
||
this.setData({ noteModalVisible: false })
|
||
},
|
||
|
||
/** 删除备注 */
|
||
onDeleteNote(e: WechatMiniprogram.BaseEvent) {
|
||
const noteId = e.currentTarget.dataset.id as string
|
||
wx.showModal({
|
||
title: '删除备注',
|
||
content: '确定要删除这条备注吗?删除后无法恢复。',
|
||
confirmColor: '#e34d59',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
try {
|
||
// CHANGE 2026-03-27 | 联调:调真实后端 API 删除备注
|
||
await deleteNote(Number(noteId))
|
||
const notes = this.data.sortedNotes.filter((n) => String(n.id) !== noteId)
|
||
this.setData({ sortedNotes: notes })
|
||
wx.showToast({ title: '已删除', icon: 'success' })
|
||
} catch (err) {
|
||
console.error('[task-detail] deleteNote failed:', err)
|
||
wx.showToast({ title: '删除失败', icon: 'none' })
|
||
}
|
||
}
|
||
},
|
||
})
|
||
},
|
||
|
||
/** 放弃/取消放弃 */
|
||
onAbandon() {
|
||
// 如果已放弃,直接取消放弃(不需要二次确认)
|
||
if (this.data.detail?.status === 'abandoned') {
|
||
this.cancelAbandon()
|
||
return
|
||
}
|
||
// 否则打开放弃弹窗
|
||
this.setData({ abandonModalVisible: true })
|
||
},
|
||
|
||
/** 取消放弃 - 直接修改状态 */
|
||
cancelAbandon() {
|
||
wx.showLoading({ title: '处理中...' })
|
||
setTimeout(() => {
|
||
wx.hideLoading()
|
||
// 更新状态为 pending
|
||
this.setData({ 'detail.status': 'pending' })
|
||
wx.showToast({ title: '已取消放弃', icon: 'success' })
|
||
}, 500)
|
||
},
|
||
|
||
/** 放弃 — 确认 */
|
||
onAbandonConfirm(_e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
|
||
this.setData({ abandonModalVisible: false })
|
||
wx.showLoading({ title: '处理中...' })
|
||
setTimeout(() => {
|
||
wx.hideLoading()
|
||
// 更新状态为 abandoned
|
||
this.setData({ 'detail.status': 'abandoned' })
|
||
wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })
|
||
}, 500)
|
||
},
|
||
|
||
/** 放弃 — 取消 */
|
||
onAbandonCancel() {
|
||
this.setData({ abandonModalVisible: false })
|
||
},
|
||
|
||
/** 问问助手 */
|
||
onAskAssistant() {
|
||
// 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?taskId=${taskId}`,
|
||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||
})
|
||
},
|
||
|
||
/** 查看全部服务记录 */
|
||
onViewAllRecords() {
|
||
// 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' }),
|
||
})
|
||
},
|
||
|
||
/** noop — 阻止弹窗背景点击冒泡 */
|
||
noop() {},
|
||
|
||
/** 切换调试面板 */
|
||
toggleDebugPanel() {
|
||
this.setData({ showDebugPanel: !this.data.showDebugPanel })
|
||
},
|
||
|
||
/** 调试 - 切换任务类型 */
|
||
onDebugTaskType(e: WechatMiniprogram.BaseEvent) {
|
||
const type = e.currentTarget.dataset.type as string
|
||
const typeMap: Record<string, string> = {
|
||
high_priority: '高优先召回',
|
||
priority_recall: '优先召回',
|
||
relationship: '关系构建',
|
||
callback: '客户回访',
|
||
}
|
||
|
||
// 根据任务类型设置 Banner 背景
|
||
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||
if (type === 'high_priority_recall') {
|
||
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
|
||
} else if (type === 'priority_recall') {
|
||
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
|
||
} else if (type === 'relationship_building') {
|
||
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
|
||
} else if (type === 'follow_up_visit') {
|
||
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
|
||
}
|
||
|
||
this.setData({
|
||
debugTaskType: type,
|
||
'detail.taskTypeLabel': typeMap[type] || '高优先召回',
|
||
bannerBgSvg,
|
||
})
|
||
},
|
||
|
||
/** 调试 - 调整关系数值 */
|
||
onDebugHeartScore(e: WechatMiniprogram.SliderChange) {
|
||
const score = e.detail.value
|
||
this.setData({
|
||
debugHeartScore: score,
|
||
'detail.heartScore': score,
|
||
})
|
||
this.updateRelationshipDisplay(score)
|
||
},
|
||
|
||
/** 调试 - 切换备注弹窗展开按钮 */
|
||
onDebugToggleExpandBtn(e: WechatMiniprogram.BaseEvent) {
|
||
const value = e.currentTarget.dataset.value as boolean
|
||
this.setData({ debugShowExpandBtn: value })
|
||
},
|
||
})
|