feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations

This commit is contained in:
Neo
2026-03-20 01:43:48 +08:00
parent 075caf067f
commit 79f9a0e1da
437 changed files with 118603 additions and 976 deletions

View File

@@ -0,0 +1,701 @@
/* 任务列表页 — 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需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 634rpx750 - 左右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<typeof setTimeout> | 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<string, EnrichedTask[]> = {
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 })
},
})