Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts
Neo 2dfc926f96 feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活
Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作:
- 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId
- 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设)
- xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离
- chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡)
- reference_card KPI 富卡接入 SSE 路径,db 真写入
- 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字

数据库:
- public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id
- biz.ai_run_logs 加 assistant_id + 复合索引
- chk_ai_cache_type CHECK 约束 8 类应用名
- cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation)
- 历史 emoji 抽取脚本 44/44 成功

后端 silent failure 修:
- cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效)
- _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema)
- task_manager talkingPoints 改 app5_tactics + tactics 字段
- task_manager aiSuggestion 改取 one_line_summary
- cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area
- WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞)
- internal_ai token 改 hmac.compare_digest

工具/文档:
- main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤
- 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则)
- 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错)
- audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务
- backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线

实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:07 +08:00

271 lines
9.0 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 { fetchCustomerDetail, fetchAICache } from '../../services/api'
interface ConsumptionRecord {
id: string
type: "table" | "shop" | "recharge"
date: string
tableName?: string
startTime?: string
endTime?: string
duration?: string
tableFee?: number
tableOrigPrice?: number
coaches?: Array<{
name: string
level: string
levelColor: string
courseType: string
hours: string
perfHours?: string
fee: number
}>
foodAmount?: number
foodOrigPrice?: number
foodDetail?: string
totalAmount?: number
totalOrigPrice?: number
payMethod?: string
rechargeAmount?: number
}
const mockRecords: ConsumptionRecord[] = [
{ id: '', type: 'table', date: '', tableName: '', startTime: '', endTime: '', duration: '', tableFee: 0, coaches: [{ name: '', level: '', levelColor: '', courseType: '', hours: '', fee: 0 }], foodAmount: 0, totalAmount: 0, payMethod: '' },
{ id: '', type: 'recharge', date: '', rechargeAmount: 0 },
]
Page({
data: {
pageState: "loading" as "loading" | "empty" | "error" | "normal",
detail: {
id: '',
name: '',
avatarChar: '',
phone: '',
phoneFull: '',
balance: null as number | null,
consumption60d: null as number | null,
idealInterval: null as number | null,
daysSinceVisit: null as number | null,
},
phoneVisible: false,
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
// 对齐 demo 标杆 wxml(`{{item.text}}` 单字段);color 由前端按 index 轮换。
aiInsight: {
summary: '',
strategies: [] as Array<{ color: string; text: string }>,
},
// W1-AI-CLOSURE 组 6:clues 字段对齐 RetentionClue schema
// {tag, tag_color, emoji, text, source, desc} → camelCase {tag, tagColor, emoji, text, source, desc}
clues: [] as Array<{
tag: string
tagColor: string
emoji: string
text: string
source: string
desc: string
}>,
coachTasks: [
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
],
favoriteCoaches: [
{ emoji: '', name: '', relationIndex: '', indexColor: '', bgClass: '', stats: [{ label: '', value: '' }] },
],
consumptionRecords: mockRecords,
loadingMore: false,
noteModalVisible: false,
favCoachExpanded: false,
sortedNotes: [
{ id: '', tagLabel: '', createdAt: '', content: '' },
{ id: '', tagLabel: '', createdAt: '', content: '' },
] as Array<{ id: string; tagLabel: string; createdAt: string; content: string }>,
},
onLoad(options: any) {
// 随机 AI 配色
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
this.setData({ aiColor })
const id = options?.id || options?.customerId || ''
this.loadDetail(id)
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/customer-detail/customer-detail')
},
// CHANGE 2026-03-29 | P3 联调映射所有后端返回字段AI 相关暂跳过)
async loadDetail(id?: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
if (id) {
const d = await fetchCustomerDetail(id)
if (d) {
this.setData({
detail: {
id: d.id ?? id,
name: d.name || '',
avatarChar: (d.name || '')[0] || '',
phone: d.phone || '',
phoneFull: d.phoneFull || '',
balance: d.balance ?? null,
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D大写 D
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
idealInterval: d.idealInterval ?? null,
daysSinceVisit: d.daysSinceVisit ?? null,
},
// 维客线索
clues: d.retentionClues || [],
// 助教任务
coachTasks: d.coachTasks || [],
// 最亲密助教
favoriteCoaches: d.favoriteCoaches || [],
// 消费记录
consumptionRecords: d.consumptionRecords || [],
// 备注
sortedNotes: d.notes || [],
})
}
}
this.setData({ pageState: 'normal' })
if (id) this._loadAIInsight(id)
} catch (e) {
console.error('[customer-detail] loadDetail 失败:', e)
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
async _loadAIInsight(memberId: string) {
// cache_type 统一为 'app7_customer'(W1-AI-CLOSURE 组 1 数据库迁移已对齐)。
// App7Result.strategies = [{title, content}],前端拼成 demo 标杆 {color, text} 单字段。
const cache = await fetchAICache('app7_customer', memberId)
if (!cache?.result_json) return
const rj = cache.result_json as { summary?: string; strategies?: Array<{ title?: string; content?: string; text?: string }> }
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
const strategies = Array.isArray(rj.strategies)
? rj.strategies.map((s, i) => {
const t = (s?.title || '').trim()
const c = (s?.content || '').trim()
const text = s?.text || (t && c ? `${t}${c}` : (c || t))
return { color: COLORS[i % COLORS.length], text }
})
: []
this.setData({
'aiInsight.summary': rj.summary || '',
'aiInsight.strategies': strategies,
})
},
onRetry() {
const id = this.data.detail?.id || ''
this.loadDetail(id)
},
/** 查看/隐藏手机号 */
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号(复制完整号码) */
onCopyPhone() {
const phone = this.data.detail.phoneFull || this.data.detail.phone
wx.setClipboardData({
data: phone,
success: () => {
wx.showToast({ title: '手机号码已复制', icon: 'none' })
},
})
},
onViewServiceRecords() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/customer-records/customer-records?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onStartChat() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onAddNote() {
this.setData({ noteModalVisible: true })
},
// CHANGE 2026-03-29 | 备注创建调用后端 API保存后直接插入列表顶部
async onNoteConfirm(e: any) {
const { content, score } = e.detail || {}
this.setData({ noteModalVisible: false })
if (!content) return
try {
const { createNote } = require('../../services/api')
const result = await createNote({
targetId: Number(this.data.detail.id),
content,
score: score || undefined,
})
wx.showToast({ title: '备注已保存', icon: 'success' })
// 直接插入到列表顶部,不刷新整页
const now = new Date()
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const newNote = {
id: result?.id || Date.now(),
tagLabel: '备注',
createdAt: timeStr,
content,
}
this.setData({
sortedNotes: [newNote, ...this.data.sortedNotes],
})
} catch {
wx.showToast({ title: '备注保存失败', icon: 'none' })
}
},
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
onToggleFavCoaches() {
this.setData({ favCoachExpanded: !this.data.favCoachExpanded })
},
onDeleteNote(e: WechatMiniprogram.TouchEvent) {
const noteId = e.currentTarget.dataset.id
if (!noteId) return
wx.showModal({
title: '确认删除',
content: '删除后不可恢复',
success: async (res) => {
if (!res.confirm) return
try {
const { deleteNote } = require('../../services/api')
await deleteNote(noteId)
// 从列表中移除
this.setData({
sortedNotes: this.data.sortedNotes.filter((n: any) => n.id !== noteId),
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch {
wx.showToast({ title: '删除失败', icon: 'none' })
}
},
})
},
})