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>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -1,8 +1,14 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { fetchTaskDetail } from '../../services/api'
import { fetchTaskDetail, fetchTaskByMember, createNote, deleteNote } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time'
import { formatMoney } from '../../utils/money'
import { formatStorageLevel } from '../../utils/storage-level'
/** 维客线索项 */
interface RetentionClue {
@@ -38,24 +44,6 @@ interface ServiceSummary {
avgIncome: number
}
/**
* 将 ISO/空格分隔的日期字符串格式化为中文短格式
* "2026-02-07T21:30" → "2月7日 21:30"
* 与 customer-service-records 页面拼接方式保持一致
*/
function formatServiceDate(dateStr: string): string {
if (!dateStr) return dateStr
// 兼容 "2026-02-07T21:30" 和 "2026-02-07 21:30"
const normalized = dateStr.replace('T', ' ')
const [datePart, timePart] = normalized.split(' ')
if (!datePart) return dateStr
const parts = datePart.split('-')
if (parts.length < 3) return dateStr
const month = parseInt(parts[1], 10)
const day = parseInt(parts[2], 10)
return timePart ? `${month}${day}${timePart}` : `${month}${day}`
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
@@ -65,33 +53,25 @@ Page({
// --- 维客线索 ---
retentionClues: [
{ tag: '客户\n基础', tagColor: 'primary', emoji: '🎂', text: '生日 3月15日 · VIP会员 · 注册2年', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🌙', text: '常来夜场 · 月均4-5次', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '💰', text: '高客单价', source: 'By:系统', desc: '近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%', expanded: false },
{ tag: '玩法\n偏好', tagColor: 'purple', emoji: '🎱', text: '偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣', source: 'By:系统', desc: '中式八球占比 60%,斯诺克 30%近2周开始尝试花式九球技术水平中等偏上', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '⚠️', text: '上次提到想练斯诺克走位', source: 'By:小燕', desc: '2月7日到店时主动提及希望有针对性的走位训练建议下次安排斯诺克专项课程', expanded: false },
{ tag: '社交\n偏好', tagColor: 'purple', emoji: '👥', text: '喜欢带朋友来玩 · 社交型客户', source: 'By:系统', desc: '70%的到店记录都是多人消费,经常介绍新客户;建议推荐团建套餐和会员推荐奖励', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🍷', text: '酒水消费占比高 · 偏好高端酒水', source: 'By:系统', desc: '每次到店必点酒水偏好芝华士、百威等品牌酒水消费占总消费的40%', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '💬', text: '上次提到想办生日派对', source: 'By:Lucy', desc: '3月15日生日想在店里办派对预计10-15人已记录需求建议提前联系确认', expanded: false },
{ 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: [
'王哥您好,好久不见!最近店里新到了几张国际标准的斯诺克球桌,知道您是斯诺克爱好者,想邀请您有空来体验一下~',
'王哥,最近忙吗?这周末我们有个老客户专属的球友交流赛,奖品还挺丰富的,您要不要来参加?',
'王哥好呀,上次您提到想练练斯诺克的走位,我最近研究了一些新的训练方法,下次来的时候可以一起试试~',
'王哥,好久没见您了,您的老位置 A12 号台一直给您留着呢!最近晚上人不多,环境特别好,随时欢迎您来~',
'王哥您好,我们这个月推出了储值会员专属的夜场优惠套餐,包含球台+酒水,性价比很高,给您留意着呢~',
'',
'',
'',
] as string[],
copiedIndex: -1,
// --- 近期服务记录 ---
serviceSummary: { totalHours: 6.0, totalIncome: 510, avgIncome: 170 } as ServiceSummary,
serviceSummary: { totalHours: 0, totalIncome: 0, avgIncome: 0 } as ServiceSummary,
serviceRecords: [
{ table: 'A12号台', type: '基础课', typeClass: 'basic', duration: 2.5, durationRaw: 3.0, income: 200, isEstimate: true, drinks: '🍷 百威x2 红牛x1', date: formatServiceDate('2026-02-07T21:30') },
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: 2.0, durationRaw: 2.0, income: 160, isEstimate: false, drinks: '🍷 可乐x1', date: formatServiceDate('2026-02-01T20:30') },
{ table: 'VIP1号房', type: '包厢课', typeClass: 'vip', duration: 1.5, durationRaw: 1.5, income: 150, isEstimate: true, drinks: '🍷 芝华士x1 矿泉水x2', date: formatServiceDate('2026-01-28T19:00') },
{ table: '', type: '充值', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 80, isEstimate: false, drinks: '', date: formatServiceDate('2026-01-15T10:00') },
{ 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[],
// --- 放弃弹窗 ---
@@ -101,19 +81,19 @@ Page({
phoneVisible: false,
// --- 储值等级 ---
storageLevel: '非常多',
storageLevel: '',
// --- 关系等级相关 ---
relationLevel: 'excellent' as 'poor' | 'normal' | 'good' | 'excellent',
relationLevelText: '很好',
relationColor: '#e91e63',
relationLevel: 'poor' as 'poor' | 'normal' | 'good' | 'excellent',
relationLevelText: '',
relationColor: '',
// --- Banner 背景(根据任务类型动态切换)---
bannerBgSvg: '/assets/images/banner-bg-red-aurora.svg',
// --- 调试面板 ---
showDebugPanel: false,
debugTaskType: 'high_priority',
debugTaskType: 'high_priority_recall',
debugHeartScore: 8.5,
debugShowExpandBtn: true, // 调试:备注弹窗是否显示展开/收起按钮
@@ -121,17 +101,31 @@ Page({
aiColor: 'indigo' as 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple',
},
onLoad(options: { id?: string }) {
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 })
this.loadData(id)
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) {
@@ -141,13 +135,13 @@ Page({
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (detail.taskType === 'high_priority') {
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') {
} else if (detail.taskType === 'relationship_building') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (detail.taskType === 'callback') {
} else if (detail.taskType === 'follow_up_visit') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}
@@ -158,6 +152,9 @@ Page({
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',
@@ -165,9 +162,39 @@ Page({
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' })
}
},
@@ -216,7 +243,11 @@ Page({
/** 复制手机号 */
onCopyPhone() {
const phone = '13812345678'
const phone = this.data.detail?.customerPhone || ''
if (!phone) {
wx.showToast({ title: '暂无手机号', icon: 'none' })
return
}
wx.setClipboardData({
data: phone,
success: () => {
@@ -253,18 +284,73 @@ Page({
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { content } = e.detail
wx.showToast({ title: '备注已保存', icon: 'success' })
async onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { content, serviceScore, returnScore } = e.detail
const detail = this.data.detail
if (!detail) return
this.setData({ noteModalVisible: false })
const newNote: Note = {
id: `note-${Date.now()}`,
content,
tagType: 'customer',
tagLabel: `客户:${this.data.detail?.customerName || ''}`,
createdAt: new Date().toLocaleString('zh-CN', { hour12: 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' })
}
this.setData({ sortedNotes: [newNote, ...this.data.sortedNotes] })
},
/** 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)
},
/** 备注弹窗取消 */
@@ -279,11 +365,18 @@ Page({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
success: async (res) => {
if (res.confirm) {
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
this.setData({ sortedNotes: notes })
wx.showToast({ title: '已删除', icon: 'success' })
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' })
}
}
},
})
@@ -369,13 +462,13 @@ Page({
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (type === 'high_priority') {
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') {
} else if (type === 'relationship_building') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (type === 'callback') {
} else if (type === 'follow_up_visit') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}