Files
Neo-ZQYY/apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts

541 lines
21 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.
import { mockCoaches } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
*/
const SHINE_SPEED = 70
const SPARK_DELAY_MS = -150
const SPARK_DUR_MS = 1400
const NEXT_LOOP_DELAY_MS = 400
const SHINE_WIDTH_RPX = 120
const TRACK_WIDTH_RPX = 634
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
function calcShineDur(filledPct: number): number {
const t = (SHINE_SPEED - 1) / 99
const baseDur = 5000 - t * (5000 - 50)
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
return Math.max(50, Math.round(baseDur * distRatio))
}
interface TickItem {
value: number
label: string
left: string
highlight: boolean
}
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
return tierNodes.map((v, i) => ({
value: v,
label: String(v),
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
highlight: i === 2,
}))
}
/** 助教详情(含绩效、收入、任务、客户关系等) */
interface CoachDetail {
id: string
name: string
avatar: string
level: string
skills: string[]
workYears: number
customerCount: number
hireDate: string
performance: {
monthlyHours: number
monthlySalary: number
customerBalance: number
tasksCompleted: number
/** 绩效档位 */
perfCurrent: number
perfTarget: number
}
income: {
thisMonth: IncomeItem[]
lastMonth: IncomeItem[]
}
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 数据 */
const mockCoachDetail: CoachDetail = {
id: 'coach-001',
name: '小燕',
avatar: '/assets/images/avatar-coach.png',
level: '星级',
skills: ['中🎱', '🎯斯诺克'],
workYears: 3,
customerCount: 68,
hireDate: '2023-03-15',
performance: {
monthlyHours: 87.5,
monthlySalary: 6950,
customerBalance: 86200,
tasksCompleted: 38,
perfCurrent: 80,
perfTarget: 100,
},
income: {
thisMonth: [
{ label: '基础课时费', amount: '¥3,500', color: 'primary' },
{ label: '激励课时费', amount: '¥1,800', color: 'success' },
{ label: '充值提成', amount: '¥1,200', color: 'warning' },
{ label: '酒水提成', amount: '¥450', color: 'purple' },
],
lastMonth: [
{ label: '基础课时费', amount: '¥3,800', color: 'primary' },
{ label: '激励课时费', amount: '¥1,900', color: 'success' },
{ label: '充值提成', amount: '¥1,100', color: 'warning' },
{ label: '酒水提成', amount: '¥400', color: 'purple' },
],
},
notes: [
{ id: 'n1', content: '本月表现优秀,客户好评率高', timestamp: '2026-03-05T14:30:00', score: 9, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-03-05 14:30' },
{ id: 'n2', content: '需要加强斯诺克教学技巧', timestamp: '2026-02-28T10:00:00', score: 7, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-02-28 10:00' },
{ id: 'n3', content: '客户王先生反馈服务态度很好', timestamp: '2026-02-20T16:45:00', score: 8, customerName: '王先生', tagLabel: '王先生', createdAt: '2026-02-20 16:45' },
],
}
const mockVisibleTasks: TaskItem[] = [
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '王先生', noteCount: 2, pinned: true, notes: [{ pinned: true, text: '重点客户,每周必须联系', date: '2026-02-06' }, { text: '上次来说最近出差多', date: '2026-02-01' }] },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '李女士', noteCount: 0, pinned: true },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '陈女士', noteCount: 1, pinned: true, notes: [{ text: '喜欢斯诺克,周末常来', date: '2026-01-28' }] },
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '张先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '赵总', noteCount: 3, pinned: false, notes: [{ pinned: true, text: '大客户,注意维护关系', date: '2026-02-03' }, { text: '上次带了3个朋友来', date: '2026-01-25' }, { text: '喜欢VIP包厢', date: '2026-01-15' }] },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '周女士', noteCount: 0, pinned: false },
]
const mockHiddenTasks: TaskItem[] = [
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '刘先生', noteCount: 0, pinned: false },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '孙先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '吴女士', noteCount: 0, pinned: false },
]
const mockAbandonedTasks: AbandonedTask[] = [
{ customerName: '吴先生', reason: '客户拒绝' },
{ customerName: '郑女士', reason: '超时未响应' },
]
const mockTopCustomers: TopCustomer[] = [
{ id: 'c1', name: '王先生', initial: '王', avatarGradient: 'pink', heartEmoji: '❤️', score: '9.5', scoreColor: 'success', serviceCount: 25, balance: '¥8,600', consume: '¥12,800' },
{ id: 'c2', name: '李女士', initial: '李', avatarGradient: 'amber', heartEmoji: '❤️', score: '9.2', scoreColor: 'success', serviceCount: 22, balance: '¥6,200', consume: '¥9,500' },
{ id: 'c3', name: '陈女士', initial: '陈', avatarGradient: 'green', heartEmoji: '❤️', score: '8.5', scoreColor: 'warning', serviceCount: 18, balance: '¥5,000', consume: '¥7,200' },
{ id: 'c4', name: '张先生', initial: '张', avatarGradient: 'blue', heartEmoji: '💛', score: '7.8', scoreColor: 'warning', serviceCount: 12, balance: '¥3,800', consume: '¥5,600' },
{ id: 'c5', name: '赵先生', initial: '赵', avatarGradient: 'violet', heartEmoji: '💛', score: '6.8', scoreColor: 'gray', serviceCount: 8, balance: '¥2,000', consume: '¥3,200' },
{ id: 'c6', name: '刘女士', initial: '刘', avatarGradient: 'pink', heartEmoji: '💛', score: '6.5', scoreColor: 'gray', serviceCount: 7, balance: '¥1,800', consume: '¥2,900' },
{ id: 'c7', name: '孙先生', initial: '孙', avatarGradient: 'teal', heartEmoji: '💛', score: '6.2', scoreColor: 'gray', serviceCount: 6, balance: '¥1,500', consume: '¥2,500' },
{ id: 'c8', name: '周女士', initial: '周', avatarGradient: 'amber', heartEmoji: '💛', score: '6.0', scoreColor: 'gray', serviceCount: 6, balance: '¥1,400', consume: '¥2,200' },
{ id: 'c9', name: '吴先生', initial: '吴', avatarGradient: 'blue', heartEmoji: '💛', score: '5.8', scoreColor: 'gray', serviceCount: 5, balance: '¥1,200', consume: '¥2,000' },
{ id: 'c10', name: '郑女士', initial: '郑', avatarGradient: 'green', heartEmoji: '💛', score: '5.5', scoreColor: 'gray', serviceCount: 5, balance: '¥1,000', consume: '¥1,800' },
{ id: 'c11', name: '冯先生', initial: '冯', avatarGradient: 'violet', heartEmoji: '🤍', score: '5.2', scoreColor: 'gray', serviceCount: 4, balance: '¥900', consume: '¥1,600' },
{ id: 'c12', name: '褚女士', initial: '褚', avatarGradient: 'pink', heartEmoji: '🤍', score: '5.0', scoreColor: 'gray', serviceCount: 4, balance: '¥800', consume: '¥1,400' },
{ id: 'c13', name: '卫先生', initial: '卫', avatarGradient: 'amber', heartEmoji: '🤍', score: '4.8', scoreColor: 'gray', serviceCount: 3, balance: '¥700', consume: '¥1,200' },
{ id: 'c14', name: '蒋女士', initial: '蒋', avatarGradient: 'teal', heartEmoji: '🤍', score: '4.5', scoreColor: 'gray', serviceCount: 3, balance: '¥600', consume: '¥1,000' },
{ id: 'c15', name: '沈先生', initial: '沈', avatarGradient: 'blue', heartEmoji: '🤍', score: '4.2', scoreColor: 'gray', serviceCount: 3, balance: '¥500', consume: '¥900' },
{ id: 'c16', name: '韩女士', initial: '韩', avatarGradient: 'green', heartEmoji: '🤍', score: '4.0', scoreColor: 'gray', serviceCount: 2, balance: '¥400', consume: '¥800' },
{ id: 'c17', name: '杨先生', initial: '杨', avatarGradient: 'violet', heartEmoji: '🤍', score: '3.8', scoreColor: 'gray', serviceCount: 2, balance: '¥300', consume: '¥700' },
{ id: 'c18', name: '朱女士', initial: '朱', avatarGradient: 'pink', heartEmoji: '🤍', score: '3.5', scoreColor: 'gray', serviceCount: 2, balance: '¥200', consume: '¥600' },
{ id: 'c19', name: '秦先生', initial: '秦', avatarGradient: 'amber', heartEmoji: '🤍', score: '3.2', scoreColor: 'gray', serviceCount: 1, balance: '¥100', consume: '¥500' },
{ id: 'c20', name: '尤女士', initial: '尤', avatarGradient: 'teal', heartEmoji: '🤍', score: '3.0', scoreColor: 'gray', serviceCount: 1, balance: '¥0', consume: '¥400' },
]
const mockServiceRecords: ServiceRecord[] = [
{ customerId: 'c1', customerName: '王先生', initial: '王', avatarGradient: 'pink', type: '基础课', typeClass: 'basic', table: 'A12号台', duration: '2.5h', income: '¥200', date: '2026-02-07 21:30' },
{ customerId: 'c2', customerName: '李女士', initial: '李', avatarGradient: 'amber', type: '激励课', typeClass: 'incentive', table: 'VIP1号房', duration: '1.5h', income: '¥150', date: '2026-02-07 19:00', perfHours: '2h' },
{ customerId: 'c3', customerName: '陈女士', initial: '陈', avatarGradient: 'green', type: '基础课', typeClass: 'basic', table: '2号台', duration: '2h', income: '¥160', date: '2026-02-06 20:00' },
{ customerId: 'c4', customerName: '张先生', initial: '张', avatarGradient: 'blue', type: '激励课', typeClass: 'incentive', table: '5号台', duration: '1h', income: '¥80', date: '2026-02-05 14:00' },
]
const mockHistoryMonths: HistoryMonth[] = [
{ month: '本月', estimated: true, customers: '22人', hours: '87.5h', salary: '¥6,950', callbackDone: 14, recallDone: 24 },
{ month: '上月', estimated: false, customers: '25人', hours: '92.0h', salary: '¥7,200', callbackDone: 16, recallDone: 28 },
{ month: '4月', estimated: false, customers: '20人', hours: '85.0h', salary: '¥6,600', callbackDone: 12, recallDone: 22 },
{ month: '3月', estimated: false, customers: '18人', hours: '78.5h', salary: '¥6,100', callbackDone: 10, recallDone: 18 },
{ month: '2月', estimated: false, customers: '15人', hours: '65.0h', salary: '¥5,200', callbackDone: 8, recallDone: 15 },
]
Page({
data: {
/** 页面状态:四态 */
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 助教 ID */
coachId: '',
/** 助教详情 */
detail: null as CoachDetail | null,
/** 绩效指标卡片 */
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: 24, callback: 14 },
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() {
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)
},
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
// TODO: 替换为真实 API 调用 GET /api/coaches/:id
const basicCoach = mockCoaches.find((c) => c.id === id)
const detail: CoachDetail = basicCoach
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
: mockCoachDetail
if (!detail) {
this.setData({ pageState: 'empty' })
return
}
const perf = detail.performance
const perfCards = [
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}位客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '个', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
]
const perfGap = perf.perfTarget - perf.perfCurrent
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
// 进度条组件数据
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
const maxHours = 220
const totalHours = perf.monthlyHours
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
// 当前档位
let pbCurrentTier = 0
for (let i = 1; i < tierNodes.length; i++) {
if (totalHours >= tierNodes[i]) pbCurrentTier = i
else break
}
const sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
this.setData({
pageState: 'normal',
detail,
perfCards,
perfCurrent: perf.perfCurrent,
perfTarget: perf.perfTarget,
perfGap,
perfPercent,
visibleTasks: mockVisibleTasks,
hiddenTasks: mockHiddenTasks,
abandonedTasks: mockAbandonedTasks,
topCustomers: mockTopCustomers,
serviceRecords: mockServiceRecords,
historyMonths: mockHistoryMonths,
sortedNotes: sorted,
pbFilledPct,
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
pbCurrentTier,
pbTicks: buildTicks(tierNodes, maxHours),
pbShineDurMs: calcShineDur(pbFilledPct),
pbSparkDurMs: SPARK_DUR_MS,
})
this.switchIncomeTab('this')
// 数据加载完成后启动动画循环
setTimeout(() => this._startAnimLoop(), 300)
} catch (_e) {
this.setData({ pageState: 'error' })
}
}, 500)
},
/** 切换收入明细 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: `¥${total.toLocaleString()}`,
})
},
/** 点击收入 Tab */
onIncomeTabTap(e: WechatMiniprogram.CustomEvent) {
const tab = e.currentTarget.dataset.tab as 'this' | 'last'
this.switchIncomeTab(tab)
},
/** 展开/收起任务 */
onToggleTasks() {
this.setData({ tasksExpanded: !this.data.tasksExpanded })
},
/** 点击任务项 — 跳转客户详情 */
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
const name = e.currentTarget.dataset.name as string
if (!name) return
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
},
/** 展开/收起客户关系列表 */
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 })
},
/** 点击客户卡片 — 跳转客户详情 */
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({
url: `/pages/customer-detail/customer-detail?id=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 近期服务明细 — 点击跳转客户详情 */
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 查看更多服务记录 */
onViewMoreRecords() {
const coachId = this.data.coachId || this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/performance-records/performance-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' }),
})
},
})