主线 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>
539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
/* 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' }),
|
||
})
|
||
},
|
||
})
|