feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
- 新增 GET /xcx/coaches/{id}/banner 轻量接口
- performance/records 加 coach_id 参数 + view_board_coach 权限分流
- coach/customer/performance/board/task 服务层重构
- fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
- task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
- recall_detector settle_type=3 双重限制 + 门店级 resolved
主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
- perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
- isScattered 散客标记端到端
- foodDetail/phoneFull/creator* 字段透传
主线 3: P19 指数回测框架 Phase 1+2
- 3 个指数表 stat_date 日快照模式
- 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
- task_engine 升级 HTTP 实时 + 推演回测双模式
主线 4: Core 维度层启用
- 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
- 修复 app 视图空查询问题
主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口
主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
- schema 基线与 DDL 快照同步
主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)
附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具
合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"pages/customer-service-records/customer-service-records",
|
||||
"pages/customer-records/customer-records",
|
||||
"pages/coach-detail/coach-detail",
|
||||
"pages/coach-service-records/coach-service-records",
|
||||
"pages/chat/chat",
|
||||
"pages/chat-history/chat-history",
|
||||
"pages/dev-tools/dev-tools"
|
||||
|
||||
@@ -255,6 +255,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.ppb-tick--highlight {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: var(--ppb-tick-highlight-color, rgba(255, 255, 255, 0.85));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@ Page({
|
||||
svAmountLabel: formatMoney(c.svAmount ?? 0),
|
||||
svCustomerCountLabel: formatCount(c.svCustomerCount ?? 0, '人'),
|
||||
svConsumeLabel: formatMoney(c.svConsume ?? 0),
|
||||
taskRecallLabel: formatCount(c.taskRecall ?? 0, '次'),
|
||||
taskCallbackLabel: formatCount(c.taskCallback ?? 0, '次'),
|
||||
taskRecallLabel: `${c.taskRecall ?? 0}次`,
|
||||
taskCallbackLabel: `${c.taskCallback ?? 0}次`,
|
||||
}))
|
||||
|
||||
// 追加时按 id 去重,避免 wx:key 重复警告
|
||||
|
||||
@@ -8,40 +8,12 @@ import { fetchCoachDetail } from '../../services/api'
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
import { formatHours } from '../../utils/time'
|
||||
import { sortByTimestamp } from '../../utils/sort'
|
||||
|
||||
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
|
||||
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
|
||||
*/
|
||||
const SHINE_SPEED = 70
|
||||
const SPARK_DELAY_MS = -150
|
||||
const SPARK_DUR_MS = 1400
|
||||
const NEXT_LOOP_DELAY_MS = 400
|
||||
const SHINE_WIDTH_RPX = 120
|
||||
const TRACK_WIDTH_RPX = 634
|
||||
const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100
|
||||
|
||||
function calcShineDur(filledPct: number): number {
|
||||
const t = (SHINE_SPEED - 1) / 99
|
||||
const baseDur = 5000 - t * (5000 - 50)
|
||||
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
return Math.max(50, Math.round(baseDur * distRatio))
|
||||
}
|
||||
|
||||
interface TickItem {
|
||||
value: number
|
||||
label: string
|
||||
left: string
|
||||
highlight: boolean
|
||||
}
|
||||
|
||||
function buildTicks(tierNodes: number[], maxHours: number): TickItem[] {
|
||||
return tierNodes.map((v, i) => ({
|
||||
value: v,
|
||||
label: String(v),
|
||||
left: `${Math.round((v / maxHours) * 10000) / 100}%`,
|
||||
highlight: i === 2,
|
||||
}))
|
||||
}
|
||||
import { nameToAvatarColor } from '../../utils/avatar-color'
|
||||
import {
|
||||
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
|
||||
calcShineDur, buildProgressBarData,
|
||||
type TickItem,
|
||||
} from '../../utils/perf-progress'
|
||||
|
||||
/** 助教详情(含绩效、收入、任务、客户关系等) */
|
||||
interface CoachDetail {
|
||||
@@ -54,18 +26,33 @@ interface CoachDetail {
|
||||
customerCount: number
|
||||
hireDate: string
|
||||
performance: {
|
||||
monthlyHours: number
|
||||
monthlySalary: number
|
||||
// 核心绩效字段(与任务页 PerformanceSummary 一致)
|
||||
totalHours: number
|
||||
totalIncome: number
|
||||
totalCustomers: number
|
||||
tierNodes: number[]
|
||||
basicHours: number
|
||||
bonusHours: number
|
||||
currentTier: number
|
||||
nextTierHours: number
|
||||
tierCompleted: boolean
|
||||
bonusMoney: number
|
||||
incomeTrend: string
|
||||
incomeTrendDir: string
|
||||
currentTierLabel: string
|
||||
// 助教详情专属扩展
|
||||
customerBalance: number
|
||||
tasksCompleted: number
|
||||
/** 绩效档位 */
|
||||
perfCurrent: number
|
||||
perfTarget: number
|
||||
// 兼容旧字段
|
||||
monthlyHours: number
|
||||
monthlySalary: number
|
||||
}
|
||||
income: {
|
||||
thisMonth: IncomeItem[]
|
||||
lastMonth: IncomeItem[]
|
||||
}
|
||||
taskStats: { callback: number; recall: number }
|
||||
tierNodes: number[]
|
||||
notes: NoteItem[]
|
||||
}
|
||||
|
||||
@@ -147,17 +134,30 @@ const mockCoachDetail: CoachDetail = {
|
||||
customerCount: 0,
|
||||
hireDate: '',
|
||||
performance: {
|
||||
monthlyHours: 0,
|
||||
monthlySalary: 0,
|
||||
totalHours: 0,
|
||||
totalIncome: 0,
|
||||
totalCustomers: 0,
|
||||
tierNodes: [],
|
||||
basicHours: 0,
|
||||
bonusHours: 0,
|
||||
currentTier: 0,
|
||||
nextTierHours: 0,
|
||||
tierCompleted: false,
|
||||
bonusMoney: 0,
|
||||
incomeTrend: '',
|
||||
incomeTrendDir: 'up',
|
||||
currentTierLabel: '',
|
||||
customerBalance: 0,
|
||||
tasksCompleted: 0,
|
||||
perfCurrent: 0,
|
||||
perfTarget: 0,
|
||||
monthlyHours: 0,
|
||||
monthlySalary: 0,
|
||||
},
|
||||
income: {
|
||||
thisMonth: [],
|
||||
lastMonth: [],
|
||||
},
|
||||
taskStats: { callback: 0, recall: 0 },
|
||||
tierNodes: [],
|
||||
notes: [],
|
||||
}
|
||||
|
||||
@@ -197,6 +197,8 @@ Page({
|
||||
coachId: '',
|
||||
/** 助教详情 */
|
||||
detail: null as CoachDetail | null,
|
||||
/** 等级标签背景色(助教详情页专属,适配深色背景) */
|
||||
levelBgColor: '',
|
||||
/** 绩效指标卡片 */
|
||||
perfCards: [] as Array<{ label: string; value: string; unit: string; sub: string; bgClass: string; valueColor: string }>,
|
||||
/** 绩效进度 */
|
||||
@@ -307,52 +309,67 @@ Page({
|
||||
}
|
||||
|
||||
const perf = d.performance || {} as any
|
||||
// 统一使用 totalHours(来自 monthly_summary 实时值),与任务页一致
|
||||
const totalHours = perf.totalHours ?? perf.total_hours ?? perf.monthlyHours ?? 0
|
||||
const perfCards = [
|
||||
{ label: '本月定档业绩', value: formatHours(perf.monthlyHours ?? 0), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
|
||||
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
|
||||
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
|
||||
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
|
||||
{ label: '本月定档业绩', value: formatHours(totalHours), unit: '', sub: '', bgClass: 'perf-blue', valueColor: 'text-primary' },
|
||||
{ label: '本月工资(预估)', value: formatMoney(perf.monthlySalary ?? perf.totalIncome ?? perf.total_income ?? 0), unit: '', sub: '', bgClass: 'perf-green', valueColor: 'text-success' },
|
||||
{ label: '客源储值余额', value: formatMoney(perf.customerBalance ?? perf.customer_balance ?? 0), unit: '', sub: `${formatCount(d.customerCount ?? 0, '位')}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
|
||||
{ label: '本月任务完成', value: formatCount(perf.tasksCompleted ?? perf.tasks_completed ?? 0, '个') || '--', unit: '', sub: '', bgClass: 'perf-purple', valueColor: 'text-purple' },
|
||||
]
|
||||
|
||||
const perfGap = (perf.perfTarget ?? 0) - (perf.perfCurrent ?? 0)
|
||||
const perfPercent = perf.perfTarget > 0 ? Math.min(Math.round(((perf.perfCurrent ?? 0) / perf.perfTarget) * 100), 100) : 0
|
||||
// 统一使用共用模块计算进度条数据(与任务页相同逻辑)
|
||||
const pbData = buildProgressBarData(perf)
|
||||
|
||||
// 档位节点从 API 返回,fallback [0, 120, 150, 180, 210]
|
||||
const tierNodes = d.tierNodes && d.tierNodes.length > 0 ? d.tierNodes : [0, 120, 150, 180, 210]
|
||||
const maxHours = tierNodes[tierNodes.length - 1] || 210
|
||||
const totalHours = perf.monthlyHours ?? 0
|
||||
const pbFilledPct = Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10)
|
||||
let pbCurrentTier = 0
|
||||
for (let i = 1; i < tierNodes.length; i++) {
|
||||
if (totalHours >= tierNodes[i]) pbCurrentTier = i
|
||||
else break
|
||||
}
|
||||
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
|
||||
const perfGap = Math.max(0, nextTierHours - totalHours)
|
||||
const tierNodes = perf.tierNodes ?? perf.tier_nodes ?? [0]
|
||||
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
|
||||
const perfPercent = maxHours > 0 ? Math.min(Math.round((totalHours / maxHours) * 100), 100) : 0
|
||||
|
||||
const sorted = sortByTimestamp(d.notes || [], 'timestamp') as NoteItem[]
|
||||
const taskStats = d.taskStats ?? { recall: 0, callback: 0 }
|
||||
|
||||
// 等级标签背景色(助教详情页深色背景专用)
|
||||
const level = (d as any).level || ''
|
||||
const levelBgMap: Record<string, string> = {
|
||||
'junior': '#E5EDFB', '初级': '#E5EDFB',
|
||||
'middle': '#FDEFE5', '中级': '#FDEFE5',
|
||||
'senior': '#FDE3EC', '高级': '#FDE3EC',
|
||||
}
|
||||
const levelBgColor = levelBgMap[level] || ''
|
||||
|
||||
this.setData({
|
||||
pageState: 'normal',
|
||||
detail: d,
|
||||
levelBgColor,
|
||||
perfCards,
|
||||
perfCurrent: perf.perfCurrent ?? 0,
|
||||
perfTarget: perf.perfTarget ?? 0,
|
||||
perfCurrent: totalHours,
|
||||
perfTarget: nextTierHours,
|
||||
perfGap,
|
||||
perfPercent,
|
||||
taskStats,
|
||||
visibleTasks: d.visibleTasks || [],
|
||||
hiddenTasks: d.hiddenTasks || [],
|
||||
abandonedTasks: d.abandonedTasks || [],
|
||||
topCustomers: d.topCustomers || [],
|
||||
serviceRecords: d.serviceRecords || [],
|
||||
topCustomers: (d.topCustomers || [])
|
||||
.map((c: any) => ({
|
||||
...c,
|
||||
avatarGradient: nameToAvatarColor(String(c.id || '')),
|
||||
}))
|
||||
.sort((a: any, b: any) => parseFloat(b.score || 0) - parseFloat(a.score || 0)),
|
||||
serviceRecords: (d.serviceRecords || []).map((r: any) => ({
|
||||
...r,
|
||||
avatarGradient: nameToAvatarColor(String(r.customerId || '')),
|
||||
})),
|
||||
historyMonths: d.historyMonths || [],
|
||||
sortedNotes: sorted,
|
||||
pbFilledPct,
|
||||
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
|
||||
pbCurrentTier,
|
||||
pbTicks: buildTicks(tierNodes, maxHours),
|
||||
pbShineDurMs: calcShineDur(pbFilledPct),
|
||||
pbSparkDurMs: SPARK_DUR_MS,
|
||||
pbFilledPct: pbData.filledPct,
|
||||
pbClampedSparkPct: pbData.clampedSparkPct,
|
||||
pbCurrentTier: pbData.currentTier,
|
||||
pbTicks: pbData.ticks,
|
||||
pbShineDurMs: pbData.shineDurMs,
|
||||
pbSparkDurMs: pbData.sparkDurMs,
|
||||
})
|
||||
|
||||
this.switchIncomeTab('this')
|
||||
@@ -394,11 +411,18 @@ Page({
|
||||
this.setData({ tasksExpanded: !this.data.tasksExpanded })
|
||||
},
|
||||
|
||||
/** 点击任务项 — 跳转客户详情 */
|
||||
/** 点击任务项 — 跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
|
||||
onTaskItemTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const name = e.currentTarget.dataset.name as string
|
||||
if (!name) return
|
||||
wx.navigateTo({ url: `/pages/customer-detail/customer-detail?name=${encodeURIComponent(name)}` })
|
||||
const { id } = e.currentTarget.dataset
|
||||
const cid = Number(id)
|
||||
if (!cid || cid <= 0) {
|
||||
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail?id=${cid}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 展开/收起客户关系列表 */
|
||||
@@ -432,29 +456,39 @@ Page({
|
||||
this.setData({ notesPopupVisible: false })
|
||||
},
|
||||
|
||||
/** 点击客户卡片 — 跳转客户详情 */
|
||||
/** 点击客户卡片 — 跳转客户详情(散客 id ≤ 0 时无详情可看) */
|
||||
onCustomerTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
const { id } = e.currentTarget.dataset
|
||||
const cid = Number(id)
|
||||
if (!cid || cid <= 0) {
|
||||
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail?id=${id}`,
|
||||
url: `/pages/customer-detail/customer-detail?id=${cid}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 近期服务明细 — 点击跳转客户详情 */
|
||||
/** 近期服务明细 — 点击跳转客户详情(散客 customer_id ≤ 0 时无详情可看) */
|
||||
onSvcCardTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const id = e.currentTarget.dataset.id as string
|
||||
const { id } = e.currentTarget.dataset
|
||||
const cid = Number(id)
|
||||
if (!cid || cid <= 0) {
|
||||
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail${id ? '?id=' + id : ''}`,
|
||||
url: `/pages/customer-detail/customer-detail?id=${cid}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
/** 查看更多服务记录 */
|
||||
/** 查看更多服务记录 → 跳"助教业绩明细"页(管理者视角,独立于任务 tab 自查页) */
|
||||
onViewMoreRecords() {
|
||||
const coachId = this.data.coachId || this.data.detail?.id || ''
|
||||
wx.navigateTo({
|
||||
url: `/pages/performance-records/performance-records?coachId=${coachId}`,
|
||||
url: `/pages/coach-service-records/coach-service-records?coachId=${coachId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<view class="info-middle">
|
||||
<view class="name-row">
|
||||
<text class="coach-name">{{fmt.safe(detail.name)}}</text>
|
||||
<coach-level-tag level="{{detail.level}}" />
|
||||
<coach-level-tag level="{{detail.level}}" bgColor="{{levelBgColor}}" shadowColor="transparent" />
|
||||
</view>
|
||||
<view class="skill-row">
|
||||
<text class="skill-tag" wx:for="{{detail.skills}}" wx:key="index">{{item}}</text>
|
||||
@@ -79,7 +79,7 @@
|
||||
sparkRunning="{{pbSparkRunning}}"
|
||||
shineDurMs="{{pbShineDurMs}}"
|
||||
sparkDurMs="{{pbSparkDurMs}}"
|
||||
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6);"
|
||||
style="--ppb-track-bg-color: rgba(59,130,246,0.25); --ppb-divider-color: rgba(255,255,255,1); --ppb-tick-done-color: #60a5fa; --ppb-tick-color: rgba(147,197,253,0.6); --ppb-tick-highlight-color: #3b82f6;"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -117,14 +117,14 @@
|
||||
<text class="section-title title-orange">任务执行</text>
|
||||
<view class="task-summary">
|
||||
<text class="task-summary-label">本月完成</text>
|
||||
<text class="task-summary-callback">回访<text class="task-summary-num">{{fmt.count(taskStats.callback, '个')}}</text></text>
|
||||
<text class="task-summary-recall">召回<text class="task-summary-num">{{fmt.count(taskStats.recall, '个')}}</text></text>
|
||||
<text class="task-summary-callback">回访<text class="task-summary-num">{{taskStats.callback}}个</text></text>
|
||||
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}个</text></text>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-list">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
@@ -137,7 +137,7 @@
|
||||
<block wx:if="{{tasksExpanded}}">
|
||||
<view class="task-list task-list-extra">
|
||||
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{hiddenTasks}}" wx:key="index"
|
||||
bindtap="onTaskItemTap" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
bindtap="onTaskItemTap" data-id="{{item.customerId}}" data-name="{{item.customerName}}" hover-class="task-item--hover">
|
||||
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
|
||||
<text class="task-customer-name">{{item.customerName}}</text>
|
||||
<view class="task-note-btn" wx:if="{{item.noteCount > 0}}" catchtap="onTaskNoteTap" data-hidden-index="{{index}}" hover-class="task-note-btn--hover">
|
||||
@@ -177,7 +177,7 @@
|
||||
</view>
|
||||
<view class="top-customer-info">
|
||||
<view class="top-customer-name-row">
|
||||
<text class="top-customer-name">{{item.name}}</text>
|
||||
<text class="top-customer-name {{item.isScattered ? 'top-customer-name--scattered' : ''}}">{{item.name}}</text>
|
||||
<text class="top-customer-heart">{{item.heartEmoji}}</text>
|
||||
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{fmt.safe(item.score)}}</text>
|
||||
</view>
|
||||
@@ -211,14 +211,14 @@
|
||||
<view class="svc-content">
|
||||
<!-- 第1行:客户名 + 类型标签 + 日期 -->
|
||||
<view class="svc-row1">
|
||||
<text class="svc-customer">{{item.customerName}}</text>
|
||||
<text class="svc-customer {{item.isScattered ? 'svc-customer--scattered' : ''}}">{{item.customerName}}</text>
|
||||
<text class="svc-type svc-type-{{item.typeClass}}">{{item.type}}</text>
|
||||
<text class="svc-date">{{item.date}}</text>
|
||||
</view>
|
||||
<!-- 第2行:台号 + 时长 + 绩效 + 收入 -->
|
||||
<view class="svc-row2">
|
||||
<view class="svc-row2-left">
|
||||
<text class="svc-table-tag">{{item.table}}</text>
|
||||
<text class="svc-table-tag" wx:if="{{item.table}}">{{item.table}}</text>
|
||||
<text class="svc-duration">{{item.duration}}</text>
|
||||
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
|
||||
</view>
|
||||
|
||||
@@ -744,6 +744,8 @@ view {
|
||||
}
|
||||
.svc-row1 { display: flex; align-items: center; gap: 12rpx; }
|
||||
.svc-customer { font-size: 28rpx; font-weight: 600; color: #242424; }
|
||||
/* 散客名称置灰 */
|
||||
.svc-customer--scattered { color: #999; font-weight: 500; }
|
||||
.svc-type {
|
||||
font-size: 22rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
@@ -1083,6 +1085,8 @@ view {
|
||||
.top-customer-info { flex: 1; min-width: 0; }
|
||||
.top-customer-name-row { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; }
|
||||
.top-customer-name { font-size: 28rpx; font-weight: 600; color: #242424; }
|
||||
/* 散客名称置灰 */
|
||||
.top-customer-name--scattered { color: #999; font-weight: 500; }
|
||||
.top-customer-heart { font-size: 24rpx; }
|
||||
.top-customer-score { font-size: 24rpx; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.top-customer-score-success { color: #00a870; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"navigationBarTitleText": "助教业绩明细",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button",
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"heart-icon": "/components/heart-icon/heart-icon",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 助教服务明细页(管理者视角)。
|
||||
*
|
||||
* 入口:pages/coach-detail/coach-detail "近期服务明细" 卡片"查看更多"按钮
|
||||
* 必传 query:coachId(assistant_id)
|
||||
*
|
||||
* 与任务 tab 下的 pages/performance-records/ 区别:
|
||||
* - Banner 用 fetchCoachBanner 取目标助教信息(name/level/storeName)
|
||||
* - 标题展示"<助教名>的业绩"突出查看视角
|
||||
* - 单条记录右下角显示"助教预估收入"(去第一人称)
|
||||
* - 点击单条记录跳 customer-detail(管理者关心客户而非任务)
|
||||
* - 后端 /api/xcx/performance/records?coach_id=xxx,权限码 view_board_coach
|
||||
*/
|
||||
import { checkPageAccess } from '../../utils/auth-guard'
|
||||
import { fetchPerformanceRecords, fetchCoachBanner } from '../../services/api'
|
||||
import { nameToAvatarColor } from '../../utils/avatar-color'
|
||||
import { formatMoney, formatCount } from '../../utils/money'
|
||||
import { formatHours } from '../../utils/time'
|
||||
|
||||
const COURSE_TAG_MAP: Record<string, string> = {
|
||||
'陪打': 'basic', '基础课': 'basic',
|
||||
'包厢': 'room', '包厢课': 'room',
|
||||
'超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive',
|
||||
}
|
||||
function courseTagClass(courseType: string): string {
|
||||
return COURSE_TAG_MAP[courseType] || 'basic'
|
||||
}
|
||||
|
||||
interface DateGroup {
|
||||
date: string
|
||||
totalHours: string
|
||||
totalIncome: string
|
||||
records: RecordItem[]
|
||||
}
|
||||
|
||||
interface RecordItem {
|
||||
customerName: string
|
||||
memberId: number
|
||||
avatarChar: string
|
||||
avatarColor: string
|
||||
timeRange: string
|
||||
hours: string
|
||||
courseType: string
|
||||
courseTagClass: string
|
||||
location: string
|
||||
income: string
|
||||
isScattered?: boolean
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
|
||||
|
||||
/** 目标助教 ID(必传,来自 query) */
|
||||
coachId: 0,
|
||||
|
||||
/** Banner — 来自 fetchCoachBanner */
|
||||
coachName: '',
|
||||
coachRole: '',
|
||||
storeName: '',
|
||||
/** Banner 主标题:用助教名生成"<助教名>的业绩" */
|
||||
pageTitle: '业绩明细',
|
||||
|
||||
/** 月份切换 */
|
||||
currentYear: new Date().getFullYear(),
|
||||
currentMonth: new Date().getMonth() + 1,
|
||||
monthLabel: '',
|
||||
canGoPrev: true,
|
||||
canGoNext: false,
|
||||
|
||||
/** 当月预估判断 */
|
||||
isCurrentMonth: false,
|
||||
|
||||
/** 统计概览 */
|
||||
totalCountLabel: '--',
|
||||
totalHoursLabel: '--',
|
||||
totalHoursRawLabel: '',
|
||||
totalIncomeLabel: '--',
|
||||
|
||||
/** 按日期分组的记录 */
|
||||
dateGroups: [] as DateGroup[],
|
||||
|
||||
/** 分页 */
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: false,
|
||||
},
|
||||
|
||||
onLoad(options: Record<string, string | undefined>) {
|
||||
const coachIdNum = Number(options?.coachId)
|
||||
const coachId = Number.isFinite(coachIdNum) && coachIdNum > 0 ? coachIdNum : 0
|
||||
if (coachId === 0) {
|
||||
// 必传参数缺失:提示并退回上一页
|
||||
wx.showToast({ title: '缺少助教标识', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/board-finance/board-finance' }) }), 1000)
|
||||
return
|
||||
}
|
||||
const now = new Date()
|
||||
this.setData({
|
||||
coachId,
|
||||
currentYear: now.getFullYear(),
|
||||
currentMonth: now.getMonth() + 1,
|
||||
monthLabel: `${now.getFullYear()}年${now.getMonth() + 1}月`,
|
||||
})
|
||||
this.loadBanner()
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
checkPageAccess('pages/coach-service-records/coach-service-records')
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.setData({ page: 1, dateGroups: [] })
|
||||
this.loadData(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore) return
|
||||
this.setData({ page: this.data.page + 1 })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 加载 Banner(仅 name/level/storeName,3 字段轻量接口) */
|
||||
async loadBanner() {
|
||||
try {
|
||||
const banner = await fetchCoachBanner(String(this.data.coachId))
|
||||
if (!banner) return
|
||||
const name = banner.name || ''
|
||||
this.setData({
|
||||
coachName: name,
|
||||
coachRole: banner.level || '助教',
|
||||
storeName: banner.storeName || '',
|
||||
pageTitle: name ? `${name}的业绩` : '业绩明细',
|
||||
})
|
||||
// 同步原生 navbar 标题
|
||||
if (name) {
|
||||
wx.setNavigationBarTitle({ title: `${name}的业绩` })
|
||||
}
|
||||
} catch (_e) {
|
||||
// banner 加载失败不阻塞列表
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载服务明细 */
|
||||
async loadData(cb?: () => void) {
|
||||
if (this.data.page === 1) {
|
||||
this.setData({ pageState: 'loading' })
|
||||
}
|
||||
wx.showLoading({ title: '加载中...', mask: true })
|
||||
|
||||
const now = new Date()
|
||||
const { currentYear, currentMonth } = this.data
|
||||
const isCurrentMonth = currentYear === now.getFullYear()
|
||||
&& currentMonth === now.getMonth() + 1
|
||||
&& now.getDate() <= 5
|
||||
|
||||
try {
|
||||
const res = await fetchPerformanceRecords({
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
page: this.data.page,
|
||||
pageSize: this.data.pageSize,
|
||||
coachId: this.data.coachId,
|
||||
})
|
||||
|
||||
const newGroups = (res.dateGroups || []).map((g: any) => ({
|
||||
...g,
|
||||
records: (g.records || []).map((rec: any) => ({
|
||||
...rec,
|
||||
avatarColor: nameToAvatarColor(String(rec.memberId ?? '')),
|
||||
avatarChar: rec.avatarChar || (rec.customerName || '?').charAt(0),
|
||||
courseTagClass: courseTagClass(rec.courseType || ''),
|
||||
})),
|
||||
}))
|
||||
|
||||
let dateGroups: DateGroup[]
|
||||
if (this.data.page === 1) {
|
||||
dateGroups = newGroups
|
||||
} else {
|
||||
dateGroups = this._mergeGroups(this.data.dateGroups, newGroups)
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {
|
||||
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
|
||||
isCurrentMonth,
|
||||
dateGroups,
|
||||
hasMore: res.hasMore ?? false,
|
||||
}
|
||||
|
||||
if (this.data.page === 1 && res.summary) {
|
||||
const s = res.summary
|
||||
updates.totalCountLabel = formatCount(s.totalCount, '笔')
|
||||
updates.totalHoursLabel = formatHours(s.totalHours)
|
||||
updates.totalIncomeLabel = formatMoney(s.totalIncome)
|
||||
updates.totalHoursRawLabel = (s.totalHoursRaw !== s.totalHours && s.totalHoursRaw > 0)
|
||||
? formatHours(s.totalHoursRaw) : ''
|
||||
}
|
||||
|
||||
this.setData(updates)
|
||||
} catch (_err) {
|
||||
if (this.data.page === 1) {
|
||||
this.setData({ pageState: 'error' })
|
||||
}
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
cb?.()
|
||||
},
|
||||
|
||||
_mergeGroups(existing: DateGroup[], incoming: DateGroup[]): DateGroup[] {
|
||||
const merged = [...existing]
|
||||
for (const g of incoming) {
|
||||
const found = merged.find(m => m.date === g.date)
|
||||
if (found) {
|
||||
found.records = [...found.records, ...g.records]
|
||||
} else {
|
||||
merged.push(g)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.setData({ page: 1, dateGroups: [] })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
/** 点击单条记录 → 跳 customer-detail(管理者视角,关心客户) */
|
||||
onRecordTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const { memberId } = e.currentTarget.dataset
|
||||
const mid = Number(memberId)
|
||||
if (!mid || mid <= 0) {
|
||||
wx.showToast({ title: '散客无详情可查看', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({
|
||||
url: `/pages/customer-detail/customer-detail?id=${memberId}`,
|
||||
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
|
||||
})
|
||||
},
|
||||
|
||||
switchMonth(e: WechatMiniprogram.TouchEvent) {
|
||||
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
|
||||
let { currentYear, currentMonth } = this.data
|
||||
|
||||
if (direction === 'prev') {
|
||||
currentMonth--
|
||||
if (currentMonth < 1) { currentMonth = 12; currentYear-- }
|
||||
} else {
|
||||
currentMonth++
|
||||
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const nowYear = now.getFullYear()
|
||||
const nowMonth = now.getMonth() + 1
|
||||
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
|
||||
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
|
||||
|
||||
this.setData({
|
||||
currentYear,
|
||||
currentMonth,
|
||||
monthLabel: `${currentYear}年${currentMonth}月`,
|
||||
canGoNext,
|
||||
canGoPrev: true,
|
||||
isCurrentMonth,
|
||||
page: 1,
|
||||
dateGroups: [],
|
||||
})
|
||||
|
||||
this.loadData()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
<wxs src="../../utils/format.wxs" module="fmt" />
|
||||
<!-- 错误态 -->
|
||||
<view class="page-error" wx:if="{{pageState === 'error'}}">
|
||||
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
|
||||
<text class="error-text">加载失败,请点击重试</text>
|
||||
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
|
||||
<text>重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<block wx:if="{{pageState !== 'error'}}">
|
||||
<!-- Banner 区域(对齐 performance 页面) -->
|
||||
<view class="banner-section">
|
||||
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
|
||||
<view class="banner-content">
|
||||
<view class="user-info-section">
|
||||
<view class="user-info-row">
|
||||
<view class="avatar-wrap">
|
||||
<image src="{{avatarUrl || '/assets/images/avatar-coach.png'}}" class="avatar-img" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="user-detail">
|
||||
<view class="user-name-row">
|
||||
<text class="user-name">{{fmt.safe(coachName)}}</text>
|
||||
<text class="user-role-tag">{{fmt.safe(coachRole)}}</text>
|
||||
</view>
|
||||
<view class="user-store-row">
|
||||
<text class="user-store">{{fmt.safe(storeName)}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 月份切换 -->
|
||||
<view class="month-switcher">
|
||||
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="prev" bindtap="switchMonth">
|
||||
<t-icon name="chevron-left" size="32rpx" />
|
||||
</view>
|
||||
<text class="month-label">{{monthLabel}}</text>
|
||||
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" hover-class="month-btn--hover" data-direction="next" bindtap="switchMonth">
|
||||
<t-icon name="chevron-right" size="32rpx" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<view class="stats-overview">
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">总记录</text>
|
||||
<text class="stat-value">{{totalCountLabel}}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">总业绩时长</text>
|
||||
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
|
||||
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
|
||||
<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-label">{{isCurrentMonth ? '预估收入' : '收入'}}</text>
|
||||
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
|
||||
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
|
||||
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
|
||||
<text class="empty-text">暂无数据</text>
|
||||
</view>
|
||||
|
||||
<!-- 记录列表(复用 performance 页面服务记录明细样式) -->
|
||||
<view class="records-container" wx:elif="{{pageState === 'normal' || pageState === 'loading'}}">
|
||||
<view class="records-card">
|
||||
<block wx:for="{{dateGroups}}" wx:key="date">
|
||||
<!-- 日期分隔线 -->
|
||||
<view class="date-divider">
|
||||
<text decode class="dd-date">{{fmt.safe(item.date)}} —</text>
|
||||
<text decode class="dd-stats" wx:if="{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时 · {{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}} </text>
|
||||
<view class="dd-line"></view>
|
||||
</view>
|
||||
|
||||
<!-- 该日期下的记录(与 performance 页面卡片一致) -->
|
||||
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName"
|
||||
hover-class="record-item--hover" bindtap="onRecordTap"
|
||||
data-member-id="{{rec.memberId}}">
|
||||
<view class="record-avatar avatar-{{rec.avatarColor}}">
|
||||
<text>{{rec.avatarChar}}</text>
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<view class="record-top">
|
||||
<view class="record-name-time">
|
||||
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
|
||||
<heart-icon score="{{rec.heartScore}}" size="small" />
|
||||
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
|
||||
</view>
|
||||
<text class="record-hours">{{fmt.safe(rec.hours)}}小时</text>
|
||||
</view>
|
||||
<view class="record-bottom">
|
||||
<view class="record-tags">
|
||||
<text class="course-tag course-tag--{{rec.courseTagClass}}">{{fmt.safe(rec.courseType)}}</text>
|
||||
<text class="record-location">{{fmt.safe(rec.location)}}</text>
|
||||
</view>
|
||||
<text class="record-income">{{isCurrentMonth ? '助教预估收入' : '助教收入'}} <text class="record-income-val">¥{{fmt.safe(rec.income)}}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 列表底部提示 -->
|
||||
<view class="list-end-hint" wx:if="{{!hasMore && dateGroups.length > 0}}">
|
||||
<text>— 已加载全部记录 —</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
|
||||
<dev-fab />
|
||||
@@ -0,0 +1,400 @@
|
||||
/* pages/performance-records/performance-records.wxss */
|
||||
/* CHANGE 2026-03-27 | 联调改造:Banner 对齐 performance,卡片样式复用 performance 服务记录明细 */
|
||||
|
||||
page {
|
||||
background-color: #f3f3f3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 加载态 / 空态 / 错误态
|
||||
* ============================================ */
|
||||
.page-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.page-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 28rpx;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 16rpx 48rpx;
|
||||
background: #0052d9;
|
||||
color: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.retry-btn--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* Banner(对齐 performance 页面)
|
||||
* ============================================ */
|
||||
.banner-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-bg-img {
|
||||
position: absolute;
|
||||
top: -50rpx;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.user-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 29rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
width: 98rpx;
|
||||
height: 98rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15rpx;
|
||||
margin-bottom: 7rpx;
|
||||
line-height: 51rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.user-role-tag {
|
||||
font-size: 22rpx;
|
||||
line-height: 29rpx;
|
||||
padding: 4rpx 15rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.user-store-row {
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.user-store {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 月份切换
|
||||
* ============================================ */
|
||||
.month-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 48rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.month-btn {
|
||||
padding: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.month-btn-disabled {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.month-btn--hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #242424;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 统计概览
|
||||
* ============================================ */
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 2rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #242424;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-primary { color: #0052d9; }
|
||||
.stat-success { color: #00a870; }
|
||||
|
||||
.stat-hours-raw {
|
||||
font-size: 20rpx;
|
||||
color: #a6a6a6;
|
||||
margin-top: 2rpx;
|
||||
line-height: 26rpx;
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
font-size: 20rpx;
|
||||
color: #ed7b2f;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2rpx;
|
||||
height: 80rpx;
|
||||
background: #eeeeee;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
* 记录列表(复用 performance 页面服务记录明细样式)
|
||||
* ============================================ */
|
||||
.records-container {
|
||||
padding: 24rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.records-card {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.dd-date {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.dd-line {
|
||||
flex: 1;
|
||||
height: 2rpx;
|
||||
background: #dcdcdc;
|
||||
}
|
||||
|
||||
.dd-stats {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.record-item--hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 头像(渐变色由 app.wxss 全局 .avatar-{key} 提供) */
|
||||
.record-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.record-name-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #242424;
|
||||
flex-shrink: 0;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
/* 散客名称置灰 */
|
||||
.record-name--scattered {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-hours {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
.record-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.record-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.course-tag--basic { background: #ecfdf5; color: #15803d; }
|
||||
.course-tag--room { background: #eff6ff; color: #1d4ed8; }
|
||||
.course-tag--incentive { background: #fffbeb; color: #a16207; }
|
||||
|
||||
.record-location {
|
||||
font-size: 22rpx;
|
||||
color: #8b8b8b;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income {
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
flex-shrink: 0;
|
||||
line-height: 29rpx;
|
||||
}
|
||||
|
||||
.record-income-val {
|
||||
font-weight: 500;
|
||||
color: #5e5e5e;
|
||||
}
|
||||
|
||||
/* 列表底部提示 */
|
||||
.list-end-hint {
|
||||
text-align: center;
|
||||
padding: 24rpx 0 28rpx;
|
||||
font-size: 22rpx;
|
||||
color: #c5c5c5;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ interface ConsumptionRecord {
|
||||
}>
|
||||
foodAmount?: number
|
||||
foodOrigPrice?: number
|
||||
foodDetail?: string
|
||||
totalAmount?: number
|
||||
totalOrigPrice?: number
|
||||
payMethod?: string
|
||||
@@ -46,6 +47,7 @@ Page({
|
||||
name: '',
|
||||
avatarChar: '',
|
||||
phone: '',
|
||||
phoneFull: '',
|
||||
balance: null as number | null,
|
||||
consumption60d: null as number | null,
|
||||
idealInterval: null as number | null,
|
||||
@@ -109,6 +111,7 @@ Page({
|
||||
name: d.name || '',
|
||||
avatarChar: (d.name || '')[0] || '',
|
||||
phone: d.phone || '',
|
||||
phoneFull: d.phoneFull || '',
|
||||
balance: d.balance ?? null,
|
||||
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D(大写 D)
|
||||
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
|
||||
@@ -147,9 +150,9 @@ Page({
|
||||
this.setData({ phoneVisible: !this.data.phoneVisible })
|
||||
},
|
||||
|
||||
/** 复制手机号 */
|
||||
/** 复制手机号(复制完整号码) */
|
||||
onCopyPhone() {
|
||||
const phone = this.data.detail.phone
|
||||
const phone = this.data.detail.phoneFull || this.data.detail.phone
|
||||
wx.setClipboardData({
|
||||
data: phone,
|
||||
success: () => {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<text class="customer-name">{{detail.name}}</text>
|
||||
</view>
|
||||
<view class="sub-info">
|
||||
<text class="phone">{{phoneVisible ? detail.phone : '138****5678'}}</text>
|
||||
<text class="phone">{{phoneVisible ? detail.phoneFull : detail.phone}}</text>
|
||||
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
|
||||
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
|
||||
</view>
|
||||
@@ -221,7 +221,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
|
||||
<view class="record-food-right">
|
||||
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
|
||||
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
|
||||
@@ -261,7 +261,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
|
||||
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
|
||||
</view>
|
||||
<view class="record-total-row" wx:if="{{item.totalAmount}}">
|
||||
@@ -292,7 +292,7 @@
|
||||
<view class="note-list" wx:if="{{sortedNotes.length > 0}}">
|
||||
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
|
||||
<view class="note-top">
|
||||
<text class="note-author">{{fmt.safe(item.tagLabel)}}</text>
|
||||
<text class="note-author">{{item.creatorName || item.tagLabel}}{{item.creatorRole ? ' · ' + item.creatorRole : ''}}</text>
|
||||
<view class="note-top-right">
|
||||
<text class="note-time">{{fmt.safe(item.createdAt)}}</text>
|
||||
<view class="note-delete-btn" data-id="{{item.id}}" bindtap="onDeleteNote" hover-class="note-delete-btn--hover">
|
||||
|
||||
@@ -857,11 +857,20 @@ view {
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
border-top: 2rpx solid var(--border-light);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.record-food-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-food-right {
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
|
||||
<view class="record-food-right">
|
||||
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
|
||||
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
|
||||
@@ -156,7 +156,7 @@
|
||||
<text class="record-date">{{fmt.safe(item.date)}}</text>
|
||||
</view>
|
||||
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
|
||||
<text class="record-food-label">🍷 食品酒水</text>
|
||||
<text class="record-food-label">🍷 {{item.foodDetail || '食品酒水'}}</text>
|
||||
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
|
||||
</view>
|
||||
<view class="record-total-row" wx:if="{{item.totalAmount}}">
|
||||
|
||||
@@ -288,8 +288,15 @@ page {
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
border-top: 2rpx solid var(--border-light, #f0f0f0);
|
||||
gap: 16rpx;
|
||||
}
|
||||
.record-food-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary, #666);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.record-food-label { font-size: 24rpx; color: var(--text-secondary, #666); }
|
||||
.record-food-right { display: flex; align-items: baseline; gap: 8rpx; }
|
||||
.record-food-amount {
|
||||
font-size: 28rpx;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|------|--------|------|
|
||||
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
|
||||
| 2026-03-27 | 联调改造 | 重写:Banner 对齐 performance 页面,数据对接后端 PERF-2,卡片样式复用 performance 服务记录明细 |
|
||||
| 2026-04-20 | 拆分助教视角 | 删除 coachId 分支,本页恢复"任务 tab 助教自查"单一职责;管理者视角迁至 pages/coach-service-records/ |
|
||||
*/
|
||||
import { checkPageAccess } from '../../utils/auth-guard'
|
||||
import { fetchPerformanceRecords, fetchMe } from '../../services/api'
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<view class="record-content">
|
||||
<view class="record-top">
|
||||
<view class="record-name-time">
|
||||
<text class="record-name">{{fmt.safe(rec.customerName)}}</text>
|
||||
<text class="record-name {{rec.isScattered ? 'record-name--scattered' : ''}}">{{fmt.safe(rec.customerName)}}</text>
|
||||
<heart-icon score="{{rec.heartScore}}" size="small" />
|
||||
<text class="record-time">{{fmt.safe(rec.timeRange)}}</text>
|
||||
</view>
|
||||
|
||||
@@ -138,8 +138,8 @@ view {
|
||||
}
|
||||
|
||||
.user-store {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -327,6 +327,12 @@ view {
|
||||
line-height: 36rpx;
|
||||
}
|
||||
|
||||
/* 散客名称置灰 */
|
||||
.record-name--scattered {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 22rpx;
|
||||
color: #a6a6a6;
|
||||
|
||||
@@ -22,62 +22,15 @@ import { formatDeadline } from '../../utils/time'
|
||||
import { formatStorageLevel } from '../../utils/storage-level'
|
||||
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
|
||||
import { API_BASE } from '../../utils/config'
|
||||
import {
|
||||
SPARK_DELAY_MS, SPARK_DUR_MS, NEXT_LOOP_DELAY_MS,
|
||||
calcShineDur, buildTicks, buildProgressBarData,
|
||||
type TickItem,
|
||||
} from '../../utils/perf-progress'
|
||||
|
||||
/** 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
|
||||
@@ -94,24 +47,6 @@ interface EnrichedTask extends Task {
|
||||
recent60dIncome: number
|
||||
}
|
||||
|
||||
/** 刻度项 */
|
||||
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
|
||||
@@ -414,7 +349,6 @@ Page({
|
||||
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
|
||||
const basicHours = perf.basicHours ?? perf.basic_hours ?? 0
|
||||
const bonusHours = perf.bonusHours ?? perf.bonus_hours ?? 0
|
||||
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
|
||||
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
|
||||
const tierCompleted = perf.tierCompleted ?? perf.tier_completed ?? false
|
||||
const bonusMoney = perf.bonusMoney ?? perf.bonus_money ?? 0
|
||||
@@ -423,19 +357,16 @@ Page({
|
||||
const totalIncome = perf.totalIncome ?? perf.total_income ?? 0
|
||||
const incomeTrend = perf.incomeTrend ?? perf.income_trend ?? ''
|
||||
const incomeTrendDir = perf.incomeTrendDir ?? perf.income_trend_dir ?? 'up'
|
||||
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0, 100, 130, 160, 190, 220]
|
||||
|
||||
// 计算进度条百分比(基于最大档位)
|
||||
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 220
|
||||
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
|
||||
const remainHours = Math.max(0, nextTierHours - totalHours)
|
||||
// 统一使用共用模块计算进度条数据
|
||||
const pbData = buildProgressBarData(perf)
|
||||
|
||||
perfData.totalHours = totalHours
|
||||
perfData.basicHours = basicHours
|
||||
perfData.bonusHours = bonusHours
|
||||
perfData.currentTier = currentTier
|
||||
perfData.currentTier = pbData.currentTier
|
||||
perfData.nextTierHours = nextTierHours
|
||||
perfData.remainHours = remainHours
|
||||
perfData.remainHours = pbData.remainHours
|
||||
perfData.tierCompleted = tierCompleted
|
||||
perfData.bonusMoney = bonusMoney
|
||||
perfData.incomeMonth = monthLabel
|
||||
@@ -443,21 +374,15 @@ Page({
|
||||
perfData.incomeFormatted = formatMoney(totalIncome)
|
||||
perfData.incomeTrend = incomeTrend
|
||||
perfData.incomeTrendDir = incomeTrendDir === 'down' ? 'down' : 'up'
|
||||
// 从 "↑7373" / "↓368" 中提取纯数字,用于千分位格式化
|
||||
// 从 "^7373" / "v368" 中提取纯数字,用于千分位格式化
|
||||
const trendNumMatch = incomeTrend.replace(/[^0-9.]/g, '')
|
||||
perfData.incomeTrendValue = trendNumMatch ? parseFloat(trendNumMatch) : null
|
||||
perfData.filledPct = filledPct
|
||||
perfData.clampedSparkPct = Math.max(0, Math.min(100, filledPct))
|
||||
perfData.ticks = buildTicks(tierNodes, maxHours)
|
||||
perfData.shineDurMs = calcShineDur(filledPct)
|
||||
perfData.sparkDurMs = SPARK_DUR_MS
|
||||
|
||||
// 计算段内进度
|
||||
const segStart = tierNodes[currentTier] ?? 0
|
||||
const segEnd = tierNodes[currentTier + 1] ?? maxHours
|
||||
perfData.tierProgress = segEnd > segStart
|
||||
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
|
||||
: 100
|
||||
perfData.filledPct = pbData.filledPct
|
||||
perfData.clampedSparkPct = pbData.clampedSparkPct
|
||||
perfData.ticks = pbData.ticks
|
||||
perfData.shineDurMs = pbData.shineDurMs
|
||||
perfData.sparkDurMs = pbData.sparkDurMs
|
||||
perfData.tierProgress = pbData.tierProgress
|
||||
}
|
||||
|
||||
// G2: 当月预估判断
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
|
||||
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</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>
|
||||
@@ -213,7 +213,7 @@
|
||||
<text class="expected-tag expected-tag--overdue" wx:if="{{item.expectedDays != null && item.expectedDays < 0 && item.taskType !== 'relationship_building' && item.taskType !== 'follow_up_visit'}}">逾期{{-item.expectedDays}}天</text>
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text">到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
|
||||
<text class="visit-text">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</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>
|
||||
@@ -255,7 +255,7 @@
|
||||
<heart-icon score="{{item.heartScore}}" size="small" />
|
||||
</view>
|
||||
<view class="card-row-2">
|
||||
<text class="visit-text visit-text--abandoned">到店:{{fmt.days(item.lastVisitDays)}}前 · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
|
||||
<text class="visit-text visit-text--abandoned">到店:{{fmt.daysAgo(item.lastVisitDays)}} · 储值 {{fmt.safe(item.balanceLabel)}} · 近60天 {{fmt.hoursH(item.recent60dHours)}} | {{fmt.money(item.recent60dIncome)}}</text>
|
||||
</view>
|
||||
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
|
||||
<text class="abandon-reason">放弃原因:{{fmt.safe(item.abandonReason)}}</text>
|
||||
|
||||
@@ -179,6 +179,8 @@ export async function fetchPerformanceRecords(params: {
|
||||
month: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
/** 目标助教 ID:从 coach-detail 跳入时传入,要求调用者具备 view_board_coach 权限 */
|
||||
coachId?: number
|
||||
}): Promise<{
|
||||
summary: { totalCount: number; totalHours: number; totalHoursRaw: number; totalIncome: number }
|
||||
dateGroups: Array<{
|
||||
@@ -198,10 +200,20 @@ export async function fetchPerformanceRecords(params: {
|
||||
}>
|
||||
hasMore: boolean
|
||||
}> {
|
||||
// 后端 FastAPI Query 参数为 snake_case,前端 camelCase 需手动映射
|
||||
const query: Record<string, any> = {
|
||||
year: params.year,
|
||||
month: params.month,
|
||||
page: params.page ?? 1,
|
||||
page_size: params.pageSize ?? 20,
|
||||
}
|
||||
if (params.coachId !== undefined && params.coachId !== null) {
|
||||
query.coach_id = params.coachId
|
||||
}
|
||||
return request({
|
||||
url: '/api/xcx/performance/records',
|
||||
method: 'GET',
|
||||
data: params,
|
||||
data: query,
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
@@ -317,6 +329,20 @@ export async function fetchBoardFinance(params: {
|
||||
// 助教模块
|
||||
// ============================================
|
||||
|
||||
/** 助教 banner 轻量信息(仅 name/level/storeName)— 比 fetchCoachDetail 快一个数量级 */
|
||||
export async function fetchCoachBanner(coachId: string): Promise<{
|
||||
id: number
|
||||
name: string
|
||||
level: string
|
||||
storeName: string
|
||||
} | null> {
|
||||
return request({
|
||||
url: `/api/xcx/coaches/${coachId}/banner`,
|
||||
method: 'GET',
|
||||
needAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 助教详情 */
|
||||
export async function fetchCoachDetail(coachId: string): Promise<CoachCard | null> {
|
||||
return request({
|
||||
|
||||
@@ -155,6 +155,16 @@ function days(value) {
|
||||
return value + '天'
|
||||
}
|
||||
|
||||
/**
|
||||
* 距今天数格式化(WXS 版)
|
||||
* "今天" / "3天前" / "--"
|
||||
*/
|
||||
function daysAgo(value) {
|
||||
if (value === undefined || value === null) return '--'
|
||||
if (value === 0) return '今天'
|
||||
return value + '天前'
|
||||
}
|
||||
|
||||
/**
|
||||
* 储值等级格式化(WXS 版)
|
||||
* 无/少/一般/多/非常多
|
||||
@@ -219,6 +229,7 @@ module.exports = {
|
||||
thousands: thousands,
|
||||
trendValue: trendValue,
|
||||
days: days,
|
||||
daysAgo: daysAgo,
|
||||
storageLevel: storageLevel,
|
||||
maskPhone: maskPhone,
|
||||
negativeMoney: negativeMoney,
|
||||
|
||||
124
apps/miniprogram/miniprogram/utils/perf-progress.ts
Normal file
124
apps/miniprogram/miniprogram/utils/perf-progress.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 绩效进度条共用模块 -- task-list / coach-detail 统一使用
|
||||
*
|
||||
* 包含:动画参数、刻度计算、高光时长计算、类型定义
|
||||
*/
|
||||
|
||||
/* ======================================================
|
||||
* 进度条动画参数 -- 在此调节
|
||||
* ======================================================
|
||||
*
|
||||
* 动画由 JS 状态机驱动,每轮循环重新读取实际进度条宽度:
|
||||
*
|
||||
* +-------------+ SPARK_DELAY_MS +-------------+ NEXT_LOOP_DELAY_MS +-------------+
|
||||
* | 高光匀速扫过 | --------------> | 火花迸发 | ------------------> | 下一轮 |
|
||||
* | 时长由速度决定| | SPARK_DUR_MS| |(重新读进度) |
|
||||
* +-------------+ +-------------+ +-------------+
|
||||
*
|
||||
* SHINE_SPEED : 高光移动速度,范围 1~100
|
||||
* 1 = 最慢,最宽进度条(100%)下 5 秒走完
|
||||
* 100 = 最快,最宽进度条(100%)下 0.05 秒走完
|
||||
* 实际时长 = 基准时长 x (filledPct/100)
|
||||
* 基准时长 = 5s - (SHINE_SPEED-1)/(100-1) x (5-0.05)s
|
||||
*
|
||||
* SPARK_DELAY_MS : 高光到达最右端后,光柱点亮+火花迸发的延迟(毫秒)
|
||||
* 正数 = 高光结束后停顿再点亮
|
||||
* 负数 = 高光还未结束,提前点亮(高光末尾与火花重叠)
|
||||
*
|
||||
* SPARK_DUR_MS : 火花迸发到完全消散的时长(毫秒)
|
||||
*
|
||||
* NEXT_LOOP_DELAY_MS: 火花消散后到下一轮高光启动的延迟(毫秒)
|
||||
* 正数 = 停顿一段时间
|
||||
* 负数 = 火花还未消散完,高光已从左端启动
|
||||
*
|
||||
* 修改说明见 apps/miniprogram/doc/progress-bar-animation.md
|
||||
*/
|
||||
export const SHINE_SPEED = 70 // 1~100,速度值
|
||||
export const SPARK_DELAY_MS = -200 // 毫秒,高光结束->光柱点亮+火花(负=提前)
|
||||
export const SPARK_DUR_MS = 1400 // 毫秒,火花持续时长
|
||||
export const NEXT_LOOP_DELAY_MS = 400 // 毫秒,火花结束->下轮高光(负=提前)
|
||||
|
||||
/* 高光宽度固定(SHINE_WIDTH_RPX),需要走过的距离 = 填充条宽度 + 高光宽度
|
||||
* 轨道宽度约 634rpx(750 - 左右padding各58rpx),高光宽度约占轨道 19%
|
||||
* 时长正比于需要走过的总距离,保证视觉速度恒定
|
||||
*
|
||||
* 速度1 -> baseDur=5000ms(最慢),速度100 -> baseDur=50ms(最快)
|
||||
* shineDurMs = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
*/
|
||||
export const SHINE_WIDTH_RPX = 120 // rpx,需与 WXSS 的 --shine-width 保持一致
|
||||
export const TRACK_WIDTH_RPX = 634 // rpx,进度条轨道宽度(750 - padding 116rpx)
|
||||
export const SHINE_WIDTH_PCT = (SHINE_WIDTH_RPX / TRACK_WIDTH_RPX) * 100 // ~19%
|
||||
|
||||
// ── 类型定义 ────────────────────────────────────────────────
|
||||
|
||||
/** 刻度项(传给 perf-progress-bar 组件的 ticks 属性) */
|
||||
export interface TickItem {
|
||||
value: number // 刻度数值(如 100)
|
||||
label: string // 显示文字(如 '100')
|
||||
left: string // 百分比位置字符串(如 '45.45%'),首尾项忽略此字段
|
||||
highlight: boolean // 是否加粗高亮
|
||||
}
|
||||
|
||||
// ── 工具函数 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 根据速度值和进度百分比计算高光扫过时长。
|
||||
*
|
||||
* 速度 1 -> baseDur=5000ms(最慢),速度 100 -> baseDur=50ms(最快)
|
||||
* 实际时长 = baseDur x (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
*/
|
||||
export function calcShineDur(filledPct: number): number {
|
||||
const t = (SHINE_SPEED - 1) / 99 // 0(最慢) ~ 1(最快)
|
||||
const baseDur = 5000 - t * (5000 - 50)
|
||||
const distRatio = (filledPct + SHINE_WIDTH_PCT) / (100 + SHINE_WIDTH_PCT)
|
||||
return Math.max(50, Math.round(baseDur * distRatio))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据档位节点数组生成刻度数据(供 perf-progress-bar 组件渲染)。
|
||||
*/
|
||||
export 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: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端返回的 performance 对象中计算进度条所需的全部数据。
|
||||
*
|
||||
* 两个页面统一调用此函数,确保进度百分比、当前档位、刻度等计算逻辑一致。
|
||||
*/
|
||||
export function buildProgressBarData(perf: Record<string, any>) {
|
||||
// 后端返回 snake_case(CamelModel 可能已转 camelCase),兼容两种命名
|
||||
const totalHours = perf.totalHours ?? perf.total_hours ?? 0
|
||||
const tierNodes: number[] = perf.tierNodes ?? perf.tier_nodes ?? [0]
|
||||
const currentTier = perf.currentTier ?? perf.current_tier ?? 0
|
||||
const nextTierHours = perf.nextTierHours ?? perf.next_tier_hours ?? 0
|
||||
|
||||
const maxHours = tierNodes.length > 0 ? tierNodes[tierNodes.length - 1] : 0
|
||||
const filledPct = maxHours > 0 ? Math.min(100, Math.round((totalHours / maxHours) * 1000) / 10) : 0
|
||||
const clampedSparkPct = Math.max(0, Math.min(100, filledPct))
|
||||
const remainHours = Math.max(0, nextTierHours - totalHours)
|
||||
const ticks = buildTicks(tierNodes, maxHours)
|
||||
|
||||
// 段内进度(当前档位内的百分比)
|
||||
const segStart = tierNodes[currentTier] ?? 0
|
||||
const segEnd = tierNodes[currentTier + 1] ?? maxHours
|
||||
const tierProgress = segEnd > segStart
|
||||
? Math.min(100, Math.round(((totalHours - segStart) / (segEnd - segStart)) * 100))
|
||||
: 100
|
||||
|
||||
return {
|
||||
filledPct,
|
||||
clampedSparkPct,
|
||||
currentTier,
|
||||
ticks,
|
||||
remainHours,
|
||||
tierProgress,
|
||||
shineDurMs: calcShineDur(filledPct),
|
||||
sparkDurMs: SPARK_DUR_MS,
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,12 @@
|
||||
"packOptions": {
|
||||
"ignore": [
|
||||
{
|
||||
"type": "glob",
|
||||
"value": "miniprogram/pages/task-detail-callback/**"
|
||||
"value": "miniprogram/pages/task-detail-callback/**",
|
||||
"type": "glob"
|
||||
},
|
||||
{
|
||||
"type": "glob",
|
||||
"value": "miniprogram/pages/task-detail-relationship/**"
|
||||
"value": "miniprogram/pages/task-detail-relationship/**",
|
||||
"type": "glob"
|
||||
}
|
||||
],
|
||||
"include": []
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
},
|
||||
"mini-ios": {
|
||||
"sdkVersion": "1.6.28",
|
||||
"sdkVersion": "1.7.0",
|
||||
"toolkitVersion": "0.0.9",
|
||||
"useExtendedSdk": {
|
||||
"WeAppOpenFuns": true,
|
||||
@@ -65,4 +65,4 @@
|
||||
},
|
||||
"enableOpenUrlNavigate": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "NeoZQYY",
|
||||
"projectname": "miniprogram",
|
||||
"setting": {
|
||||
"compileHotReLoad": true,
|
||||
"urlCheck": false,
|
||||
|
||||
Reference in New Issue
Block a user