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:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "任务",
"enablePullDownRefresh": true,
"usingComponents": {
"perf-progress-bar": "/components/perf-progress-bar/perf-progress-bar",
"heart-icon": "/components/heart-icon/heart-icon",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"ai-inline-icon": "/components/ai-inline-icon/ai-inline-icon",
"note-modal": "/components/note-modal/note-modal",
"abandon-modal": "/components/abandon-modal/abandon-modal",
"dev-fab": "/components/dev-fab/dev-fab",
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

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 })
},
})

View File

@@ -0,0 +1,412 @@
<!-- 任务列表页 — 2026-03-13 全量重写1:1 对齐 H5 原型 task-list.html -->
<!-- AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-13 | 重写 task-list 页面 1:1 还原 H5 原型 | 全量重写 WXML |
| 2026-03-13 | banner 错位重做 | 移除通用 banner 组件,页面内实现完整 banner |
| 2026-03-13 | 背景+纹理合并 SVG | 渐变+纹理+光晕合并为 SVG |
| 2026-03-13 | 5项修复+精确还原 | 恢复纹理CSS层、盖戳改回CSS实现、头像引用修复、abandoned标签恢复CSS灰化、全量line-height校准 |
-->
<view class="page-task-list">
<!-- ====== 顶部 Banner 区域 — 对齐 H5 .banner-bg.theme-blue.texture-aurora ====== -->
<!-- CHANGE 2026-03-13 | banner 背景SVG 做渐变底图 + CSS 做纹理叠加SVG pattern 在小程序中不渲染) -->
<view class="banner-area">
<image class="banner-bg-img" src="/assets/images/banner-bg-combined.svg" mode="aspectFill" />
<!-- 纹理层CSS repeating-linear-gradient 实现斜线网格SVG pattern 在小程序 image 中不生效 -->
<view class="banner-texture"></view>
<!-- 用户信息区 — H5: .px-5.pt-10.pb-3 -->
<view class="user-info-section">
<view class="user-info-row">
<!-- 头像 — H5: w-14 h-14 rounded-2xl bg-white/20 -->
<view class="avatar-wrap">
<image src="/assets/images/avatar-coach.png" mode="aspectFill" class="avatar-img" />
</view>
<!-- 姓名+标签+门店 -->
<view class="user-detail">
<view class="user-name-row">
<text class="user-name">{{userName}}</text>
<text class="user-role-tag">{{userRole}}</text>
</view>
<view class="user-store-row">
<text class="user-store">{{storeName}}</text>
</view>
</view>
</view>
</view>
<!-- 业绩进度卡片 — H5: .mx-4 > .bg-white/15.backdrop-blur-md.rounded-2xl -->
<view class="perf-card">
<!-- L1: 跳档提示 -->
<view class="perf-l1">
<view class="perf-l1-left">
<text class="perf-label">距离{{perfData.nextTierHours}}小时仅剩</text>
<text class="perf-accent">{{perfData.remainHours}}小时</text>
</view>
<view class="perf-l1-right" bindtap="onPerformanceTap">
<text class="perf-secondary">查看详情</text>
<t-icon name="chevron-right" size="22rpx" color="rgba(255,255,255,0.7)" />
</view>
</view>
<!-- L2: 5段档位进度条组件 -->
<view class="perf-l2">
<perf-progress-bar
filledPct="{{perfData.filledPct}}"
clampedSparkPct="{{perfData.clampedSparkPct}}"
currentTier="{{perfData.currentTier}}"
ticks="{{perfData.ticks}}"
shineRunning="{{perfData.shineRunning}}"
sparkRunning="{{perfData.sparkRunning}}"
shineDurMs="{{perfData.shineDurMs}}"
sparkDurMs="{{perfData.sparkDurMs}}"
/>
</view>
<!-- L3: 课时 + 红戳 + 奖金 -->
<view class="perf-l3">
<view class="perf-l3-left">
<view class="perf-hours-wrap">
<view class="perf-hours-row">
<text class="hours-green">{{perfData.basicHours}}</text>
<text class="hours-sep">|</text>
<text class="hours-yellow">{{perfData.bonusHours}}</text>
<text class="hours-sep">|</text>
<text class="hours-white">{{perfData.totalHours}}</text>
</view>
<view class="hours-label-row">
<text class="hours-label">基础课 | 激励课 | 全部</text>
</view>
<!-- 红戳徽章 — SVG 实现 -->
<image
class="stamp-badge {{stampAnimated ? 'stamp-animate' : ''}}"
wx:if="{{perfData.tierCompleted}}"
src="/assets/images/stamp-badge.svg"
/>
</view>
</view>
<view class="perf-l3-right">
<view class="bonus-wrap">
<text class="bonus-amount">{{perfData.bonusMoney}}</text>
<text class="bonus-unit">元</text>
</view>
<view class="bonus-label-row">
<text class="bonus-label">达{{perfData.nextTierHours}}h即得</text>
</view>
</view>
</view>
<!-- L4: 预计收入 -->
<view class="perf-l4">
<text class="perf-l4-label">{{perfData.incomeMonth}}预计收入 | 比{{perfData.prevMonth}}同期</text>
<view class="perf-l4-right" bindtap="onPerformanceTap">
<text class="income-value">¥{{perfData.incomeFormatted}}</text>
<text class="income-trend {{perfData.incomeTrendDir === 'down' ? 'trend-down' : ''}}">{{perfData.incomeTrend}}</text>
<t-icon name="chevron-right" size="28rpx" color="rgba(255,255,255,0.7)" />
</view>
</view>
</view>
</view>
<!-- ====== Loading 骨架屏 ====== -->
<view class="state-loading" wx:if="{{pageState === 'loading'}}">
<view class="loading-placeholder" wx:for="{{[1,2,3]}}" wx:key="*this">
<view class="ph-line ph-line--title"></view>
<view class="ph-line ph-line--body"></view>
<view class="ph-line ph-line--short"></view>
</view>
</view>
<!-- ====== 空状态 ====== -->
<view class="state-empty" wx:if="{{pageState === 'empty'}}">
<text class="empty-text">暂无待办任务</text>
</view>
<!-- ====== Error 状态 ====== -->
<view class="state-error" wx:if="{{pageState === 'error'}}">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" bindtap="onRetry">
<text class="retry-btn-text">重试</text>
</view>
</view>
<!-- ====== 任务列表区域 ====== -->
<view class="task-section" wx:if="{{pageState === 'normal'}}">
<!-- 标题行 -->
<view class="section-header">
<text class="section-title">今日 客户维护</text>
<text class="section-count">共 {{taskCount}} 项</text>
</view>
<!-- 📌 置顶区域 -->
<view class="task-group" wx:if="{{pinnedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--pinned">📌 置顶</text>
<text class="group-count">{{pinnedTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card {{item.isPinned ? 'task-card--pinned' : ''}} task-card--{{item.taskType}}"
wx:for="{{pinnedTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="pinned" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-2">
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 一般任务区域 -->
<view class="task-group" wx:if="{{normalTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--normal">正常任务</text>
<text class="group-count">{{normalTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card task-card--{{item.taskType}}"
wx:for="{{normalTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="normal" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-2">
<text class="visit-text">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 已放弃区域 -->
<view class="task-group" wx:if="{{abandonedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--abandoned">已放弃</text>
<text class="group-count">{{abandonedTasks.length}}项</text>
</view>
<view class="task-card-list">
<view
class="task-card task-card--abandoned"
wx:for="{{abandonedTasks}}" wx:key="id"
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
data-group="abandoned" data-index="{{index}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress"
>
<view class="card-body">
<view class="card-row-1">
<!-- CHANGE 2026-03-13 | abandoned 标签保留原始类型标签,通过 CSS 灰化(对齐 H5 行为) -->
<view class="task-type-tag task-type-tag--{{item.taskType}} task-type-tag--abandoned">
<text class="tag-text">{{item.taskTypeLabel}}</text>
</view>
<text class="customer-name customer-name--abandoned">{{item.customerName}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<view class="card-row-2">
<text class="visit-text visit-text--abandoned">最近到店:{{item.lastVisitDays}}天前 · 余额:{{item.balanceLabel}}</text>
</view>
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
<text class="abandon-reason">放弃原因:{{item.abandonReason}}</text>
</view>
</view>
<view class="card-arrow">
<t-icon name="chevron-right" size="36rpx" color="#c5c5c5" />
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" wx:if="{{!hasMore}}">
<text class="load-more-text">没有更多了</text>
</view>
</view>
<!-- ====== P3: 长按上下文菜单 ====== -->
<view class="ctx-overlay {{contextMenuVisible ? 'ctx-overlay--active' : ''}}" bindtap="onCloseContextMenu"></view>
<view class="ctx-menu {{contextMenuVisible ? 'ctx-menu--active' : ''}}"
style="left:{{contextMenuX}}px;top:{{contextMenuY}}px" catchtap="noop">
<!-- 已放弃任务:显示"取消放弃" -->
<block wx:if="{{contextMenuTarget.isAbandoned}}">
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxCancelAbandon">
<text class="ctx-emoji">↩️</text>
<text class="ctx-text">取消放弃</text>
</view>
</block>
<!-- 一般/置顶任务:显示标准菜单 -->
<block wx:else>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxPin">
<text class="ctx-emoji">📌</text>
<text class="ctx-text">{{contextMenuTarget.isPinned ? '取消置顶' : '置顶'}}</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxNote">
<text class="ctx-emoji">📝</text>
<text class="ctx-text">备注</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAI">
<text class="ctx-emoji">🤖</text>
<text class="ctx-text">问问AI助手</text>
</view>
<view class="ctx-item" hover-class="ctx-item--hover" bindtap="onCtxAbandon">
<text class="ctx-emoji">🗑️</text>
<text class="ctx-text">放弃任务</text>
</view>
</block>
</view>
<!-- ====== P4: 放弃弹窗 ====== -->
<abandon-modal
visible="{{abandonModalVisible}}"
customerName="{{abandonTarget.customerName || ''}}"
bind:confirm="onAbandonConfirm"
bind:cancel="onAbandonCancel"
/>
<!-- 备注弹窗 -->
<note-modal
visible="{{noteModalVisible}}"
customerName="{{noteTarget.customerName || ''}}"
showExpandBtn="{{true}}"
showRating="{{true}}"
bind:confirm="onNoteConfirm"
bind:cancel="onNoteCancel"
/>
<!-- AI 悬浮按钮 -->
<ai-float-button />
<!-- 开发调试 FAB -->
<dev-fab />
<!-- ====== 调试面板 ====== -->
<view class="debug-panel {{showDebugPanel ? 'debug-panel--visible' : ''}}" catchtap="noop">
<view class="debug-header">
<text class="debug-title">🔧 调试工具</text>
<view class="debug-close" bindtap="toggleDebugPanel" hover-class="debug-close--hover">
<t-icon name="close" size="32rpx" color="#5e5e5e" />
</view>
</view>
<!-- 课时进度控制 -->
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">📊 当前课时总量:</text>
<text class="debug-value-chip">{{debugTotalHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="220" step="1"
value="{{debugTotalHours}}"
bindchange="onDebugTotalHours"
activeColor="#10b981"
backgroundColor="#e7e7e7"
block-size="24"
/>
<view class="debug-tick-row">
<text class="debug-tick">0</text>
<text class="debug-tick debug-tick--key">100</text>
<text class="debug-tick debug-tick--key debug-tick--current">130</text>
<text class="debug-tick debug-tick--key">160</text>
<text class="debug-tick debug-tick--key">190</text>
<text class="debug-tick">220</text>
</view>
</view>
<!-- 基础 / 激励课时分配 -->
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">🟢 基础课时:</text>
<text class="debug-value-chip debug-chip--green">{{debugBasicHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="220" step="0.5"
value="{{debugBasicHours}}"
bindchange="onDebugBasicHours"
activeColor="#6ee7b7"
backgroundColor="#e7e7e7"
block-size="22"
/>
</view>
<view class="debug-section">
<view class="debug-label-row">
<text class="debug-label">🟡 激励课时:</text>
<text class="debug-value-chip debug-chip--yellow">{{debugBonusHours}}h</text>
</view>
<slider
class="debug-slider"
min="0" max="80" step="0.5"
value="{{debugBonusHours}}"
bindchange="onDebugBonusHours"
activeColor="#fbbf24"
backgroundColor="#e7e7e7"
block-size="22"
/>
</view>
<!-- 档位进度预设 -->
<view class="debug-section">
<text class="debug-label">🎯 快速预设档位:</text>
<view class="debug-btn-group">
<view class="debug-btn {{debugPreset === 0 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="0">未完成</view>
<view class="debug-btn {{debugPreset === 1 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="1">达100h</view>
<view class="debug-btn {{debugPreset === 2 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="2">达130h</view>
<view class="debug-btn {{debugPreset === 3 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="3">达160h</view>
<view class="debug-btn {{debugPreset === 4 ? 'debug-btn--active' : ''}}" bindtap="onDebugPreset" data-preset="4">满档220h</view>
</view>
</view>
</view>
<!-- 调试触发按钮 -->
<view class="debug-trigger {{showDebugPanel ? 'debug-trigger--active' : ''}}" bindtap="toggleDebugPanel" hover-class="debug-trigger--hover">
<text class="debug-trigger-icon">🔧</text>
</view>
</view>

File diff suppressed because it is too large Load Diff