/* 任务列表页 — 2026-03-13 全量重写,1:1 对齐 H5 原型 task-list.html */ /* AI_CHANGELOG | 日期 | Prompt | 变更 | |------|--------|------| | 2026-03-13 | 重写 task-list 1:1 还原 H5 | 对齐 H5 四种任务类型、border-left 彩条颜色、圆形红戳、mock 数据贴近原型 | | 2026-03-13 | banner 错位重做 | 添加 userName/userRole/storeName 到 page data;移除 banner 组件引用 | | 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 | | 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap(标签恢复 CSS 渐变实现) | */ import { mockTasks, mockPerformance } from '../../utils/mock-data' import type { Task } from '../../utils/mock-data' import { formatMoney } from '../../utils/money' import { formatDeadline } from '../../utils/time' /** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail,由详情页根据 taskId 动态展示内容 */ const DETAIL_ROUTE = '/pages/task-detail/task-detail' /* ╔══════════════════════════════════════════════════════╗ * ║ 进度条动画参数 — 在此调节 ║ * ╚══════════════════════════════════════════════════════╝ * * 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度: * * ┌─────────────┐ SPARK_DELAY_MS ┌─────────────┐ NEXT_LOOP_DELAY_MS ┌─────────────┐ * │ 高光匀速扫过 │ ───────────────▶ │ 火花迸发 │ ──────────────────▶ │ 下一轮 │ * │ 时长由速度决定│ │ SPARK_DUR_MS│ │(重新读进度) │ * └─────────────┘ └─────────────┘ └─────────────┘ * * SHINE_SPEED : 高光移动速度,范围 1~100 * 1 = 最慢,最宽进度条(100%)下 5 秒走完 * 100 = 最快,最宽进度条(100%)下 0.05 秒走完 * 实际时长 = 基准时长 × (filledPct/100) * 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) × (5-0.05)s * * SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒) * 正数 = 高光结束后停顿再点亮 * 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠) * * SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒) * * NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒) * 正数 = 停顿一段时间 * 负数 = 火花还未消散完,高光已从左端启动 */ const SHINE_SPEED = 70 // 1~100,速度值 const SPARK_DELAY_MS = -200 // 毫秒,高光结束→光柱点亮+火花(负=提前) const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长 const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束→下轮高光(负=提前) /* 根据速度值和进度百分比计算高光时长 * 高光宽度固定(SHINE_WIDTH_RPX),需要走过的距离 = 填充条宽度 + 高光宽度 * 轨道宽度约 634rpx(750 - 左右padding各58rpx),高光宽度约占轨道 19% * 时长正比于需要走过的总距离,保证视觉速度恒定 * * 速度1 → baseDur=5000ms(最慢),速度100 → baseDur=50ms(最快) * shineDurMs = baseDur × (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT) */ const SHINE_WIDTH_RPX = 120 // rpx,需与 WXSS 的 --shine-width 保持一致 const TRACK_WIDTH_RPX = 634 // rpx,进度条轨道宽度(750 - padding 116rpx) const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ≈19% function calcShineDur(filledPct: number): number { const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快) const baseDur = 5000 - t * (5000 - 50) // ms,走完100%进度条所需时长 // 实际距离 = 填充条 + 高光自身,相对于(100% + 高光宽度%)归一化 const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT) return Math.max(50, Math.round(baseDur * distRatio)) } /** 扩展任务字段 */ interface EnrichedTask extends Task { lastVisitDays: number balanceLabel: string aiSuggestion: string abandonReason?: string isAbandoned: boolean deadlineLabel: string deadlineStyle: 'normal' | 'warning' | 'danger' | 'muted' } /** 刻度项 */ interface TickItem { value: number // 刻度数值(如 100) label: string // 显示文字(如 '100') left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段 highlight: boolean // 是否加粗高亮 } /** Mock: 根据档位节点数组生成刻度数据 */ 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, // 第3个档位(如130h)高亮,可由接口控制 })) } /** P0: 业绩进度卡片数据 */ interface PerfData { nextTierHours: number remainHours: number currentTier: number tierProgress: number filledPct: number clampedSparkPct: number ticks: TickItem[] // 刻度数组,由接口传入(Mock 时由 buildTicks 生成) shineDurMs: number sparkDurMs: number shineRunning: boolean sparkRunning: boolean basicHours: string bonusHours: string totalHours: string tierCompleted: boolean bonusMoney: string incomeMonth: string prevMonth: string incomeFormatted: string incomeTrend: string incomeTrendDir: 'up' | 'down' } /** Mock: 为任务附加扩展字段 */ function enrichTask(task: Task): EnrichedTask { const daysSeed = (task.id.charCodeAt(task.id.length - 1) % 15) + 1 const balanceSeedNum = ((task.id.charCodeAt(task.id.length - 1) * 137) % 5000) + 200 const suggestions = [ '建议推荐斯诺克进阶课程,提升客户粘性', '客户近期消费下降,建议电话关怀了解原因', '适合推荐周末球友赛活动,增强社交体验', '高价值客户,建议维护关系并推荐VIP权益', '新客户首次体验后未续费,建议跟进意向', ] const suggIdx = task.id.charCodeAt(task.id.length - 1) % suggestions.length return { ...task, lastVisitDays: daysSeed, balanceLabel: formatMoney(balanceSeedNum), aiSuggestion: suggestions[suggIdx], isAbandoned: task.status === 'abandoned', abandonReason: task.status === 'abandoned' ? '客户已转至其他门店' : undefined, deadlineLabel: formatDeadline((task as any).deadline).text, deadlineStyle: formatDeadline((task as any).deadline).style, } } /** Mock: 构造业绩进度卡片数据 — 对齐 H5 原型数值 */ function buildPerfData(): PerfData { const total = 87.5 const filledPct = Math.min(100, parseFloat(((total / 220) * 100).toFixed(1))) // Mock 档位节点:实际由接口返回,格式为 number[] const tierNodes = [0, 100, 130, 160, 190, 220] return { nextTierHours: 100, remainHours: 12.5, currentTier: 1, tierProgress: 58, filledPct, clampedSparkPct: Math.max(0, Math.min(100, filledPct)), ticks: buildTicks(tierNodes, 220), shineDurMs: calcShineDur(filledPct), sparkDurMs: SPARK_DUR_MS, shineRunning: false, sparkRunning: false, basicHours: '77.5', bonusHours: '12', totalHours: String(total), tierCompleted: true, bonusMoney: '800', incomeMonth: '2月', prevMonth: '1月', incomeFormatted: '6,206', incomeTrend: '↓368', incomeTrendDir: 'down', } } Page({ data: { pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal', pinnedTasks: [] as EnrichedTask[], normalTasks: [] as EnrichedTask[], abandonedTasks: [] as EnrichedTask[], taskCount: 0, bannerMetrics: [] as Array<{ label: string; value: string }>, bannerTitle: '', /* CHANGE 2026-03-13 | banner 重做:添加用户信息字段,对齐 H5 原型 */ userName: '小燕', userRole: '助教', storeName: '广州朗朗桌球', avatarUrl: '/assets/images/avatar-coach.png', // MOCK 头像地址 /* CHANGE 2026-03-13 | tagSvgMap 已移除,标签恢复 CSS 渐变实现 */ perfData: { nextTierHours: 0, remainHours: 0, currentTier: 0, tierProgress: 0, filledPct: 0, clampedSparkPct: 0, ticks: [], shineDurMs: 1000, sparkDurMs: 1400, shineRunning: false, sparkRunning: false, basicHours: '0', bonusHours: '0', totalHours: '0', tierCompleted: false, bonusMoney: '0', incomeMonth: '', prevMonth: '', incomeFormatted: '0', incomeTrend: '', incomeTrendDir: 'up' as 'up' | 'down', } as PerfData, stampAnimated: false, hasMore: true, // --- 调试面板 --- showDebugPanel: false, debugTotalHours: 87.5, debugBasicHours: 77.5, debugBonusHours: 12, debugPreset: -1, contextMenuVisible: false, contextMenuX: 0, contextMenuY: 0, contextMenuTarget: {} as EnrichedTask, abandonModalVisible: false, abandonTarget: {} as EnrichedTask, noteModalVisible: false, noteTarget: {} as EnrichedTask, aiColor: 'indigo', // 随机 AI 配色,页面加载时随机选取 }, _longPressed: false, _animTimer: null as ReturnType | null, onLoad() { const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] const aiColor = colors[Math.floor(Math.random() * colors.length)] this.setData({ aiColor }) this.loadData() }, onReady() { // 页面渲染完成后启动动画循环 setTimeout(() => { this.setData({ stampAnimated: true }) this._startAnimLoop() }, 100) }, onShow() { // 每次显示页面时重新随机 AI 配色,并恢复动画循环 const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] const aiColor = colors[Math.floor(Math.random() * colors.length)] this.setData({ aiColor }) const tabBar = this.getTabBar?.() if (tabBar) tabBar.setData({ active: 'task' }) // 若页面从后台恢复,重启动画循环 if (this.data.pageState === 'normal' && !this._animTimer) { this._startAnimLoop() } }, onHide() { // 页面不可见时停止动画,节省性能 this._stopAnimLoop() }, onUnload() { this._stopAnimLoop() }, /* ────────────────────────────────────────────────────── * JS 动画状态机 * 每轮流程: * 1. 读当前 filledPct,重新计算 shineDurMs * 2. 启动高光(shineRunning=true),等 shineDurMs * 3. 等 SPARK_DELAY_MS(可为负,即与高光末尾重叠) * 4. 启动火花(sparkRunning=true),等 SPARK_DUR_MS * 5. 停止火花(sparkRunning=false) * 6. 等 NEXT_LOOP_DELAY_MS(可为负,即提前启动下轮) * 7. 回到第1步 * ────────────────────────────────────────────────────── */ _startAnimLoop() { this._stopAnimLoop() // 防止重复启动 this._runAnimStep() }, _stopAnimLoop() { if (this._animTimer !== null) { clearTimeout(this._animTimer) this._animTimer = null } // 停止时重置动画状态 this.setData({ 'perfData.shineRunning': false, 'perfData.sparkRunning': false, }) }, _runAnimStep() { // 每轮开始时重新读当前进度,重新计算高光时长 const filledPct = this.data.perfData.filledPct ?? 0 const shineDurMs = calcShineDur(filledPct) console.log(`[动画] filledPct=${filledPct}% shineDurMs=${shineDurMs}ms`) // 阶段1:启动高光 this.setData({ 'perfData.shineRunning': true, 'perfData.sparkRunning': false, 'perfData.shineDurMs': shineDurMs, }) // 阶段2:高光结束后 + SPARK_DELAY_MS → 点亮光柱+火花 // 若 SPARK_DELAY_MS 为负,高光还未结束就提前点火 const sparkTriggerDelay = Math.max(0, shineDurMs + SPARK_DELAY_MS) this._animTimer = setTimeout(() => { // 阶段3:火花迸发 this.setData({ 'perfData.sparkRunning': true }) // 阶段4:火花持续 SPARK_DUR_MS 后熄灭 this._animTimer = setTimeout(() => { this.setData({ 'perfData.shineRunning': false, 'perfData.sparkRunning': false, }) // 阶段5:等 NEXT_LOOP_DELAY_MS 后启动下一轮 const nextDelay = Math.max(0, NEXT_LOOP_DELAY_MS) this._animTimer = setTimeout(() => { this._runAnimStep() }, nextDelay) }, SPARK_DUR_MS) }, sparkTriggerDelay) }, onPullDownRefresh() { this.loadData(() => { wx.stopPullDownRefresh() }) }, onReachBottom() { if (!this.data.hasMore) return this.setData({ hasMore: false }) wx.showToast({ title: '没有更多了', icon: 'none' }) }, loadData(cb?: () => void) { this.setData({ pageState: 'loading', stampAnimated: false }) setTimeout(() => { /* CHANGE 2026-03-13 | mock 数据贴近 H5 原型:7 条任务(2 置顶 + 3 一般 + 2 已放弃) */ const allTasks: Task[] = [ ...mockTasks, { id: 'task-007', customerName: '孙丽', customerAvatar: '/assets/images/avatar-default.png', taskType: 'callback', taskTypeLabel: '客户回访', deadline: '2026-03-06', heartScore: 3.5, hobbies: [], isPinned: false, hasNote: false, status: 'abandoned', }, ] const enriched = allTasks.map(enrichTask) const pinnedTasks = enriched.filter((t) => t.isPinned && !t.isAbandoned) const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && t.status === 'pending') const abandonedTasks = enriched.filter((t) => t.isAbandoned) const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length const perfData = buildPerfData() const perf = mockPerformance const bannerTitle = `${perf.currentTier}` const bannerMetrics: Array<{ label: string; value: string }> = [] this.setData({ pageState: totalCount > 0 ? 'normal' : 'empty', pinnedTasks, normalTasks, abandonedTasks, taskCount: totalCount, bannerTitle, bannerMetrics, perfData, hasMore: true, }) if (perfData.tierCompleted) { setTimeout(() => { this.setData({ stampAnimated: true }) }, 300) } cb?.() }, 600) }, onRetry() { this.loadData() }, onTaskTap(e: WechatMiniprogram.TouchEvent) { if (this._longPressed) { this._longPressed = false return } const { id } = e.currentTarget.dataset wx.navigateTo({ url: `${DETAIL_ROUTE}?id=${id}`, fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), }) }, onPerformanceTap() { wx.navigateTo({ url: '/pages/performance/performance', fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), }) }, onTaskLongPress(e: WechatMiniprogram.TouchEvent) { this._longPressed = true const { group, index } = e.currentTarget.dataset const groupKey = group as 'pinned' | 'normal' | 'abandoned' const groupMap: Record = { pinned: this.data.pinnedTasks, normal: this.data.normalTasks, abandoned: this.data.abandonedTasks, } const target = groupMap[groupKey]?.[index] if (!target) return const touch = e.touches[0] const sysInfo = wx.getSystemInfoSync() const menuW = 175 const menuH = 220 let x = touch.clientX let y = touch.clientY if (x + menuW > sysInfo.windowWidth - 16) x = sysInfo.windowWidth - menuW - 16 if (x < 16) x = 16 if (y + menuH > sysInfo.windowHeight - 16) y = sysInfo.windowHeight - menuH - 16 if (y < 16) y = 16 this.setData({ contextMenuVisible: true, contextMenuX: x, contextMenuY: y, contextMenuTarget: target, }) }, onCloseContextMenu() { this.setData({ contextMenuVisible: false }) }, noop() {}, onCtxPin() { const target = this.data.contextMenuTarget const isPinned = !target.isPinned wx.showToast({ title: isPinned ? `已置顶「${target.customerName}」` : `已取消置顶「${target.customerName}」`, icon: 'none', }) this.setData({ contextMenuVisible: false }) this._updateTaskPin(target.id, isPinned) }, onCtxNote() { const target = this.data.contextMenuTarget this.setData({ contextMenuVisible: false, noteModalVisible: true, noteTarget: target, }) }, onCtxAI() { const target = this.data.contextMenuTarget this.setData({ contextMenuVisible: false }) wx.navigateTo({ url: `/pages/ai-chat/ai-chat?taskId=${target.id}&customerName=${target.customerName}`, fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }), }) }, onCtxAbandon() { const target = this.data.contextMenuTarget this.setData({ contextMenuVisible: false, abandonModalVisible: true, abandonTarget: target, }) }, /** 放弃弹窗 - 确认 */ onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) { const { reason } = e.detail const target = this.data.abandonTarget wx.showToast({ title: `已放弃「${target.customerName}」`, icon: 'none' }) this.setData({ abandonModalVisible: false }) this._updateTaskAbandon(target.id, reason) }, /** 放弃弹窗 - 取消 */ onAbandonCancel() { this.setData({ abandonModalVisible: false }) }, /** 长按菜单 - 取消放弃(已放弃任务) */ onCtxCancelAbandon() { const target = this.data.contextMenuTarget this.setData({ contextMenuVisible: false }) wx.showLoading({ title: '处理中...' }) setTimeout(() => { wx.hideLoading() wx.showToast({ title: `已取消放弃「${target.customerName}」`, icon: 'success' }) this._updateTaskCancelAbandon(target.id) }, 500) }, onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) { const { score, content } = e.detail const target = this.data.noteTarget wx.showToast({ title: `已保存「${target.customerName}」备注`, icon: 'success' }) this.setData({ noteModalVisible: false }) console.log('[note]', target.id, score, content) }, onNoteCancel() { this.setData({ noteModalVisible: false }) }, _updateTaskPin(taskId: string, isPinned: boolean) { const allTasks = [ ...this.data.pinnedTasks, ...this.data.normalTasks, ...this.data.abandonedTasks, ].map((t) => (t.id === taskId ? { ...t, isPinned } : t)) this.setData({ pinnedTasks: allTasks.filter((t) => t.isPinned && !t.isAbandoned), normalTasks: allTasks.filter((t) => !t.isPinned && !t.isAbandoned), abandonedTasks: allTasks.filter((t) => t.isAbandoned), }) }, _updateTaskAbandon(taskId: string, reason: string) { const allTasks = [ ...this.data.pinnedTasks, ...this.data.normalTasks, ...this.data.abandonedTasks, ].map((t) => t.id === taskId ? { ...t, isAbandoned: true, isPinned: false, status: 'abandoned' as const, abandonReason: reason } : t, ) const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned) const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned) const abandonedTasks = allTasks.filter((t) => t.isAbandoned) this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length }) }, /** 切换调试面板 */ toggleDebugPanel() { this.setData({ showDebugPanel: !this.data.showDebugPanel }) }, /** 调试 - 拖动总课时滑块 */ onDebugTotalHours(e: WechatMiniprogram.SliderChange) { const total = e.detail.value this.setData({ debugTotalHours: total, debugPreset: -1 }) this._applyDebugHours(this.data.debugBasicHours, this.data.debugBonusHours, total) }, /** 调试 - 拖动基础课时滑块 */ onDebugBasicHours(e: WechatMiniprogram.SliderChange) { const basic = e.detail.value const total = basic + this.data.debugBonusHours this.setData({ debugBasicHours: basic, debugTotalHours: total, debugPreset: -1 }) this._applyDebugHours(basic, this.data.debugBonusHours, total) }, /** 调试 - 拖动激励课时滑块 */ onDebugBonusHours(e: WechatMiniprogram.SliderChange) { const bonus = e.detail.value const total = this.data.debugBasicHours + bonus this.setData({ debugBonusHours: bonus, debugTotalHours: total, debugPreset: -1 }) this._applyDebugHours(this.data.debugBasicHours, bonus, total) }, /** 调试 - 快速预设档位 */ onDebugPreset(e: WechatMiniprogram.BaseEvent) { const preset = e.currentTarget.dataset.preset as number const presets: Array<{ basic: number; bonus: number; total: number }> = [ { basic: 45, bonus: 5, total: 50 }, // 未完成 (段0中间) { basic: 90, bonus: 10, total: 100 }, // 恰好达100h { basic: 115, bonus: 15, total: 130 }, // 恰好达130h { basic: 145, bonus: 15, total: 160 }, // 恰好达160h { basic: 195, bonus: 25, total: 220 }, // 满档220h ] const p = presets[preset] if (!p) return this.setData({ debugBasicHours: p.basic, debugBonusHours: p.bonus, debugTotalHours: p.total, debugPreset: preset }) this._applyDebugHours(p.basic, p.bonus, p.total) }, /** 调试 - 切换盖戳动画 */ onDebugToggleStamp() { const completed = !this.data.perfData.tierCompleted this.setData({ 'perfData.tierCompleted': completed, stampAnimated: false, }) if (completed) { setTimeout(() => this.setData({ stampAnimated: true }), 50) } }, /** 内部:根据课时数值重新计算档位进度并更新 perfData */ _applyDebugHours(basic: number, bonus: number, total: number) { // 档位刻度:[0, 100, 130, 160, 190, 220] const tiers = [0, 100, 130, 160, 190, 220] let currentTier = 0 for (let i = 1; i < tiers.length; i++) { if (total >= tiers[i]) currentTier = i else break } // 当前段内进度百分比 const segStart = tiers[currentTier] const segEnd = tiers[currentTier + 1] ?? tiers[tiers.length - 1] const tierProgress = segEnd > segStart ? Math.min(100, Math.round(((total - segStart) / (segEnd - segStart)) * 100)) : 100 const nextTierHours = tiers[currentTier + 1] ?? 220 const remainHours = Math.max(0, nextTierHours - total) const tierCompleted = total >= 220 const filledPct = Math.min(100, Math.round((total / 220) * 1000) / 10) const tierNodes = [0, 100, 130, 160, 190, 220] // Mock,实际由接口返回 this.setData({ 'perfData.totalHours': String(total), 'perfData.basicHours': String(basic), 'perfData.bonusHours': String(bonus), 'perfData.currentTier': currentTier, 'perfData.tierProgress': tierProgress, 'perfData.filledPct': filledPct, 'perfData.clampedSparkPct': Math.max(0, Math.min(100, filledPct)), 'perfData.ticks': buildTicks(tierNodes, 220), 'perfData.shineDurMs': calcShineDur(filledPct), 'perfData.sparkDurMs': SPARK_DUR_MS, 'perfData.nextTierHours': nextTierHours, 'perfData.remainHours': remainHours, 'perfData.tierCompleted': tierCompleted, stampAnimated: false, }) // 进度变化后重启动画循环,使下一轮立即用新进度重新计算高光时长 this._startAnimLoop() if (tierCompleted) { setTimeout(() => this.setData({ stampAnimated: true }), 50) } }, /** 取消放弃任务 - 将任务从已放弃列表移出至一般任务 */ _updateTaskCancelAbandon(taskId: string) { const allTasks = [ ...this.data.pinnedTasks, ...this.data.normalTasks, ...this.data.abandonedTasks, ].map((t) => t.id === taskId ? { ...t, isAbandoned: false, isPinned: false, status: 'pending' as const, abandonReason: undefined } : t, ) const pinnedTasks = allTasks.filter((t) => t.isPinned && !t.isAbandoned) const normalTasks = allTasks.filter((t) => !t.isPinned && !t.isAbandoned) const abandonedTasks = allTasks.filter((t) => t.isAbandoned) this.setData({ pinnedTasks, normalTasks, abandonedTasks, taskCount: allTasks.length }) }, })