Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- 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>
2026-04-06 00:03:48 +08:00

498 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 | 清洗 serviceRecordsnull 值转为组件期望的默认值
// 小程序组件 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 })
},
})