feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs
This commit is contained in:
701
tmp/DEMO-miniprogram/miniprogram/pages/task-list/task-list.ts
Normal file
701
tmp/DEMO-miniprogram/miniprogram/pages/task-list/task-list.ts
Normal 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),需要走过的距离 = 填充条宽度 + 高光宽度
|
||||
* 轨道宽度约 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<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 })
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user