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:
Neo
2026-04-20 06:32:07 +08:00
parent 79d3c2e97e
commit 2a7a5d68aa
157 changed files with 14304 additions and 3717 deletions

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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 重复警告

View File

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

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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"
}
}

View File

@@ -0,0 +1,275 @@
/**
* 助教服务明细页(管理者视角)。
*
* 入口pages/coach-detail/coach-detail "近期服务明细" 卡片"查看更多"按钮
* 必传 querycoachIdassistant_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/storeName3 字段轻量接口) */
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()
},
})

View File

@@ -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)}}&nbsp;&nbsp;—</text>
<text decode class="dd-stats" wx:if="{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时&nbsp;·&nbsp;{{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}}&nbsp;&nbsp;</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 />

View File

@@ -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;
}

View File

@@ -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: () => {

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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}}">

View File

@@ -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;

View File

@@ -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'

View File

@@ -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>

View File

@@ -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;

View File

@@ -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需要走过的距离 = 填充条宽度 + 高光宽度
* 轨道宽度约 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
@@ -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: 当月预估判断

View File

@@ -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>

View File

@@ -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({

View File

@@ -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,

View 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_caseCamelModel 可能已转 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,
}
}

View File

@@ -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": []

View File

@@ -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
}
}
}

View File

@@ -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,