feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"navigationBarTitleText": "助教详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
|
||||
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
|
||||
"note-modal": "/components/note-modal/note-modal",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
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' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
<!-- 加载态(toast 浮层,不白屏) -->
|
||||
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
|
||||
<view class="g-toast-loading-inner">
|
||||
<t-loading theme="circular" size="40rpx" />
|
||||
<text class="g-toast-loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
|
||||
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
|
||||
<text class="empty-text">未找到助教信息</text>
|
||||
</view>
|
||||
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:elif="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败</text>
|
||||
<view class="retry-btn" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
|
||||
</view>
|
||||
|
||||
<!-- 正常态 -->
|
||||
<block wx:elif="{{pageState === 'normal'}}">
|
||||
<!-- Banner 区域 -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-overlay">
|
||||
<view class="coach-header">
|
||||
<view class="avatar-box">
|
||||
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="info-middle">
|
||||
<view class="name-row">
|
||||
<text class="coach-name">{{detail.name}}</text>
|
||||
<coach-level-tag level="{{detail.level}}" />
|
||||
</view>
|
||||
<view class="skill-row">
|
||||
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-right-stats">
|
||||
<view class="right-stat">
|
||||
<text class="right-stat-label">工龄</text>
|
||||
<text class="right-stat-value">{{detail.workYears}}</text>
|
||||
<text class="right-stat-label">年</text>
|
||||
</view>
|
||||
<view class="right-stat">
|
||||
<text class="right-stat-label">客户</text>
|
||||
<text class="right-stat-value">{{detail.customerCount}}</text>
|
||||
<text class="right-stat-label">人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-content">
|
||||
|
||||
<!-- 绩效概览 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-blue">绩效概览</text>
|
||||
</view>
|
||||
<view class="perf-grid">
|
||||
<view class="perf-card {{item.bgClass}}" wx:for="{{perfCards}}" wx:key="label">
|
||||
<text class="perf-label">{{item.label}}</text>
|
||||
<view class="perf-value-row">
|
||||
<text class="perf-value {{item.valueColor}}">{{item.value}}</text>
|
||||
<text class="perf-unit" wx:if="{{item.unit}}">{{item.unit}}</text>
|
||||
</view>
|
||||
<text class="perf-sub">{{item.sub}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="perf-progress-box">
|
||||
<view class="perf-progress-header">
|
||||
<text class="perf-progress-label">绩效档位进度</text>
|
||||
<text class="perf-progress-hint">距下一档还差 {{perfGap}}h</text>
|
||||
</view>
|
||||
<perf-progress-bar
|
||||
filledPct="{{pbFilledPct}}"
|
||||
clampedSparkPct="{{pbClampedSparkPct}}"
|
||||
currentTier="{{pbCurrentTier}}"
|
||||
ticks="{{pbTicks}}"
|
||||
shineRunning="{{pbShineRunning}}"
|
||||
sparkRunning="{{pbSparkRunning}}"
|
||||
shineDurMs="{{pbShineDurMs}}"
|
||||
sparkDurMs="{{pbSparkDurMs}}"
|
||||
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收入明细 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-green">收入明细</text>
|
||||
<view class="income-tabs">
|
||||
<view class="income-tab {{incomeTab === 'this' ? 'active' : ''}}" data-tab="this" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
|
||||
<text>本月</text>
|
||||
<text class="income-tab-est" wx:if="{{incomeTab === 'this'}}">预估</text>
|
||||
</view>
|
||||
<view class="income-tab {{incomeTab === 'last' ? 'active' : ''}}" data-tab="last" bindtap="onIncomeTabTap" hover-class="income-tab--hover">
|
||||
<text>上月</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="income-list">
|
||||
<view class="income-item" wx:for="{{currentIncome}}" wx:key="label">
|
||||
<view class="income-dot dot-{{item.color}}"></view>
|
||||
<text class="income-label">{{item.label}}</text>
|
||||
<text class="income-amount">{{item.amount}}</text>
|
||||
</view>
|
||||
<view class="income-total">
|
||||
<text class="income-total-label">合计{{incomeTab === 'this' ? '(预估)' : ''}}</text>
|
||||
<text class="income-total-value">{{incomeTotal}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务执行 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">任务执行</text>
|
||||
<view class="task-summary">
|
||||
<text class="task-summary-label">本月完成</text>
|
||||
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}</text>个</text>
|
||||
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text>个</text>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-list">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
<t-icon name="chat" size="32rpx" color="#777777" />
|
||||
<text class="task-note-count">{{item.noteCount}}</text>
|
||||
</view>
|
||||
<text class="task-pin" wx:if="{{item.pinned}}">📌</text>
|
||||
</view>
|
||||
</view>
|
||||
<block wx:if="{{tasksExpanded}}">
|
||||
<view class="task-list task-list-extra">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
<t-icon name="chat" size="32rpx" color="#777777" />
|
||||
<text class="task-note-count">{{item.noteCount}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-item task-item-abandoned" wx:for="{{abandonedTasks}}" wx:key="index">
|
||||
<text class="task-abandoned-name">{{item.customerName}}</text>
|
||||
<text class="task-abandoned-reason">{{item.reason}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="task-toggle" bindtap="onToggleTasks" hover-class="task-toggle--hover" wx:if="{{hiddenTasks.length > 0 || abandonedTasks.length > 0}}">
|
||||
<text>{{tasksExpanded ? '收起 ↑' : '展开全部 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 客户关系 TOP20 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-pink">客户关系 TOP20</text>
|
||||
<text class="header-hint">近60天</text>
|
||||
</view>
|
||||
<view class="top-customer-list">
|
||||
<view
|
||||
class="top-customer-item"
|
||||
hover-class="top-customer-item--hover"
|
||||
wx:for="{{topCustomers}}"
|
||||
wx:key="id"
|
||||
wx:if="{{topCustomersExpanded || index < 5}}"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="onCustomerTap"
|
||||
>
|
||||
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
|
||||
<text class="top-customer-avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
<view class="top-customer-info">
|
||||
<view class="top-customer-name-row">
|
||||
<text class="top-customer-name">{{item.name}}</text>
|
||||
<text class="top-customer-heart">{{item.heartEmoji}}</text>
|
||||
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{item.score}}</text>
|
||||
</view>
|
||||
<view class="top-customer-stats">
|
||||
<text class="top-customer-stat">服务 <text class="top-customer-stat-val">{{item.serviceCount}}</text>次</text>
|
||||
<text class="top-customer-stat">储值 <text class="top-customer-stat-val">{{item.balance}}</text></text>
|
||||
<text class="top-customer-stat">消费 <text class="top-customer-stat-val">{{item.consume}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="toggle-btn" bindtap="onToggleTopCustomers" hover-class="toggle-btn--hover" wx:if="{{topCustomers.length > 5}}">
|
||||
<text>{{topCustomersExpanded ? '收起 ↑' : '查看更多 ↓'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近期服务明细 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-purple">近期服务明细</text>
|
||||
</view>
|
||||
<view class="svc-list">
|
||||
<view class="svc-card" wx:for="{{serviceRecords}}" wx:key="index"
|
||||
bindtap="onSvcCardTap" data-id="{{item.customerId}}" hover-class="svc-card--hover">
|
||||
<!-- 头像列 -->
|
||||
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
|
||||
<text class="top-customer-avatar-text">{{item.initial}}</text>
|
||||
</view>
|
||||
<!-- 右侧内容列:两行垂直排列 -->
|
||||
<view class="svc-content">
|
||||
<!-- 第1行:客户名 + 类型标签 + 日期 -->
|
||||
<view class="svc-row1">
|
||||
<text class="svc-customer">{{item.customerName}}</text>
|
||||
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
|
||||
<text class="svc-date">{{item.date}}</text>
|
||||
</view>
|
||||
<!-- 第2行:台号 + 时长 + 绩效 + 收入 -->
|
||||
<view class="svc-row2">
|
||||
<view class="svc-row2-left">
|
||||
<text class="svc-table-tag">{{item.table}}</text>
|
||||
<text class="svc-duration">{{item.duration}}</text>
|
||||
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
|
||||
</view>
|
||||
<text class="svc-income">{{item.income}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="svc-more" bindtap="onViewMoreRecords" hover-class="svc-more--hover">
|
||||
<text>查看更多服务记录 →</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多信息 -->
|
||||
<view class="card">
|
||||
<view class="card-title-row">
|
||||
<text class="section-title title-teal">更多信息</text>
|
||||
</view>
|
||||
<view class="more-info-row">
|
||||
<text class="more-info-label">入职日期</text>
|
||||
<text class="more-info-value">{{detail.hireDate}}</text>
|
||||
</view>
|
||||
<view class="history-table">
|
||||
<view class="history-thead">
|
||||
<text class="history-th history-th-left">月份</text>
|
||||
<text class="history-th">服务客户</text>
|
||||
<text class="history-th">访/召完成</text>
|
||||
<text class="history-th">业绩时长</text>
|
||||
<text class="history-th">工资</text>
|
||||
</view>
|
||||
<view class="history-row {{index === 0 ? 'history-row-current' : ''}}" wx:for="{{historyMonths}}" wx:key="month">
|
||||
<view class="history-td history-td-left">
|
||||
<text>{{item.month}}</text>
|
||||
<text class="history-est" wx:if="{{item.estimated}}">预估</text>
|
||||
</view>
|
||||
<text class="history-td">{{item.customers}}</text>
|
||||
<text class="history-td">{{item.callbackDone}} | {{item.recallDone}}</text>
|
||||
<text class="history-td {{index === 0 ? 'text-primary' : ''}}">{{item.hours}}</text>
|
||||
<text class="history-td {{index === 0 ? 'text-success' : ''}}">{{item.salary}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注列表 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-orange">备注记录</text>
|
||||
<text class="header-hint">共 {{sortedNotes.length}} 条</text>
|
||||
</view>
|
||||
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
|
||||
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
|
||||
<view class="note-top">
|
||||
<text class="note-author">{{item.tagLabel}}</text>
|
||||
<text class="note-time">{{item.createdAt}}</text>
|
||||
</view>
|
||||
<text class="note-content">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="note-empty" wx:else>
|
||||
<t-icon name="edit-1" size="80rpx" color="#dcdcdc" />
|
||||
<text class="empty-hint">暂无备注</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar safe-area-bottom">
|
||||
<view class="btn-chat" bindtap="onStartChat" hover-class="btn-chat--hover">
|
||||
<t-icon name="chat" size="36rpx" color="#ffffff" />
|
||||
<text>问问助手</text>
|
||||
</view>
|
||||
<view class="btn-note" bindtap="onAddNote" hover-class="btn-note--hover">
|
||||
<t-icon name="edit-1" size="36rpx" color="#242424" />
|
||||
<text>备注</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
|
||||
|
||||
<!-- 备注列表弹窗 -->
|
||||
<view class="notes-popup-overlay" wx:if="{{notesPopupVisible}}" catchtap="onHideNotesPopup">
|
||||
<view class="notes-popup" catchtap="">
|
||||
<view class="notes-popup-header">
|
||||
<text class="notes-popup-title">{{notesPopupName}} 的备注</text>
|
||||
<view class="notes-popup-close" bindtap="onHideNotesPopup" hover-class="notes-popup-close--hover">
|
||||
<t-icon name="close" size="40rpx" color="#8b8b8b" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="notes-popup-list">
|
||||
<view class="notes-popup-item" wx:for="{{notesPopupList}}" wx:key="index">
|
||||
<view class="notes-popup-item-top">
|
||||
<text wx:if="{{item.pinned}}">📌</text>
|
||||
<text class="notes-popup-item-date">{{item.date}}</text>
|
||||
</view>
|
||||
<text class="notes-popup-item-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user