Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
2026-05-04 02:30:19 +08:00

263 lines
8.4 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",
aiInsight: {
summary: '',
strategies: [
{ color: '', text: '' },
{ color: '', text: '' },
],
},
clues: [
{ category: '', categoryColor: '', text: '', source: '' },
{ category: '', categoryColor: '', text: '', source: '', detail: '' },
],
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) {
const cache = await fetchAICache('app7_customer_analysis', memberId)
if (!cache?.result_json) return
const rj = cache.result_json as any
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
const strategies = Array.isArray(rj.strategies)
? rj.strategies.map((s: any, i: number) => ({
color: COLORS[i % COLORS.length],
text: s.title || s.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' })
}
},
})
},
})