Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

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

539 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 { fetchCoachDetail } from '../../services/api'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { sortByTimestamp } from '../../utils/sort'
import { nameToAvatarColor } from '../../utils/avatar-color'
import {
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
calcShineDur, buildProgressBarData,
type TickItem,
} from '../../utils/perf-progress'
/** 助教详情(含绩效、收入、任务、客户关系等) */
interface CoachDetail {
id: string
name: string
avatar: string
level: string
skills: string[]
workYears: number
customerCount: number
hireDate: string
performance: {
// 核心绩效字段(与任务页 PerformanceSummary 一致)
totalHours: number
totalIncome: number
totalCustomers: number
tierNodes: number[]
basicHours: number
bonusHours: number
currentTier: number
nextTierHours: number
tierCompleted: boolean
bonusMoney: number
incomeTrend: string
incomeTrendDir: string
currentTierLabel: string
// 助教详情专属扩展
customerBalance: number
tasksCompleted: number
// 兼容旧字段
monthlyHours: number
monthlySalary: number
}
income: {
thisMonth: IncomeItem[]
lastMonth: IncomeItem[]
}
taskStats: { callback: number; recall: number }
tierNodes: number[]
notes: NoteItem[]
}
interface IncomeItem {
label: string
amount: string
color: string
}
interface NoteItem {
id: string
content: string
timestamp: string
score: number
customerName: string
tagLabel: string
createdAt: string
}
interface TaskItem {
typeLabel: string
typeClass: string
customerName: string
noteCount: number
pinned: boolean
notes?: Array<{ pinned?: boolean; text: string; date: string }>
}
interface AbandonedTask {
customerName: string
reason: string
}
interface TopCustomer {
id: string
name: string
initial: string
avatarGradient: string
heartEmoji: string
score: string
scoreColor: string
serviceCount: number
balance: string
consume: string
}
interface ServiceRecord {
customerId?: string
customerName: string
initial: string
avatarGradient: string
type: string
typeClass: string
table: string
duration: string
income: string
date: string
perfHours?: string
}
interface HistoryMonth {
month: string
estimated: boolean
customers: string
hours: string
salary: string
callbackDone: number
recallDone: number
}
/** Mock 数据(已清空,用于排查 MOCK 覆盖完整性) */
const mockCoachDetail: CoachDetail = {
id: '',
name: '',
avatar: '',
level: '',
skills: [],
workYears: 0,
customerCount: 0,
hireDate: '',
performance: {
totalHours: 0,
totalIncome: 0,
totalCustomers: 0,
tierNodes: [],
basicHours: 0,
bonusHours: 0,
currentTier: 0,
nextTierHours: 0,
tierCompleted: false,
bonusMoney: 0,
incomeTrend: '',
incomeTrendDir: 'up',
currentTierLabel: '',
customerBalance: 0,
tasksCompleted: 0,
monthlyHours: 0,
monthlySalary: 0,
},
income: {
thisMonth: [],
lastMonth: [],
},
taskStats: { callback: 0, recall: 0 },
tierNodes: [],
notes: [],
}
const mockVisibleTasks: TaskItem[] = [
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false, notes: [{ text: '', date: '' }] },
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false },
]
const mockHiddenTasks: TaskItem[] = [
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false },
]
const mockAbandonedTasks: AbandonedTask[] = [
{ customerName: '', reason: '' },
]
const mockTopCustomers: TopCustomer[] = [
{ id: '', name: '', initial: '', avatarGradient: '', heartEmoji: '', score: '', scoreColor: '', serviceCount: 0, balance: '', consume: '' },
{ id: '', name: '', initial: '', avatarGradient: '', heartEmoji: '', score: '', scoreColor: '', serviceCount: 0, balance: '', consume: '' },
]
const mockServiceRecords: ServiceRecord[] = [
{ customerName: '', initial: '', avatarGradient: '', type: '', typeClass: '', table: '', duration: '', income: '', date: '' },
{ customerName: '', initial: '', avatarGradient: '', type: '', typeClass: '', table: '', duration: '', income: '', date: '' },
]
const mockHistoryMonths: HistoryMonth[] = [
{ month: '', estimated: false, customers: '', hours: '', salary: '', callbackDone: 0, recallDone: 0 },
{ month: '', estimated: false, customers: '', hours: '', salary: '', callbackDone: 0, recallDone: 0 },
]
Page({
data: {
/** 页面状态:四态 */
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 助教 ID */
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** 等级标签背景色(助教详情页专属,适配深色背景) */
levelBgColor: '',
/** 绩效指标卡片 */
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
/** 绩效进度 */
perfCurrent: 0,
perfTarget: 100,
perfGap: 0,
perfPercent: 0,
/** 收入明细 Tab */
incomeTab: 'this' as 'this' | 'last',
currentIncome: [] as IncomeItem[],
incomeTotal: '',
/** 任务执行 */
taskStats: { recall: 0, callback: 0 },
visibleTasks: [] as TaskItem[],
hiddenTasks: [] as TaskItem[],
abandonedTasks: [] as AbandonedTask[],
tasksExpanded: false,
/** 客户关系 TOP20 */
topCustomers: [] as TopCustomer[],
topCustomersExpanded: false,
/** 近期服务明细 */
serviceRecords: [] as ServiceRecord[],
/** 更多信息 */
historyMonths: [] as HistoryMonth[],
/** 备注 */
sortedNotes: [] as NoteItem[],
noteModalVisible: false,
/** 备注列表弹窗 */
notesPopupVisible: false,
notesPopupName: '',
notesPopupList: [] as Array<{ pinned?: boolean; text: string; date: string }>,
/** 进度条动画状态(驱动 perf-progress-bar 组件) */
pbFilledPct: 0,
pbClampedSparkPct: 0,
pbCurrentTier: 0,
pbTicks: [] as TickItem[],
pbShineRunning: false,
pbSparkRunning: false,
pbShineDurMs: 1000,
pbSparkDurMs: SPARK_DUR_MS,
},
_longPressed: false,
_animTimer: null as ReturnType<typeof setTimeout> | null,
onLoad(options: { id?: string }) {
const id = options?.id || ''
this.setData({ coachId: id })
this.loadData(id)
},
onHide() {
this._stopAnimLoop()
},
onUnload() {
this._stopAnimLoop()
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/coach-detail/coach-detail')
if (this.data.pageState === 'normal' && !this._animTimer) {
this._startAnimLoop()
}
},
_startAnimLoop() {
this._stopAnimLoop()
this._runAnimStep()
},
_stopAnimLoop() {
if (this._animTimer !== null) {
clearTimeout(this._animTimer)
this._animTimer = null
}
this.setData({ pbShineRunning: false, pbSparkRunning: false })
},
_runAnimStep() {
const filledPct = this.data.pbFilledPct ?? 0
const shineDurMs = calcShineDur(filledPct)
this.setData({ pbShineRunning: true, pbSparkRunning: false, pbShineDurMs: shineDurMs })
const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS)
this._animTimer = setTimeout(() => {
this.setData({ pbSparkRunning: true })
this._animTimer = setTimeout(() => {
this.setData({ pbShineRunning: false, pbSparkRunning: false })
this._animTimer = setTimeout(() => {
this._runAnimStep()
}, Math.max(0, NEXT_LOOP_DELAY_MS))
}, SPARK_DUR_MS)
}, sparkTriggerDelay)
},
// CHANGE 2026-03-29 | P2 联调Mock → 真实 API所有字段从 fetchCoachDetail 映射
async loadData(id: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const d = await fetchCoachDetail(id)
if (!d) {
this.setData({ pageState: 'empty' })
return
}
const perf = d.performance || {} as any
// 统一使用 totalHours来自 monthly_summary 实时值),与任务页一致
const totalHours = perf.totalHours ?? perf.total_hours ?? perf.monthlyHours ?? 0
const perfCards = [
{ label: '本月定档业绩', value: formatHours(totalHours), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? perf.totalIncome ?? perf.total_income ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? perf.customer_balance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? perf.tasks_completed ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
// 统一使用共用模块计算进度条数据(与任务页相同逻辑)
const pbData = buildProgressBarData(perf)
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
const perfGap = Math.max(0, nextTierHours - totalHours)
const tierNodes = perf.tierNodes ?? perf.tier_nodes ?? [0]
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
const perfPercent = maxHours > 0 ? Math.min(Math.round((totalHours / maxHours) * 100), 100) : 0
const sorted = sortByTimestamp(d.notes || [], 'timestamp') as NoteItem[]
const taskStats = d.taskStats ?? { recall: 0, callback: 0 }
// 等级标签背景色(助教详情页深色背景专用)
const level = (d as any).level || ''
const levelBgMap: Record<string, string> = {
'junior': '#E5EDFB', '初级': '#E5EDFB',
'middle': '#FDEFE5', '中级': '#FDEFE5',
'senior': '#FDE3EC', '高级': '#FDE3EC',
}
const levelBgColor = levelBgMap[level] || ''
this.setData({
pageState: 'normal',
detail: d,
levelBgColor,
perfCards,
perfCurrent: totalHours,
perfTarget: nextTierHours,
perfGap,
perfPercent,
taskStats,
visibleTasks: d.visibleTasks || [],
hiddenTasks: d.hiddenTasks || [],
abandonedTasks: d.abandonedTasks || [],
topCustomers: (d.topCustomers || [])
.map((c: any) => ({
...c,
avatarGradient: nameToAvatarColor(String(c.id || '')),
}))
.sort((a: any, b: any) => parseFloat(b.score || 0) - parseFloat(a.score || 0)),
serviceRecords: (d.serviceRecords || []).map((r: any) => ({
...r,
avatarGradient: nameToAvatarColor(String(r.customerId || '')),
})),
historyMonths: d.historyMonths || [],
sortedNotes: sorted,
pbFilledPct: pbData.filledPct,
pbClampedSparkPct: pbData.clampedSparkPct,
pbCurrentTier: pbData.currentTier,
pbTicks: pbData.ticks,
pbShineDurMs: pbData.shineDurMs,
pbSparkDurMs: pbData.sparkDurMs,
})
this.switchIncomeTab('this')
setTimeout(() => this._startAnimLoop(), 300)
} catch (e) {
console.error('[coach-detail] loadData 失败:', e)
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 切换收入明细 Tab */
switchIncomeTab(tab: 'this' | 'last') {
const detail = this.data.detail
if (!detail) return
const items = tab === 'this' ? detail.income.thisMonth : detail.income.lastMonth
const total = items.reduce((sum, item) => {
const num = parseFloat(item.amount.replace(/[¥,]/g, ''))
return sum + (isNaN(num) ? 0 : num)
}, 0)
this.setData({
incomeTab: tab,
currentIncome: items,
incomeTotal: formatMoney(total),
})
},
/** 点击收入 Tab */
onIncomeTabTap(e: WechatMiniprogram.CustomEvent) {
const tab = e.currentTarget.dataset.tab as 'this' | 'last'
this.switchIncomeTab(tab)
},
/** 展开/收起任务 */
onToggleTasks() {
this.setData({ tasksExpanded: !this.data.tasksExpanded })
},
/** 点击任务项 — 跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 展开/收起客户关系列表 */
onToggleTopCustomers() {
this.setData({ topCustomersExpanded: !this.data.topCustomersExpanded })
},
/** 点击任务备注图标 — 弹出备注列表 */
onTaskNoteTap(e: WechatMiniprogram.CustomEvent) {
const idx = e.currentTarget.dataset.index as number | undefined
const hiddenIdx = e.currentTarget.dataset.hiddenIndex as number | undefined
let task: TaskItem | undefined
if (idx !== undefined) {
task = this.data.visibleTasks[idx]
} else if (hiddenIdx !== undefined) {
task = this.data.hiddenTasks[hiddenIdx]
}
if (task?.notes && task.notes.length > 0) {
this.setData({
notesPopupVisible: true,
notesPopupName: task.customerName,
notesPopupList: task.notes,
})
}
},
/** 关闭备注列表弹窗 */
onHideNotesPopup() {
this.setData({ notesPopupVisible: false })
},
/** 点击客户卡片 — 跳转客户详情(散客 id ≤ 0 时无详情可看) */
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 近期服务明细 — 点击跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
const { id } = e.currentTarget.dataset
const cid = Number(id)
if (!cid || cid <= 0) {
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${cid}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看更多服务记录 → 跳"助教业绩明细"页(管理者视角,独立于任务 tab 自查页) */
onViewMoreRecords() {
const coachId = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/coach-service-records/coach-service-records?coachId=${coachId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 打开备注弹窗 */
onAddNote() {
this.setData({ noteModalVisible: true })
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
const { content } = e.detail
// TODO: 替换为真实 API 调用 POST /api/xcx/notes
const newNote: NoteItem = {
id: `n-${Date.now()}`,
content,
timestamp: new Date().toISOString().slice(0, 16).replace('T', ' '),
score: 0,
customerName: '我',
tagLabel: '我',
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
const notes = [newNote, ...this.data.sortedNotes]
this.setData({ noteModalVisible: false, sortedNotes: notes })
wx.showToast({ title: '备注已保存', icon: 'success' })
},
/** 备注弹窗取消 */
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** 重试 */
onRetry() {
const id = this.data.coachId || ''
this.loadData(id)
},
/** 问问助手 */
onStartChat() {
const id = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/chat/chat?coachId=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
})