feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -1,5 +1,18 @@
# apps/miniprogram — 微信小程序
<!-- AI_CHANGELOG
- 2026-03-28 02:03:11 | Prompt: 登录落地页修复 | 认证流程章节补充权限码驱动登录跳转说明(落地页优先级、/me 降级策略)
- 2026-03-27 19:07:51 | Prompt: board-finance 双重格式化修复 | board-finance.ts 移除 formatMoney() 预格式化,
金额字段传原始数字(?? 0格式化统一由 WXS 完成;添加 checkPageAccess 权限守卫;
区域筛选从 7→9 项;新增 isCurrentMonth 预估标记和 aiInsights 字段。
- 2026-03-20 23:41:47 | Prompt: ai-prompt-refinement spec 验证 | board-coach.ts Mock 数据从 6 位助教精简为 2 个空骨架项,
用于字段覆盖排查;页面结构和逻辑不变。
- 2026-03-20 | Prompt: 小程序文档过期检查与落盘 | 页面路由表从 13→19 条(同步 app.json
移除已删除的 mvp/index/logs目录结构补充 services/、assets/、utils 22 个文件;
新增组件清单18 个API 端点表补充 me/tasks/{id}/performance/customers/coaches 等;
Roadmap 更新已完成项;移除已废弃的 MVP 页面章节。
-->
微信小程序前端项目,基于 Donut 多端框架 + TDesign 组件库,为台球门店会员提供移动端服务入口。
## 技术栈
@@ -14,23 +27,26 @@
```
apps/miniprogram/
├── miniprogram/ # 小程序主体代码
│ ├── app.ts # 应用入口(wx.login 获取 code
│ ├── app.json # 全局配置(页面路由、窗口样式)
│ ├── app.wxss # 全局样式
│ ├── pages/ # 页面目录
│ ├── mvp/ # MVP 全链路验证页
│ ├── index/ # 首页
│ │ ── login/ # 登录页
│ ├── apply/ # 入驻申请页
│ │ ├── reviewing/ # 审核中等待页
│ │ ├── no-permission/ # 无权限提示页
│ │ ├── dev-tools/ # 开发调试面板(仅 develop 环境)
│ │ └── logs/ # 日志页
│ ├── components/ # 全局组件
│ │ └── dev-fab/ # 浮动调试按钮(仅 develop 环境显示)
│ ├── utils/ # 工具函数
│ ├── app.ts # 应用入口(检查登录状态 → 路由分发
│ ├── app.json # 全局配置(页面路由、TabBar、窗口样式)
│ ├── app.wxss # 全局样式CSS 变量系统、头像色板)
│ ├── pages/ # 页面目录19 个,详见「页面路由」)
│ ├── components/ # 可复用组件18 个,详见「组件清单」)
│ ├── services/ # 数据请求层
│ │ ── api.ts # 统一 API 入口(封装所有后端调用)
│ ├── utils/ # 工具函数22 个)
│ │ ├── config.ts # 环境配置API 地址自动切换)
│ │ ── util.ts # 通用工具(日期格式化等
│ │ ── request.ts # HTTP 请求封装JWT 自动注入、刷新
│ │ ├── router.ts # 页面跳转工具
│ │ ├── vi-colors.ts # VI 颜色常量
│ │ ├── ai-color-manager.ts # AI 配色管理器
│ │ ├── avatar-color.ts # 头像颜色映射(姓名 → 24 色)
│ │ ├── task-config.ts # 任务类型配置
│ │ ├── mock-data.ts # Mock 数据(开发调试用)
│ │ └── ... # format.wxs、time.wxs、money.ts 等
│ ├── assets/ # 静态资源
│ │ ├── icons/ # 图标文件
│ │ └── images/ # 图片文件
│ ├── miniprogram_npm/ # 构建后的 npm 包TDesign 组件)
│ ├── i18n/ # 国际化资源
│ └── miniapp/ # Donut 多端原生资源
@@ -53,23 +69,54 @@ apps/miniprogram/
### 页面路由
当前注册页面(`app.json`
当前注册页面(`app.json`,共 19 个
| 路径 | 说明 |
|------|------|
| `pages/mvp/mvp` | MVP 全链路验证(从后端读取测试数据 |
| `pages/index/index` | 首页(待开发) |
| `pages/login/login` | 登录页 |
| `pages/apply/apply` | 入驻申请页 |
| `pages/reviewing/reviewing` | 审核中等待页 |
| `pages/no-permission/no-permission` | 无权限提示页 |
| `pages/task-list/task-list` | 任务列表页H5 原型 1:1 重写,四种任务类型分组 |
| `pages/notes/notes` | 备注管理页(备注 CRUD + 关联任务上下文) |
| `pages/chat/chat` | AI 对话页CHAT-2b/3/4按上下文进入对话 |
| `pages/chat-history/chat-history` | 对话历史列表页CHAT-1 |
| `pages/board-coach/board-coach` | 助教看板页BOARD-1排序×技能×时间筛选 |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入 |
| `pages/logs/logs` | 日志页(框架默认 |
| 路径 | 说明 | TabBar |
|------|------|:------:|
| `pages/login/login` | 登录页(首页,微信授权登录 | |
| `pages/apply/apply` | 入驻申请页 | |
| `pages/reviewing/reviewing` | 审核中等待页 | |
| `pages/no-permission/no-permission` | 无权限 / 账号禁用提示页 | |
| `pages/task-list/task-list` | 任务列表页(四种任务类型分组、置顶/放弃、备注弹窗) | ✅ 任务 |
| `pages/task-detail/task-detail` | 任务详情页维客线索、服务记录、AI 分析、备注) | |
| `pages/notes/notes` | 备注管理页(备注 CRUD + 关联任务上下文 | |
| `pages/board-finance/board-finance` | 财务看板页6 大板块 + 环比开关) | ✅ 看板 |
| `pages/board-coach/board-coach` | 助教看板页(排序×技能×时间三重筛选 | |
| `pages/board-customer/board-customer` | 客户看板页(维度×项目筛选 + 分页 | |
| `pages/my-profile/my-profile` | 个人中心页 | ✅ 我的 |
| `pages/performance/performance` | 绩效概览页(档位进度、收入明细、新客/常客 | |
| `pages/performance-records/performance-records` | 绩效明细页(按日期分组的服务记录 | |
| `pages/customer-detail/customer-detail` | 客户详情页(完整档案 + AI 洞察 + 维客线索) | |
| `pages/customer-service-records/customer-service-records` | 客户服务记录页(按日期分组的消费记录) | |
| `pages/coach-detail/coach-detail` | 助教详情页(业绩数据 + 客户列表) | |
| `pages/chat/chat` | AI 对话页SSE 流式输出,按上下文进入对话) | |
| `pages/chat-history/chat-history` | 对话历史列表页 | |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) | |
## 组件清单
可复用组件(`miniprogram/components/`,共 18 个):
| 组件 | 路径 | 说明 |
|------|------|------|
| abandon-modal | `components/abandon-modal/` | 放弃任务确认弹窗 |
| ai-float-button | `components/ai-float-button/` | 页面底部 AI 助手浮动入口 |
| ai-inline-icon | `components/ai-inline-icon/` | 行内 AI 小图标30rpx6 种配色) |
| ai-title-badge | `components/ai-title-badge/` | 标题行 AI 胶囊徽章6 种配色) |
| banner | `components/banner/` | 页面顶部用户信息条 |
| board-tab-bar | `components/board-tab-bar/` | 看板页面标签切换栏 |
| clue-card | `components/clue-card/` | 维客线索单项卡片 |
| clue-list | `components/clue-list/` | 维客线索列表容器 |
| coach-level-tag | `components/coach-level-tag/` | 助教等级徽章(初级/中级/高级/星级) |
| dev-fab | `components/dev-fab/` | 开发调试浮动按钮(仅 develop 环境) |
| filter-dropdown | `components/filter-dropdown/` | 看板筛选下拉菜单 |
| heart-icon | `components/heart-icon/` | 关系指数心形图标4 级渐变) |
| hobby-tag | `components/hobby-tag/` | 客户爱好标签 |
| metric-card | `components/metric-card/` | 数据指标展示卡片 |
| note-modal | `components/note-modal/` | 备注编辑弹窗 |
| perf-progress-bar | `components/perf-progress-bar/` | 业绩档位进度条 |
| service-record-card | `components/service-record-card/` | 服务记录单项卡片 |
| star-rating | `components/star-rating/` | 星级评分组件 |
## 后端 API 集成
@@ -110,6 +157,12 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
- `rejected`:审批拒绝,可重新申请
- `disabled`:账号禁用
登录落地页approved 用户):
- 登录成功后额外请求 `/api/xcx/me` 获取 `permissions[]` 权限码
- 调用 `syncPermissions()` 设置 tab 可见性,`getPermissionHome()` 计算落地页
- 落地页优先级board-finance > board-customer > board-coach > task-list > my-profile
- `/me` 请求失败时降级到 my-profile后续 `checkAuthStatus` 补偿
令牌类型:
- 受限令牌(`limited=True`new/pending/rejected 用户,仅可访问申请和状态查询端点
- 完整令牌approved 用户,包含 `user_id` + `site_id` + `roles`
@@ -137,28 +190,28 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
| `/api/xcx-auth/sites` | GET | 获取关联门店列表 |
| `/api/xcx-auth/switch-site` | POST | 切换当前门店 |
| `/api/xcx-auth/refresh` | POST | 刷新令牌 |
| `/api/xcx/tasks` | GET | 获取任务列表 |
| `/api/xcx/me` | GET | 查询当前用户信息 |
| `/api/xcx/tasks` | GET | 获取任务列表(含绩效概览) |
| `/api/xcx/tasks/{task_id}` | GET | 获取任务详情维客线索、服务记录、AI 分析) |
| `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 |
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 |
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃任务 |
| `/api/xcx/tasks/{task_id}/restore` | POST | 取消放弃任务 |
| `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD |
| `/api/xcx/config/skill-types` | GET | 项目类型筛选器配置CONFIG-1 |
| `/api/xcx/board/coaches` | GET | 助教看板BOARD-1排序×技能×时间筛选 |
| `/api/xcx/board/customers` | GET | 客户看板BOARD-2维度×项目筛选 + 分页 |
| `/api/xcx/board/finance` | GET | 财务看板BOARD-36 大板块 + 环比开关) |
| `/api/xcx/chat/history` | GET | CHAT-1 对话历史列表 |
| `/api/xcx/chat/{chat_id}/messages` | GET | CHAT-2a 通过 chatId 查消息 |
| `/api/xcx/chat/messages` | GET | CHAT-2b 通过上下文查消息contextType + contextId |
| `/api/xcx/chat/{chat_id}/messages` | POST | CHAT-3 发送消息(同步回复 |
| `/api/xcx/chat/stream` | POST | CHAT-4 SSE 流式对话 |
| `/api/xcx-test` | GET | MVP 全链路验证 |
| `/api/xcx/performance` | GET | 绩效概览(档位、收入、新客/常客 |
| `/api/xcx/performance/records` | GET | 绩效明细(按日期分组 |
| `/api/xcx/customers/{id}` | GET | 客户详情(完整档案 + AI 洞察 |
| `/api/xcx/coaches/{id}` | GET | 助教详情 |
| `/api/xcx/config/skill-types` | GET | 项目类型筛选器配置 |
| `/api/xcx/board/coaches` | GET | 助教看板(排序×技能×时间筛选) |
| `/api/xcx/board/customers` | GET | 客户看板(维度×项目筛选 + 分页 |
| `/api/xcx/board/finance` | GET | 财务看板6 大板块 + 环比开关 |
| `/api/xcx/chat/history` | GET | 对话历史列表 |
| `/api/xcx/chat/{chat_id}/messages` | GET/POST | 查看/发送消息 |
| `/api/xcx/chat/messages` | GET | 通过上下文查消息 |
| `/api/xcx/chat/stream` | POST | SSE 流式对话 |
> 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)
## MVP 页面
`pages/mvp/mvp` 是全链路验证页面,从后端 `/api/xcx-test` 读取 `test."xcx-test"` 表数据并显示,用于验证:
- 小程序 → 后端 API → 数据库 的完整链路
- 网络请求、错误处理、加载状态
> 完整接口契约见 [`docs/miniprogram-dev/API-contract.md`](../../docs/miniprogram-dev/API-contract.md)
> 后端 API 参考见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)
## 权限模型
@@ -205,13 +258,17 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
## Roadmap
- [ ] 完善认证流程页面(登录 → 申请 → 等待审批 → 首页)
- [x] 任务列表页面task-listH5 原型 1:1 重写,含四种任务类型分组、上下文菜单、备注弹窗)
- [ ] 任务管理功能联调(置顶、放弃、备注 API 对接
- [ ] 数据看板页面(助教业绩、客户分析
- [ ] 会员中心页面
- [ ] 助教预约功能
- [ ] 订单查询功能
- [x] 认证流程页面(登录 → 申请 → 等待审批 → 首页)
- [x] 任务列表页面task-list四种任务类型分组、上下文菜单、备注弹窗
- [x] 任务详情页面task-detail维客线索、服务记录、AI 分析
- [x] 数据看板页面(助教/客户/财务三大看板
- [x] 绩效页面performance档位进度、收入明细、新客/常客)
- [x] 客户详情页面customer-detail完整档案 + AI 洞察)
- [x] 助教详情页面coach-detail业绩数据 + 客户列表)
- [x] AI 对话页面chatSSE 流式输出 + 8 个 AI App
- [x] 个人中心页面my-profile
- [x] VI 颜色系统统一24 色头像色板、AI 配色系统)
- [ ] 前后端联调services/api.ts 当前为 mock 模式,待恢复真实 API 调用)
- [ ] 多门店切换 UI
- [ ] 消息通知(微信订阅消息)
- [ ] CI/CD代码检查、自动上传体验版

View File

@@ -15,6 +15,7 @@
"pages/board-coach/board-coach",
"pages/customer-detail/customer-detail",
"pages/customer-service-records/customer-service-records",
"pages/customer-records/customer-records",
"pages/coach-detail/coach-detail",
"pages/chat/chat",
"pages/chat-history/chat-history",

View File

@@ -1,6 +1,13 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | checkAuthStatus 补完:保存 role、syncVisibleTabs、按状态路由approved 留当前页由守卫处理、disabled 强制登出) |
| 2026-03-27 | 权限改造 W5 | 改用 syncPermissions 替代 syncVisibleTabs从后端 permissions 驱动 tab 可见性 |
*/
// app.ts
// 应用入口 — 启动时检查登录状态并路由到对应页面
import { request } from "./utils/request"
import { syncPermissions, getPermissionHome, getVisibleTabs } from "./utils/auth-guard"
App<IAppOption>({
globalData: {},
@@ -17,35 +24,52 @@ App<IAppOption>({
this.globalData.authUser = {
userId,
status: wx.getStorageSync("userStatus") || "new",
role: wx.getStorageSync("userRole") || undefined,
}
// onLaunch 阶段 getApp() 尚未就绪,直接用 this.globalData 设置
// CHANGE 2026-03-27 | 权限码驱动onLaunch 无 permissions给默认空 tab
this.globalData.permissions = []
this.globalData.visibleTabs = ['my']
}
// 有 token → 查询最新用户状态并路由
this.checkAuthStatus()
}
// 无 token → 停留在 login 页(首页已设为 login
},
/**
* CHANGE 2026-03-22 | 竞态修复
* CHANGE 2026-03-23 | 角色路由:按角色跳转不同首页
*/
async checkAuthStatus() {
const tokenAtStart = this.globalData.token
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
if (this.globalData.token !== tokenAtStart) return
// 持久化用户信息
// CHANGE 2026-03-23 | camelCase 修复:后端 CamelModel 序列化输出 camelCase
this.globalData.authUser = {
userId: data.user_id,
userId: data.userId,
status: data.status,
nickname: data.nickname,
// CHANGE 2026-03-24 | 头像:保存 avatarUrl 到 globalData
avatarUrl: data.avatarUrl || undefined,
role: data.role || undefined,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userId", data.userId)
wx.setStorageSync("userStatus", data.status)
if (data.role) wx.setStorageSync("userRole", data.role)
// 根据状态路由
// CHANGE 2026-03-27 | 权限码驱动:同步权限和 tab 可见性
const permissions: string[] = data.permissions || []
syncPermissions(permissions)
// 根据用户状态路由
switch (data.status) {
case "approved":
wx.reLaunch({ url: "/pages/task-list/task-list" })
// 已通过 — 不跳转,用户留在当前页面(由页面守卫处理权限)
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
@@ -57,14 +81,16 @@ App<IAppOption>({
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
case "disabled":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
default:
wx.reLaunch({ url: "/pages/apply/apply" })
// 强制登出
wx.removeStorageSync("token")
wx.removeStorageSync("refreshToken")
this.globalData.token = undefined
this.globalData.refreshToken = undefined
wx.reLaunch({ url: "/pages/login/login" })
break
}
} catch {
// token 无效或网络错误 → 停留在 login 页
// 网络错误不阻塞401 由 request.ts 拦截处理)
}
},
})

View File

@@ -349,6 +349,8 @@ page {
.avatar-gold { background: linear-gradient(135deg, #fcd34d, #b45309); }
.avatar-crimson { background: linear-gradient(135deg, #c42844, #750d28); }
.avatar-ocean { background: linear-gradient(135deg, #38bdf8, #1d4ed8); }
/* 默认灰色头像(未知客户/散客) */
.avatar-default { background: #dcdcdc; color: #8b8b8b; }
/* ============================================
* AI 图标配色系统(基于 docs/h5_ui/css/ai-icons.css

View File

@@ -15,11 +15,12 @@ Component({
score(val: number) {
const s = val < 0 ? 0 : val > 10 ? 10 : val
let emoji: string
// 分档对齐后端 compute_heart_icon>8.5 💖 / >7 🧡 / >5 💛 / ≤5 💙
if (s > 8.5) {
emoji = '💖' // 粉红色 - 很好
} else if (s >= 6) {
} else if (s > 7) {
emoji = '🧡' // 橙色 - 良好
} else if (s >= 3.5) {
} else if (s > 5) {
emoji = '💛' // 黄色 - 一般
} else {
emoji = '💙' // 蓝色 - 待发展

View File

@@ -1,5 +1,5 @@
.heart-icon {
font-size: 22rpx;
font-size: 24rpx;
line-height: 1;
position: relative;
top: -4rpx;

View File

@@ -65,14 +65,17 @@ Component({
}
},
// 任意评分或内容变化时重新计算 canSave
'serviceScore, returnScore, content, showRating'(
'serviceScore, returnScore, content, showRating, ratingExpanded'(
serviceScore: number,
returnScore: number,
content: string,
showRating: boolean
showRating: boolean,
ratingExpanded: boolean
) {
// 如果不显示评分,只需有内容即可保存;否则需要评分和内容都有
const canSave = showRating
// CHANGE 2026-03-27 | 评分区域收起时不要求评分,只需有内容即可保存
// 评分区域展开时才要求两个评分都 > 0
const needRating = showRating && ratingExpanded
const canSave = needRating
? serviceScore > 0 && returnScore > 0 && content.trim().length > 0
: content.trim().length > 0
this.setData({ canSave })

View File

@@ -1,22 +1,24 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- service-record-card 组件 —— 60天内服务记录单条卡片 -->
<view class="svc-card {{type === 'recharge' ? 'svc-card--recharge' : ''}}" hover-class="svc-card--hover">
<!-- 第一行:左=时间,右=课程+台桌+小时数 -->
<view class="svc-row svc-row1">
<text class="svc-time">{{time}}</text>
<text class="svc-time">{{fmt.safe(time)}}</text>
<view class="svc-right1">
<text class="svc-course-tag svc-course-{{typeClass}}">{{type === 'recharge' ? '充值' : courseLabel}}</text>
<text class="svc-course-tag svc-course-{{typeClass}}">{{type === 'recharge' ? '充值' : fmt.safe(courseLabel)}}</text>
<text class="svc-table-label" wx:if="{{tableNo && type !== 'recharge'}}">{{tableNo}}</text>
<text class="svc-hours" wx:if="{{hours && type !== 'recharge'}}">{{hours}}h</text>
<text class="svc-hours-raw" wx:if="{{hoursRaw && hoursRaw !== hours && type !== 'recharge'}}">折前{{hoursRaw}}h</text>
<text class="svc-hours" wx:if="{{hours && type !== 'recharge'}}">{{fmt.hoursH(hours)}}</text>
<!-- CHANGE 2026-03-27 | 任务B: 折前时长仅在 hoursRaw 有值且与 hours 不同时显示 -->
<text class="svc-hours-raw" wx:if="{{hoursRaw && hoursRaw !== hours && type !== 'recharge'}}">折前{{fmt.hoursH(hoursRaw)}}</text>
</view>
</view>
<!-- 第二行:左=商品,右=金额(含预估/提成) -->
<view class="svc-row svc-row2">
<text class="svc-drinks">{{drinks || '—'}}</text>
<text class="svc-drinks" wx:if="{{drinks}}">🍹 {{fmt.safe(drinks)}}</text>
<view class="svc-income-wrap">
<text class="svc-income-est" wx:if="{{isEstimate && type !== 'recharge'}}">预估</text>
<text class="svc-income-label">{{type === 'recharge' ? '提成' : '到手'}}</text>
<text class="svc-income {{type === 'recharge' ? 'svc-income--recharge' : ''}}">¥{{income}}</text>
<text class="svc-income {{type === 'recharge' ? 'svc-income--recharge' : ''}}">{{fmt.money(income)}}</text>
</view>
</view>
</view>

View File

@@ -90,15 +90,18 @@
color: #b9b9b9;
}
/* 第二行左:商品 */
/* 第二行左:商品最多2行超出省略 */
.svc-drinks {
font-size: 22rpx;
line-height: 29rpx;
color: #8b8b8b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 340rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
margin-right: 20rpx;
}
/* 第二行右:金额 */
@@ -107,6 +110,7 @@
align-items: baseline;
gap: 20rpx;
flex-shrink: 0;
margin-left: auto;
}
.svc-income-label {

View File

@@ -1,10 +1,17 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | 从 globalData.visibleTabs 动态读取可见 tabpageLifetimes.show 刷新 |
*/
/**
* 自定义 tabBar 组件
*
* 微信 custom-tab-bar 机制tabBar 页面由框架自动挂载;
* 非 tabBar 页面(如 board-customer/board-coach可手动引用。
*
* 支持 2/3 按钮动态布局,权限数据由外部注入(当前 mock 为 3 按钮)
* 支持 2/3 按钮动态布局,权限数据从 globalData.visibleTabs 读取
*
* CHANGE 2026-03-23 | 角色路由:从 globalData 动态读取可见 tab
*/
/** tab 路由映射key → url + 是否为 tabBar 页面) */
@@ -14,16 +21,12 @@ const TAB_ROUTES: Record<string, { url: string; isTabBarPage: boolean }> = {
my: { url: '/pages/my-profile/my-profile', isTabBarPage: true },
}
// TODO: 联调时从全局状态/接口获取权限,过滤可见 tab
// 示例const visibleKeys = getApp().globalData.visibleTabs || ['task', 'board', 'my']
const VISIBLE_KEYS = ['task', 'board', 'my']
/** 根据权限过滤后的 tab 列表 */
const TABS = [
/** 全量 tab 定义 */
const ALL_TABS = [
{ key: 'task', label: '任务', icon: '/assets/icons/tab-task-nav.svg', activeIcon: '/assets/icons/tab-task-nav-active.svg' },
{ key: 'board', label: '看板', icon: '/assets/icons/tab-board-nav.svg', activeIcon: '/assets/icons/tab-board-nav-active.svg' },
{ key: 'my', label: '我的', icon: '/assets/icons/tab-my-nav.svg', activeIcon: '/assets/icons/tab-my-nav-active.svg' },
].filter((t) => VISIBLE_KEYS.includes(t.key))
]
Component({
properties: {
@@ -35,14 +38,34 @@ Component({
},
data: {
tabs: TABS,
tabCount: TABS.length,
tabs: ALL_TABS,
tabCount: ALL_TABS.length,
},
lifetimes: {
attached() {
this._refreshTabs()
},
},
pageLifetimes: {
show() {
// 每次页面显示时刷新 tab角色可能因切换门店而变化
this._refreshTabs()
},
},
methods: {
/** 从 globalData.visibleTabs 刷新可见 tab 列表 */
_refreshTabs() {
const app = getApp<IAppOption>()
const visibleKeys = app.globalData.visibleTabs || ['task', 'board', 'my']
const tabs = ALL_TABS.filter((t) => visibleKeys.includes(t.key))
this.setData({ tabs, tabCount: tabs.length })
},
onTap(e: WechatMiniprogram.TouchEvent) {
const key = e.currentTarget.dataset.key as string
// 通过 properties 获取 active避免 this.data 类型推断问题
if (key === (this as unknown as { data: { active: string } }).data.active) return
const route = TAB_ROUTES[key]

View File

@@ -1,4 +1,14 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | approved 跳转改为 getRoleHome(data.role),保存 role 到 globalData 和 Storage |
| 2026-03-23 | 审核流程增强 | onLoad 支持 URL 参数预填重新申请场景rejected 用户留在申请页而非跳走 |
| 2026-03-24 | 头像昵称获取 | 新增 chooseAvatar 头像选择 + wx.uploadFile 上传;昵称 input type=nickname |
*/
import { request } from "../../utils/request"
import { getRoleHome, syncVisibleTabs } from "../../utils/auth-guard"
// CHANGE 2026-03-24 | 头像上传:需要 API_BASE 构建上传 URL
import { API_BASE } from "../../utils/config"
Page({
data: {
@@ -9,11 +19,26 @@ Page({
employeeNumber: "",
nickname: "",
submitting: false,
// CHANGE 2026-03-24 | 头像chooseAvatar 临时路径(用于预览和上传)
avatarTempPath: "",
avatarUploaded: false,
},
onLoad() {
onLoad(options?: Record<string, string>) {
const { statusBarHeight } = wx.getWindowInfo()
this.setData({ statusBarHeight })
// CHANGE 2026-03-23 | 审核流程增强:支持从重新申请跳转时预填上次信息
if (options) {
const prefill: Record<string, string> = {}
if (options.siteCode) prefill.siteCode = decodeURIComponent(options.siteCode)
if (options.role) prefill.role = decodeURIComponent(options.role)
if (options.phone) prefill.phone = decodeURIComponent(options.phone)
if (options.employeeNumber) prefill.employeeNumber = decodeURIComponent(options.employeeNumber)
if (Object.keys(prefill).length > 0) {
this.setData(prefill)
}
}
},
onShow() {
@@ -34,22 +59,29 @@ Page({
needAuth: true,
})
const app = getApp<IAppOption>()
// CHANGE 2026-03-23 | 角色路由:保存 role 并按角色跳转
// CHANGE 2026-03-23 | camelCase 修复:后端 CamelModel 序列化输出 camelCase
app.globalData.authUser = {
userId: data.user_id,
userId: data.userId,
status: data.status,
nickname: data.nickname,
// CHANGE 2026-03-24 | 头像:保存 avatarUrl 到 globalData
avatarUrl: data.avatarUrl || undefined,
role: data.role || undefined,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userId", data.userId)
wx.setStorageSync("userStatus", data.status)
if (data.role) wx.setStorageSync("userRole", data.role)
switch (data.status) {
case "new":
break
case "rejected":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
// 允许 new 和 rejected 用户留在申请页填表
break
case "approved":
wx.reLaunch({ url: "/pages/mvp/mvp" })
syncVisibleTabs(data.role)
// CHANGE 2026-03-24 | 防御approved 但 role 为空时跳个人页,不跳登录页
wx.reLaunch({ url: data.role ? getRoleHome(data.role) : '/pages/my-profile/my-profile' })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
@@ -88,13 +120,67 @@ Page({
this.setData({ nickname: e.detail.value })
},
// CHANGE 2026-03-24 | 头像chooseAvatar 回调,保存临时路径
onChooseAvatar(e: any) {
const { avatarUrl } = e.detail
if (avatarUrl) {
this.setData({ avatarTempPath: avatarUrl, avatarUploaded: false })
}
},
/**
* CHANGE 2026-03-24 | 头像上传wx.uploadFile 将临时文件上传到后端
* chooseAvatar 返回的是临时路径http://tmp/xxx.jpeg必须上传持久化
* CHANGE 2026-03-24 | bug 修复:添加详细错误日志,修复 statusCode 类型比较
*/
_uploadAvatar(): Promise<boolean> {
const { avatarTempPath } = this.data
if (!avatarTempPath) return Promise.resolve(true) // 无头像不阻塞提交
if (this.data.avatarUploaded) return Promise.resolve(true)
const token = wx.getStorageSync("token")
const uploadUrl = `${API_BASE}/api/xcx/avatar/upload`
console.log("[avatar] 开始上传", { uploadUrl, filePath: avatarTempPath })
return new Promise((resolve) => {
wx.uploadFile({
url: uploadUrl,
filePath: avatarTempPath,
name: "file",
header: { Authorization: `Bearer ${token}` },
success: (res) => {
console.log("[avatar] 上传响应", { statusCode: res.statusCode, data: res.data })
// CHANGE 2026-03-24 | statusCode 可能是 number 或 string统一用 == 比较
if (res.statusCode == 200) {
this.setData({ avatarUploaded: true })
resolve(true)
} else {
let detail = "头像上传失败"
try {
const body = JSON.parse(res.data)
if (body.detail) detail = body.detail
} catch { /* 忽略解析失败 */ }
wx.showToast({ title: detail, icon: "none" })
resolve(false)
}
},
fail: (err) => {
console.error("[avatar] 上传失败", err)
wx.showToast({ title: "头像上传失败,请检查网络", icon: "none" })
resolve(false)
},
})
})
},
async onSubmit() {
if (this.data.submitting) return
const { siteCode, role, phone, nickname, employeeNumber } = this.data
if (!siteCode.trim()) {
wx.showToast({ title: "请输入球房ID", icon: "none" })
// CHANGE 2026-03-23 | 球房ID 改为 6 位字母/数字
if (!/^[A-Za-z0-9]{6}$/.test(siteCode.trim())) {
wx.showToast({ title: "请输入6位球房ID", icon: "none" })
return
}
if (!role.trim()) {
@@ -113,6 +199,15 @@ Page({
this.setData({ submitting: true })
try {
// CHANGE 2026-03-24 | 头像:提交前先上传头像(如有)
if (this.data.avatarTempPath && !this.data.avatarUploaded) {
const uploaded = await this._uploadAvatar()
if (!uploaded) {
this.setData({ submitting: false })
return
}
}
await request({
url: "/api/xcx/apply",
method: "POST",

View File

@@ -49,13 +49,28 @@
<!-- 表单区域 -->
<view class="form-card">
<!-- CHANGE 2026-03-24 | 头像chooseAvatar 头像选择区域 -->
<view class="form-item form-item--border avatar-section">
<view class="form-label">
<text>头像</text>
<text class="optional-tag">选填</text>
</view>
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-preview">
<image class="avatar-img" src="{{avatarTempPath || '/assets/images/avatar-coach.png'}}" mode="aspectFill" />
<view class="avatar-camera">
<t-icon name="camera" size="24rpx" color="#fff" />
</view>
</view>
</button>
</view>
<!-- 球房ID -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>球房ID</text>
</view>
<input class="form-input" type="text" placeholder="请输入球房ID" value="{{siteCode}}" maxlength="5" bindinput="onSiteCodeInput" />
<input class="form-input" type="text" placeholder="请输入球房ID" value="{{siteCode}}" maxlength="6" bindinput="onSiteCodeInput" />
</view>
<!-- 申请身份 -->
<view class="form-item form-item--border">
@@ -82,12 +97,13 @@
<input class="form-input" type="text" placeholder="请输入编号" value="{{employeeNumber}}" maxlength="50" bindinput="onEmployeeNumberInput" />
</view>
<!-- 昵称 -->
<!-- CHANGE 2026-03-24 | 昵称type="nickname" 支持微信昵称快捷填入(基础库 ≥ 2.25.3 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>昵称</text>
</view>
<input class="form-input" type="text" placeholder="请输入昵称" value="{{nickname}}" maxlength="50" bindinput="onNicknameInput" />
<input class="form-input" type="nickname" placeholder="请输入昵称" value="{{nickname}}" maxlength="50" bindinput="onNicknameInput" />
</view>
</view>

View File

@@ -152,6 +152,60 @@
margin-bottom: 24rpx;
}
/* ---- CHANGE 2026-03-24 | 头像选择区域 ---- */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-section .form-label {
align-self: flex-start;
}
.avatar-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
line-height: normal;
/* 覆盖 button 默认样式 */
font-size: inherit;
color: inherit;
overflow: visible;
}
.avatar-btn::after {
display: none;
}
.avatar-preview {
position: relative;
width: 128rpx;
height: 128rpx;
}
.avatar-img {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 4rpx solid var(--bg-tertiary);
}
.avatar-camera {
position: absolute;
right: 0;
bottom: 0;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid var(--bg-secondary);
}
/* ---- 表单卡片 rounded-2xl=16px→28rpx ---- */
.form-card {
background: var(--bg-secondary);
@@ -177,7 +231,7 @@
margin-bottom: 14rpx;
font-size: 24rpx;
font-weight: 500;
color: var(--text-primary);
color: var(--color-gray-10);
}
/* text-sm=14px→24rpx */
@@ -204,7 +258,7 @@
border-radius: 22rpx;
border: 2rpx solid var(--bg-tertiary);
font-size: 24rpx;
font-weight: 300;
font-weight: 700;
color: var(--text-primary);
box-sizing: border-box;
}

View File

@@ -2,11 +2,14 @@
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SKILL_OPTIONS value 从
// all/chinese/snooker/mahjong/karaoke 改为 ALL/BILLIARD/SNOOKER/MAHJONG/KTV
// 与后端枚举和数据库 category_code 一致。
// - 2026-03-23 | Prompt: 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫
// 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片
// TODO: 联调时替换 mock 数据为真实 API 调用
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
// CHANGE 2026-03-28 | P4 联调Mock 真实 API
import { fetchBoardCoaches } from '../../services/api'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { nameToAvatarColor } from '../../utils/avatar-color'
export {}
@@ -98,73 +101,29 @@ interface CoachItem {
taskCallback: number
}
/** Mock 数据(忠于 H5 原型 6 位助教) */
/** Mock 数据 — 2 个空字段骨架项,用于排查页面每个字段位置是否被覆盖 */
const MOCK_COACHES: CoachItem[] = [
{
id: 'c1', name: '小燕', initial: '',
avatarGradient: 'blue',
level: 'star', levelClass: LEVEL_CLASS['star'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💖 王先生', '💖 李女士', '💛 赵总'],
perfHours: 86.2, perfHoursBefore: 92.0, perfGap: '距升档 13.8h', perfReached: false,
salary: 12680, salaryPerfHours: 86.2, salaryPerfBefore: 92.0,
svAmount: 45200, svCustomerCount: 18, svConsume: 8600,
taskRecall: 18, taskCallback: 14,
},
{
id: 'c2', name: '泡芙', initial: '泡',
avatarGradient: 'green',
level: 'senior', levelClass: LEVEL_CLASS['senior'],
skills: [{ text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 陈先生', '💛 刘女士', '💛 黄总'],
perfHours: 72.5, perfHoursBefore: 78.0, perfGap: '距升档 7.5h', perfReached: false,
salary: 10200, salaryPerfHours: 72.5, salaryPerfBefore: 78.0,
svAmount: 38600, svCustomerCount: 15, svConsume: 6200,
taskRecall: 15, taskCallback: 13,
},
{
id: 'c3', name: 'Lucy', initial: 'A',
avatarGradient: 'pink',
level: 'star', levelClass: LEVEL_CLASS['star'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 张先生', '💛 周女士', '💛 吴总'],
perfHours: 68.0, perfHoursBefore: 72.5, perfGap: '距升档 32.0h', perfReached: false,
salary: 9800, salaryPerfHours: 68.0, salaryPerfBefore: 72.5,
svAmount: 32100, svCustomerCount: 14, svConsume: 5800,
taskRecall: 12, taskCallback: 13,
},
{
id: 'c4', name: 'Mia', initial: 'M',
avatarGradient: 'amber',
level: 'middle', levelClass: LEVEL_CLASS['middle'],
skills: [{ text: '🀄', cls: SKILL_CLASS['🀄'] }],
topCustomers: ['💛 赵先生', '💛 吴女士', '💛 孙总'],
perfHours: 55.0, perfGap: '距升档 5.0h', perfReached: false,
salary: 7500, salaryPerfHours: 55.0,
svAmount: 28500, svCustomerCount: 12, svConsume: 4100,
taskRecall: 10, taskCallback: 10,
},
{
id: 'c5', name: '糖糖', initial: '糖',
avatarGradient: 'violet',
id: '', name: '', initial: '',
avatarGradient: '',
level: 'junior', levelClass: LEVEL_CLASS['junior'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💛 钱先生', '💛 孙女士', '💛 周总'],
perfHours: 42.0, perfHoursBefore: 45.0, perfReached: true,
salary: 6200, salaryPerfHours: 42.0, salaryPerfBefore: 45.0,
svAmount: 22000, svCustomerCount: 10, svConsume: 3500,
taskRecall: 8, taskCallback: 10,
skills: [{ text: '', cls: '' }],
topCustomers: ['', ''],
perfHours: 0, perfHoursBefore: 0, perfGap: '', perfReached: false,
salary: 0, salaryPerfHours: 0, salaryPerfBefore: 0,
svAmount: 0, svCustomerCount: 0, svConsume: 0,
taskRecall: 0, taskCallback: 0,
},
{
id: 'c6', name: '露露', initial: '',
avatarGradient: 'cyan',
id: '', name: '', initial: '',
avatarGradient: '',
level: 'middle', levelClass: LEVEL_CLASS['middle'],
skills: [{ text: '🎤', cls: SKILL_CLASS['🎤'] }],
topCustomers: ['💛 郑先生', '💛 冯女士', '💛 陈总'],
perfHours: 38.0, perfGap: '距升档 22.0h', perfReached: false,
salary: 5100, salaryPerfHours: 38.0,
svAmount: 18300, svCustomerCount: 9, svConsume: 2800,
taskRecall: 6, taskCallback: 9,
skills: [{ text: '', cls: '' }],
topCustomers: ['', ''],
perfHours: 0, perfGap: '', perfReached: false,
salary: 0, salaryPerfHours: 0,
svAmount: 0, svCustomerCount: 0, svConsume: 0,
taskRecall: 0, taskCallback: 0,
},
]
@@ -173,9 +132,12 @@ Page({
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'coach' as 'finance' | 'customer' | 'coach',
// CHANGE 2026-03-27 | 权限改造 W5动态看板二级 tab
boardTabs: [] as Array<{ key: string; label: string; active: boolean }>,
selectedSort: 'perf_desc',
sortOptions: SORT_OPTIONS,
selectedSkill: 'all',
selectedSkill: 'ALL',
skillOptions: SKILL_OPTIONS,
selectedTime: 'month',
timeOptions: TIME_OPTIONS,
@@ -186,6 +148,13 @@ Page({
coaches: [] as CoachItem[],
allCoaches: [] as CoachItem[],
/** 分页状态 */
currentPage: 1,
pageSize: 20,
totalCount: 0,
hasMore: true,
isLoadingMore: false,
/** 筛选栏可见性(滚动隐藏/显示) */
filterBarVisible: true,
},
@@ -198,7 +167,21 @@ Page({
this.loadData()
},
onShow() {
// CHANGE 2026-03-27 | 权限改造 W5checkPageAccess 完成后再刷新 boardTabs
// checkPageAccess 内部会调用 /api/xcx/me 并同步 permissions 到 globalData
checkPageAccess('pages/board-coach/board-coach').then((allowed) => {
if (!allowed) return
const TAB_LABELS: Record<string, string> = { finance: '财务', customer: '客户', coach: '助教' }
const visibleKeys = getVisibleBoardTabs()
this.setData({
boardTabs: visibleKeys.map(k => ({ key: k, label: TAB_LABELS[k] || k, active: k === 'coach' })),
})
})
},
onPullDownRefresh() {
this.setData({ currentPage: 1, hasMore: true, coaches: [], allCoaches: [] })
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
@@ -234,43 +217,103 @@ Page({
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
const data = MOCK_COACHES
if (!data || data.length === 0) {
this.setData({ pageState: 'empty' })
return
}
// 格式化数字字段为展示字符串
const enriched = data.map((c) => ({
...c,
perfHoursLabel: formatHours(c.perfHours),
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
salaryLabel: formatMoney(c.salary),
salaryPerfHoursLabel: formatHours(c.salaryPerfHours),
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
svAmountLabel: formatMoney(c.svAmount),
svCustomerCountLabel: formatCount(c.svCustomerCount, '人'),
svConsumeLabel: formatMoney(c.svConsume),
taskRecallLabel: formatCount(c.taskRecall, '次'),
taskCallbackLabel: formatCount(c.taskCallback, '次'),
}))
this.setData({ allCoaches: enriched, coaches: enriched, pageState: 'normal' })
} catch {
this.setData({ pageState: 'error' })
// CHANGE 2026-03-28 | P4 联调:替换 Mock 为真实 API 调用
// CHANGE 2026-03-29 | 懒加载:支持分页追加
async loadData() {
const page = this.data.currentPage
const isFirstPage = page === 1
if (isFirstPage) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
} else {
if (this.data.isLoadingMore) return
this.setData({ isLoadingMore: true })
}
try {
const res = await fetchBoardCoaches({
sort: this.data.selectedSort,
skill: this.data.selectedSkill,
time: this.data.selectedTime,
page,
pageSize: this.data.pageSize,
})
const items = res.items
if (isFirstPage && (!items || items.length === 0)) {
this.setData({ pageState: 'empty', totalCount: 0, hasMore: false })
return
}
}, 400)
// 非首页返回空数据,标记到底
if (!isFirstPage && (!items || items.length === 0)) {
this.setData({ hasMore: false })
return
}
// 后端返回 camelCase映射为展示字段传原始数字WXML 用 WXS 格式化)
const enriched = items.map((c: any) => ({
...c,
levelClass: LEVEL_CLASS[c.level] || '',
avatarGradient: nameToAvatarColor(String(c.id)),
skills: c.skills || [],
perfHoursLabel: formatHours(c.perfHours ?? 0),
perfHoursBeforeLabel: c.perfHoursBefore ? formatHours(c.perfHoursBefore) : undefined,
salaryLabel: formatMoney(c.salary ?? 0),
salaryPerfHoursLabel: formatHours(c.salaryPerfHours ?? 0),
salaryPerfBeforeLabel: c.salaryPerfBefore ? formatHours(c.salaryPerfBefore) : undefined,
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, '次'),
}))
// 追加时按 id 去重,避免 wx:key 重复警告
let merged: any[]
if (isFirstPage) {
merged = enriched
} else {
const existIds = new Set(this.data.coaches.map((c: any) => c.id))
const newItems = enriched.filter((c: any) => !existIds.has(c.id))
merged = [...this.data.coaches, ...newItems]
}
// items 不足一页 或 已达 total标记到底
const hasMore = items.length >= this.data.pageSize && merged.length < res.total
this.setData({
allCoaches: merged,
coaches: merged,
totalCount: res.total,
hasMore,
pageState: 'normal',
})
} catch (e) {
console.error('[board-coach] loadData 失败:', e)
if (isFirstPage) this.setData({ pageState: 'error' })
} finally {
if (isFirstPage) wx.hideLoading()
this.setData({ isLoadingMore: false })
}
},
/** 触底加载下一页 */
onReachBottom() {
if (!this.data.hasMore || this.data.isLoadingMore) return
this.setData({ currentPage: this.data.currentPage + 1 })
this.loadData()
},
/** 看板 Tab 切换 — CHANGE 2026-03-29 | P1redirectTo 替代 navigateTo避免页面栈堆积 */
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'finance') {
wx.switchTab({ url: '/pages/board-finance/board-finance' })
} else if (tab === 'customer') {
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
if (tab === 'coach') return
const routes: Record<string, { url: string; isTab: boolean }> = {
finance: { url: '/pages/board-finance/board-finance', isTab: true },
customer: { url: '/pages/board-customer/board-customer', isTab: false },
coach: { url: '/pages/board-coach/board-coach', isTab: false },
}
const route = routes[tab]
if (!route) return
if (route.isTab) { wx.switchTab({ url: route.url }) } else { wx.redirectTo({ url: route.url }) }
},
onSortChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
@@ -278,15 +321,19 @@ Page({
this.setData({
selectedSort: val,
dimType: SORT_TO_DIM[val] || 'perf',
currentPage: 1, hasMore: true, coaches: [], allCoaches: [],
})
this.loadData()
},
onSkillChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedSkill: e.detail.value })
this.setData({ selectedSkill: e.detail.value, currentPage: 1, hasMore: true, coaches: [], allCoaches: [] })
this.loadData()
},
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedTime: e.detail.value })
this.setData({ selectedTime: e.detail.value, currentPage: 1, hasMore: true, coaches: [], allCoaches: [] })
this.loadData()
},
onRetry() {

View File

@@ -1,15 +1,9 @@
<!-- 助教看板页 — 忠于 H5 原型结构 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- CHANGE 2026-03-21 | P13 B: 引入 WXS 格式化工具 -->
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-empty description="暂无助教数据" />
</view>
@@ -21,16 +15,16 @@
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab -->
<view class="board-tabs">
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
<text>财务</text>
</view>
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
<text>客户</text>
</view>
<view class="board-tab board-tab--active" data-tab="coach">
<text>助教</text>
<!-- CHANGE 2026-03-27 | 权限改造 W5动态看板二级 tab均分宽度居中 -->
<view class="board-tabs board-tabs--{{boardTabs.length}}">
<view
wx:for="{{boardTabs}}"
wx:key="key"
class="board-tab {{item.active ? 'board-tab--active' : ''}}"
data-tab="{{item.key}}"
bindtap="{{item.active ? '' : 'onTabChange'}}"
>
<text>{{item.label}}</text>
</view>
</view>
@@ -68,25 +62,25 @@
<!-- 定档业绩维度 -->
<view class="card-right" wx:if="{{dimType === 'perf'}}">
<text class="right-text">定档 <text class="right-highlight">{{item.perfHoursLabel}}</text></text>
<text class="right-sub" wx:if="{{item.perfHoursBeforeLabel}}">折前 <text class="right-sub-val">{{item.perfHoursBeforeLabel}}</text></text>
<text class="right-text">定档 <text class="right-highlight">{{fmt.safe(item.perfHoursLabel)}}</text></text>
<text class="right-sub" wx:if="{{item.perfHoursBeforeLabel}}">折前 <text class="right-sub-val">{{fmt.safe(item.perfHoursBeforeLabel)}}</text></text>
</view>
<!-- 工资维度 -->
<view class="card-right" wx:elif="{{dimType === 'salary'}}">
<text class="salary-tag">预估</text>
<text class="salary-amount">{{item.salaryLabel}}</text>
<text class="salary-amount">{{fmt.safe(item.salaryLabel)}}</text>
</view>
<!-- 客源储值维度 -->
<view class="card-right" wx:elif="{{dimType === 'sv'}}">
<text class="right-sub">储值</text>
<text class="salary-amount">{{item.svAmountLabel}}</text>
<text class="salary-amount">{{fmt.safe(item.svAmountLabel)}}</text>
</view>
<!-- 任务维度 -->
<view class="card-right" wx:elif="{{dimType === 'task'}}">
<text class="right-text">召回 <text class="right-highlight">{{item.taskRecallLabel}}</text></text>
<text class="right-text">召回 <text class="right-highlight">{{fmt.safe(item.taskRecallLabel)}}</text></text>
</view>
</view>
@@ -99,30 +93,39 @@
</view>
<!-- 定档业绩:距升档/已达标 -->
<text class="bottom-right bottom-right--warning" wx:if="{{dimType === 'perf' && !item.perfReached}}">{{item.perfGap}}</text>
<text class="bottom-right bottom-right--warning" wx:if="{{dimType === 'perf' && !item.perfReached}}">{{fmt.safe(item.perfGap)}}</text>
<text class="bottom-right bottom-right--success" wx:elif="{{dimType === 'perf' && item.perfReached}}">✅ 已达标</text>
<!-- 工资:定档/折前 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'salary'}}">
<text class="bottom-perf">定档 <text class="bottom-perf-val">{{item.salaryPerfHoursLabel}}</text></text>
<text class="bottom-sub" wx:if="{{item.salaryPerfBeforeLabel}}">折前 <text class="bottom-sub-val">{{item.salaryPerfBeforeLabel}}</text></text>
<text class="bottom-perf">定档 <text class="bottom-perf-val">{{fmt.safe(item.salaryPerfHoursLabel)}}</text></text>
<text class="bottom-sub" wx:if="{{item.salaryPerfBeforeLabel}}">折前 <text class="bottom-sub-val">{{fmt.safe(item.salaryPerfBeforeLabel)}}</text></text>
</view>
<!-- 客源储值:客户数 | 消耗 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'sv'}}">
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{item.svCustomerCountLabel}}</text></text>
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{fmt.safe(item.svCustomerCountLabel)}}</text></text>
<text class="bottom-divider">|</text>
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{item.svConsumeLabel}}</text></text>
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{fmt.safe(item.svConsumeLabel)}}</text></text>
</view>
<!-- 任务:回访数 -->
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{item.taskCallbackLabel}}</text></text>
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{fmt.safe(item.taskCallbackLabel)}}</text></text>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view class="load-more" wx:if="{{isLoadingMore}}">
<t-loading theme="circular" size="40rpx" />
<text class="load-more-text">加载中...</text>
</view>
<view class="load-more" wx:elif="{{!hasMore && coaches.length > 0}}">
<text class="load-more-text load-more-text--end">已加载全部</text>
</view>
<!-- 底部安全区(为自定义导航栏留空间) -->
<view class="safe-bottom"></view>
</block>

View File

@@ -331,6 +331,22 @@
color: #c5c5c5;
}
/* ===== 加载更多 ===== */
.load-more {
display: flex;
align-items: center;
justify-content: center;
padding: 36rpx 0;
gap: 12rpx;
}
.load-more-text {
font-size: 24rpx;
color: #c5c5c5;
}
.load-more-text--end {
color: #e0e0e0;
}
/* ===== 底部安全区(自定义导航栏高度 100rpx + safe-area ===== */
.safe-bottom {
height: 200rpx;

View File

@@ -1,6 +1,13 @@
// 客户看板页 — 8 个维度查看前 100 名客户
// TODO: 联调时替换 mock 数据为真实 API 调用
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
// CHANGE 2026-03-28 | P5 联调Mock → 真实 API
import { fetchBoardCustomers } from '../../services/api'
import { initPageAiColor } from '../../utils/ai-color-manager'
import { nameToAvatarColor } from '../../utils/avatar-color'
export {}
@@ -29,12 +36,14 @@ const DIMENSION_OPTIONS = [
{ value: 'loyal', text: '最专一 近60天' },
]
// CHANGE 2026-03-28 | P9 修复value 改为数据库 category_code与后端枚举一致。
// 与 board-coach SKILL_OPTIONS 保持同步2026-03-20 已修正)。
const PROJECT_OPTIONS = [
{ value: 'all', text: '全部' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
{ value: 'ALL', text: '全部' },
{ value: 'BILLIARD', text: '🎱 中式/追分' },
{ value: 'SNOOKER', text: '斯诺克' },
{ value: 'MAHJONG', text: '🀄 麻将/棋牌' },
{ value: 'KTV', text: '🎤 团建/K歌' },
]
interface AssistantInfo {
@@ -102,92 +111,58 @@ interface CustomerItem {
assistants: AssistantInfo[]
}
/** Mock 数据(忠于 H5 原型 3 位客户) */
/** Mock 数据 — 2 个空字段骨架项,用于排查字段为空时的渲染表现 */
const MOCK_CUSTOMERS: CustomerItem[] = [
{
id: 'u1', name: '王先生', initial: '', avatarCls: 'avatar--amber',
idealDays: 7, elapsedDays: 15, overdueDays: 8,
visits30d: 3, balance: '¥2,680', recallIndex: '9.2',
id: '', name: '', initial: '', avatarCls: '',
idealDays: 0, elapsedDays: 0, overdueDays: 0,
visits30d: 0, balance: '', recallIndex: '',
potentialTags: [
{ text: '高频', theme: 'primary' },
{ text: '高客单', theme: 'warning' },
{ text: '', theme: '' },
],
spend30d: '¥4,200', avgVisits: '6.2次', avgSpend: '¥680',
lastVisit: '3天前', monthlyConsume: '¥3,500', availableMonths: '约0.8个月',
lastRecharge: '2月15日', rechargeAmount: '¥5,000', recharges60d: '2次', currentBalance: '¥2,680',
spend60d: '¥8,400', visits60d: '12', highSpendTag: true,
avgInterval: '5.0天', intimacy: '92',
topCoachName: '小燕', topCoachHeart: 9.2, topCoachScore: '9.2',
spend30d: '', avgVisits: '', avgSpend: '',
lastVisit: '', monthlyConsume: '', availableMonths: '',
lastRecharge: '', rechargeAmount: '', recharges60d: '', currentBalance: '',
spend60d: '', visits60d: '', highSpendTag: false,
avgInterval: '', intimacy: '',
topCoachName: '', topCoachHeart: 0, topCoachScore: '',
coachDetails: [
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '', avgDuration: '2.3h', serviceCount: '14', coachSpend: '¥4,200', relationIdx: 9.2 },
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5, avgDuration: '1.5h', serviceCount: '8', coachSpend: '¥2,100', relationIdx: 7.2 },
{ name: '', cls: '', heartScore: 0, badge: '', avgDuration: '', serviceCount: '', coachSpend: '', relationIdx: 0 },
],
weeklyVisits: [
{ val: 2, pct: 60 }, { val: 2, pct: 60 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
{ val: 2, pct: 60 }, { val: 3, pct: 100 }, { val: 1, pct: 30 }, { val: 3, pct: 100 },
], coachName: '小燕', coachRatio: '78%',
visitFreq: '6.2次/月',
daysAgo: 3,
{ val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 },
{ val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 },
], coachName: '', coachRatio: '',
visitFreq: '',
daysAgo: 0,
assistants: [
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '', badgeCls: 'assistant-badge--follow' },
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5 },
{ name: '', cls: '', heartScore: 0, badge: '', badgeCls: '' },
],
},
{
id: 'u2', name: '李女士', initial: '', avatarCls: 'avatar--pink',
idealDays: 10, elapsedDays: 22, overdueDays: 12,
visits30d: 1, balance: '¥8,200', recallIndex: '8.5',
id: '', name: '', initial: '', avatarCls: '',
idealDays: 0, elapsedDays: 0, overdueDays: 0,
visits30d: 0, balance: '', recallIndex: '',
potentialTags: [
{ text: '高余额', theme: 'success' },
{ text: '低频', theme: 'gray' },
{ text: '', theme: '' },
],
spend30d: '¥1,800', avgVisits: '2.1次', avgSpend: '¥860',
lastVisit: '12天前', monthlyConsume: '¥1,800', availableMonths: '约4.6个月',
lastRecharge: '1月20日', rechargeAmount: '¥10,000', recharges60d: '1次', currentBalance: '¥8,200',
spend60d: '¥3,600', visits60d: '4', highSpendTag: false,
avgInterval: '15.0天', intimacy: '68',
topCoachName: 'Lucy', topCoachHeart: 8.5, topCoachScore: '8.5',
spend30d: '', avgVisits: '', avgSpend: '',
lastVisit: '', monthlyConsume: '', availableMonths: '',
lastRecharge: '', rechargeAmount: '', recharges60d: '', currentBalance: '',
spend60d: '', visits60d: '', highSpendTag: false,
avgInterval: '', intimacy: '',
topCoachName: '', topCoachHeart: 0, topCoachScore: '',
coachDetails: [
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '', avgDuration: '1.8h', serviceCount: '10', coachSpend: '¥3,600', relationIdx: 8.5 },
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', avgDuration: '0.8h', serviceCount: '3', coachSpend: '¥600', relationIdx: 4.2 },
{ name: '', cls: '', heartScore: 0, badge: '', avgDuration: '', serviceCount: '', coachSpend: '', relationIdx: 0 },
],
weeklyVisits: [
{ val: 1, pct: 40 }, { val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 },
{ val: 1, pct: 40 }, { val: 0, pct: 10 }, { val: 1, pct: 40 }, { val: 1, pct: 40 },
], coachName: 'Lucy', coachRatio: '62%',
visitFreq: '2.1次/月',
daysAgo: 12,
{ val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 },
{ val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 }, { val: 0, pct: 0 },
], coachName: '', coachRatio: '',
visitFreq: '',
daysAgo: 0,
assistants: [
{ name: 'Lucy', cls: 'assistant--assignee', heartScore: 8.5, badge: '', badgeCls: 'assistant-badge--follow' },
{ name: '小燕', cls: 'assistant--abandoned', heartScore: 4.2, badge: '弃', badgeCls: 'assistant-badge--drop' },
],
},
{
id: 'u3', name: '张先生', initial: '张', avatarCls: 'avatar--blue',
idealDays: 5, elapsedDays: 8, overdueDays: 3,
visits30d: 5, balance: '¥1,200', recallIndex: '7.8',
potentialTags: [
{ text: '高频', theme: 'primary' },
],
spend30d: '¥3,500', avgVisits: '8.0次', avgSpend: '¥440',
lastVisit: '1天前', monthlyConsume: '¥3,200', availableMonths: '约0.4个月',
lastRecharge: '3月1日', rechargeAmount: '¥3,000', recharges60d: '3次', currentBalance: '¥1,200',
spend60d: '¥7,000', visits60d: '16', highSpendTag: true,
avgInterval: '3.8天', intimacy: '95',
topCoachName: '泡芙', topCoachHeart: 9.5, topCoachScore: '9.5',
coachDetails: [
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', avgDuration: '2.1h', serviceCount: '11', coachSpend: '¥3,300', relationIdx: 9.5 },
{ name: '小燕', cls: 'assistant--normal', heartScore: 6.8, avgDuration: '1.3h', serviceCount: '6', coachSpend: '¥1,800', relationIdx: 6.8 },
{ name: 'Lucy', cls: 'assistant--abandoned', heartScore: 3.2, badge: '弃', avgDuration: '0.5h', serviceCount: '2', coachSpend: '¥400', relationIdx: 3.2 },
],
weeklyVisits: [
{ val: 2, pct: 50 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 4, pct: 100 },
{ val: 3, pct: 75 }, { val: 3, pct: 75 }, { val: 2, pct: 50 }, { val: 3, pct: 75 },
], coachName: '泡芙', coachRatio: '85%',
visitFreq: '8.0次/月',
daysAgo: 1,
assistants: [
{ name: '泡芙', cls: 'assistant--assignee', heartScore: 9.5, badge: '跟', badgeCls: 'assistant-badge--follow' },
{ name: '', cls: '', heartScore: 0, badge: '', badgeCls: '' },
],
},
]
@@ -197,9 +172,12 @@ Page({
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
activeTab: 'customer' as 'finance' | 'customer' | 'coach',
// CHANGE 2026-03-27 | 权限改造 W5动态看板二级 tab
boardTabs: [] as Array<{ key: string; label: string; active: boolean }>,
selectedDimension: 'recall',
dimensionOptions: DIMENSION_OPTIONS,
selectedProject: 'all',
selectedProject: 'ALL',
projectOptions: PROJECT_OPTIONS,
/** 当前维度类型,控制卡片模板 */
@@ -209,6 +187,12 @@ Page({
allCustomers: [] as CustomerItem[],
totalCount: 0,
/** 分页状态 */
currentPage: 1,
pageSize: 20,
hasMore: true,
isLoadingMore: false,
/** 筛选栏可见性 */
filterBarVisible: true,
},
@@ -221,7 +205,21 @@ Page({
this.loadData()
},
onShow() {
// CHANGE 2026-03-27 | 权限改造 W5checkPageAccess 完成后再刷新 boardTabs
// checkPageAccess 内部会调用 /api/xcx/me 并同步 permissions 到 globalData
checkPageAccess('pages/board-customer/board-customer').then((allowed) => {
if (!allowed) return
const TAB_LABELS: Record<string, string> = { finance: '财务', customer: '客户', coach: '助教' }
const visibleKeys = getVisibleBoardTabs()
this.setData({
boardTabs: visibleKeys.map(k => ({ key: k, label: TAB_LABELS[k] || k, active: k === 'customer' })),
})
})
},
onPullDownRefresh() {
this.setData({ currentPage: 1, hasMore: true, customers: [], allCustomers: [] })
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
@@ -257,38 +255,95 @@ Page({
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
const data = MOCK_CUSTOMERS
if (!data || data.length === 0) {
this.setData({ pageState: 'empty' })
return
}
this.setData({
allCustomers: data,
customers: data,
totalCount: data.length,
pageState: 'normal',
})
} catch {
this.setData({ pageState: 'error' })
// CHANGE 2026-03-28 | P5 联调:替换 Mock 为真实 API 调用
// CHANGE 2026-03-29 | 懒加载:支持分页追加
async loadData() {
const page = this.data.currentPage
const isFirstPage = page === 1
if (isFirstPage) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
} else {
if (this.data.isLoadingMore) return
this.setData({ isLoadingMore: true })
}
try {
const res = await fetchBoardCustomers({
dimension: this.data.selectedDimension,
project: this.data.selectedProject,
page,
pageSize: this.data.pageSize,
})
const items = res.items
if (isFirstPage && (!items || items.length === 0)) {
this.setData({ pageState: 'empty', totalCount: 0, hasMore: false })
return
}
}, 400)
// 非首页返回空数据,标记到底
if (!isFirstPage && (!items || items.length === 0)) {
this.setData({ hasMore: false })
return
}
// 头像颜色 + 项目标签
items.forEach((item: any) => {
item.avatarCls = 'avatar-' + nameToAvatarColor(String(item.id))
const PROJ_SHORT: Record<string, string> = { BILLIARD: '🎱', SNOOKER: '斯', MAHJONG: '🀄', KTV: '🎤' }
const PROJ_CLS: Record<string, string> = { BILLIARD: 'skill--chinese', SNOOKER: 'skill--snooker', MAHJONG: 'skill--mahjong', KTV: 'skill--karaoke' }
item.projectTags = (item.projects || []).map((p: string) => ({ text: PROJ_SHORT[p] || p, cls: PROJ_CLS[p] || '' }))
})
// 追加时按 id 去重,避免 wx:key 重复警告
let merged: any[]
if (isFirstPage) {
merged = items
} else {
const existIds = new Set(this.data.customers.map((c: any) => c.id))
const newItems = items.filter((c: any) => !existIds.has(c.id))
merged = [...this.data.customers, ...newItems]
}
// items 不足一页 或 已达 total标记到底
const hasMore = items.length >= this.data.pageSize && merged.length < res.total
this.setData({
allCustomers: merged,
customers: merged,
totalCount: res.total,
hasMore,
pageState: 'normal',
})
} catch {
if (isFirstPage) this.setData({ pageState: 'error' })
} finally {
if (isFirstPage) wx.hideLoading()
this.setData({ isLoadingMore: false })
}
},
/** 触底加载下一页 */
onReachBottom() {
if (!this.data.hasMore || this.data.isLoadingMore) return
this.setData({ currentPage: this.data.currentPage + 1 })
this.loadData()
},
onRetry() {
this.loadData()
},
/** 看板 Tab 切换 — CHANGE 2026-03-29 | P1redirectTo 替代 navigateTo */
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'finance') {
wx.switchTab({ url: '/pages/board-finance/board-finance' })
} else if (tab === 'coach') {
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
if (tab === 'customer') return
const routes: Record<string, { url: string; isTab: boolean }> = {
finance: { url: '/pages/board-finance/board-finance', isTab: true },
customer: { url: '/pages/board-customer/board-customer', isTab: false },
coach: { url: '/pages/board-coach/board-coach', isTab: false },
}
const route = routes[tab]
if (!route) return
if (route.isTab) { wx.switchTab({ url: route.url }) } else { wx.redirectTo({ url: route.url }) }
},
onDimensionChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
@@ -296,11 +351,14 @@ Page({
this.setData({
selectedDimension: val,
dimType: DIMENSION_TO_DIM[val] || 'recall',
currentPage: 1, hasMore: true, customers: [], allCustomers: [],
})
this.loadData()
},
onProjectChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedProject: e.detail.value })
this.setData({ selectedProject: e.detail.value, currentPage: 1, hasMore: true, customers: [], allCustomers: [] })
this.loadData()
},
onCustomerTap(e: WechatMiniprogram.TouchEvent) {

View File

@@ -1,15 +1,9 @@
<!-- 客户看板页 — 忠于 H5 原型8 维度卡片模板 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- CHANGE 2026-03-21 | P13 B: 引入 WXS 格式化工具 -->
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-empty description="暂无客户数据" />
</view>
@@ -21,16 +15,16 @@
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab -->
<view class="board-tabs">
<view class="board-tab" data-tab="finance" bindtap="onTabChange">
<text>财务</text>
</view>
<view class="board-tab board-tab--active" data-tab="customer">
<text>客户</text>
</view>
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
<text>助教</text>
<!-- CHANGE 2026-03-27 | 权限改造 W5动态看板二级 tab均分宽度居中 -->
<view class="board-tabs board-tabs--{{boardTabs.length}}">
<view
wx:for="{{boardTabs}}"
wx:key="key"
class="board-tab {{item.active ? 'board-tab--active' : ''}}"
data-tab="{{item.key}}"
bindtap="{{item.active ? '' : 'onTabChange'}}"
>
<text>{{item.label}}</text>
</view>
</view>
@@ -60,9 +54,8 @@
<view class="list-header">
<view class="list-header-left">
<text class="list-header-title">客户列表</text>
<text class="list-header-sub">· 前100名</text>
</view>
<text class="list-header-count">共{{totalCount}}名客户</text>
<text class="list-header-count">共{{fmt.safe(totalCount)}}名客户</text>
</view>
<!-- 客户列表 -->
@@ -81,22 +74,27 @@
<text class="avatar-text">{{item.initial}}</text>
</view>
<text class="card-name" wx:if="{{dimType !== 'freq60'}}">{{item.name}}</text>
<!-- CHANGE 2026-03-29 | 客户项目标签(样式同助教看板 skill-tag -->
<text class="skill-tag {{proj.cls}}" wx:for="{{item.projectTags}}" wx:for-item="proj" wx:key="text" wx:if="{{dimType !== 'freq60'}}">{{proj.text}}</text>
<!-- 最频繁:姓名 + 下方小字垂直排列 -->
<view class="card-name-group" wx:if="{{dimType === 'freq60'}}">
<text class="card-name">{{item.name}}</text>
<view style="display:flex;align-items:center;gap:8rpx;">
<text class="card-name">{{item.name}}</text>
<text class="skill-tag {{proj.cls}}" wx:for="{{item.projectTags}}" wx:for-item="proj" wx:key="text">{{proj.text}}</text>
</view>
<view class="card-name-sub">
<text class="mid-text" style="white-space: nowrap;">平均间隔 <text class="mid-primary">{{item.avgInterval}}</text></text>
<text class="mid-text" style="white-space: nowrap;">60天消费 <text class="mid-dark">{{item.spend60d}}</text></text>
<text class="mid-text" style="white-space: nowrap;">平均间隔 <text class="mid-primary">{{fmt.safe(item.avgInterval)}}</text></text>
<text class="mid-text" style="white-space: nowrap;">60天消费 <text class="mid-dark">{{fmt.money(item.spend60d)}}</text></text>
</view>
</view>
<view class="card-header-spacer"></view>
<!-- 最应召回:理想/已过/超期 -->
<view class="header-metrics" wx:if="{{dimType === 'recall'}}">
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
<text class="metric-gray">已过 <text class="metric-error">{{item.elapsedDays}}天</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{fmt.safe(item.idealDays)}}天</text></text>
<text class="metric-gray">已过 <text class="metric-error">{{fmt.safe(item.elapsedDays)}}天</text></text>
<view class="overdue-tag {{item.overdueDays > 7 ? 'overdue-tag--danger' : 'overdue-tag--warn'}}">
<text>超期 {{item.overdueDays}}天</text>
<text>超期 {{fmt.safe(item.overdueDays)}}天</text>
</view>
</view>
@@ -109,20 +107,20 @@
<!-- 最高余额:最近到店/理想 -->
<view class="header-metrics" wx:elif="{{dimType === 'balance'}}">
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
<text class="metric-gray">最近到店 <text class="metric-dark">{{fmt.safe(item.lastVisit)}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{fmt.safe(item.idealDays)}}天</text></text>
</view>
<!-- 最近充值:最近到店/理想 -->
<view class="header-metrics" wx:elif="{{dimType === 'recharge'}}">
<text class="metric-gray">最近到店 <text class="metric-dark">{{item.lastVisit}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{item.idealDays}}天</text></text>
<text class="metric-gray">最近到店 <text class="metric-dark">{{fmt.safe(item.lastVisit)}}</text></text>
<text class="metric-gray">理想 <text class="metric-dark">{{fmt.safe(item.idealDays)}}天</text></text>
</view>
<!-- 最频繁近60天右上角大字到店次数 -->
<view class="header-metrics header-metrics--freq" wx:elif="{{dimType === 'freq60'}}">
<view class="freq-big-num">
<text class="freq-big-val">{{item.visits60d}}</text>
<text class="freq-big-val">{{fmt.safe(item.visits60d)}}</text>
<text class="freq-big-unit">次</text>
</view>
<text class="freq-big-label">60天到店</text>
@@ -131,8 +129,8 @@
<!-- 最专一近60天右上角 ❤️ 助教名 + 关系指数 -->
<view class="header-metrics header-metrics--loyal" wx:elif="{{dimType === 'loyal'}}">
<heart-icon score="{{item.topCoachHeart}}" />
<text class="loyal-top-name">{{item.topCoachName}}</text>
<text class="loyal-top-score">{{item.topCoachScore}}</text>
<text class="loyal-top-name">{{fmt.safe(item.topCoachName)}}</text>
<text class="loyal-top-score">{{fmt.safe(item.topCoachScore)}}</text>
</view>
<!-- 最高消费近60天高消费标签 -->
@@ -145,7 +143,7 @@
<!-- 最近到店:右上角大字 X天前到店 -->
<view class="header-metrics header-metrics--recent" wx:elif="{{dimType === 'recent'}}">
<view class="recent-big-num">
<text class="recent-big-val">{{item.daysAgo}}</text>
<text class="recent-big-val">{{fmt.safe(item.daysAgo)}}</text>
<text class="recent-big-unit">天前到店</text>
</view>
</view>
@@ -155,28 +153,28 @@
<!-- 最应召回30天到店 / 余额 / 召回指数 -->
<view class="card-mid-row" wx:if="{{dimType === 'recall'}}">
<text class="mid-text">30天到店 <text class="mid-dark">{{item.visits30d}}次</text></text>
<text class="mid-text mid-ml">余额 <text class="mid-dark">{{item.balance}}</text></text>
<text class="mid-text mid-right">召回指数 <text class="mid-primary-bold">{{item.recallIndex}}</text></text>
<text class="mid-text">30天到店 <text class="mid-dark">{{item.visits30d || 0}}次</text></text>
<text class="mid-text mid-ml">余额 <text class="mid-dark">{{fmt.money(item.balance)}}</text></text>
<text class="mid-text mid-right">召回指数 <text class="mid-primary-bold">{{fmt.safe(item.recallIndex)}}</text></text>
</view>
<!-- 最大消费潜力4 列网格30天消费用橙色大字和最高余额的余额值一致 -->
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'potential'}}">
<view class="grid-cell">
<text class="grid-label">30天消费</text>
<text class="grid-val grid-val--lg">{{item.spend30d}}</text>
<text class="grid-val grid-val--lg">{{fmt.money(item.spend30d)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均到店</text>
<text class="grid-val">{{item.avgVisits}}</text>
<text class="grid-val">{{fmt.count(item.avgVisits, '次')}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">余额</text>
<text class="grid-val grid-val--success">{{item.balance}}</text>
<text class="grid-val grid-val--success">{{fmt.money(item.balance)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
<text class="grid-val">{{fmt.money(item.avgSpend)}}</text>
</view>
</view>
@@ -184,15 +182,15 @@
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'balance'}}">
<view class="grid-cell">
<text class="grid-label">余额</text>
<text class="grid-val grid-val--warning grid-val--lg">{{item.balance}}</text>
<text class="grid-val grid-val--warning grid-val--lg">{{fmt.money(item.balance)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均消耗</text>
<text class="grid-val">{{item.monthlyConsume}}</text>
<text class="grid-val">{{fmt.money(item.monthlyConsume)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">可用</text>
<text class="grid-val grid-val--success">{{item.availableMonths}}</text>
<text class="grid-val grid-val--success">{{fmt.safe(item.availableMonths)}}</text>
</view>
</view>
@@ -200,19 +198,19 @@
<view class="card-grid card-grid--4" wx:elif="{{dimType === 'recharge'}}">
<view class="grid-cell">
<text class="grid-label">最后充值</text>
<text class="grid-val">{{item.lastRecharge}}</text>
<text class="grid-val">{{fmt.safe(item.lastRecharge)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">充值</text>
<text class="grid-val grid-val--success">{{item.rechargeAmount}}</text>
<text class="grid-val grid-val--success">{{fmt.money(item.rechargeAmount)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">60天充值</text>
<text class="grid-val">{{item.recharges60d}}</text>
<text class="grid-val">{{fmt.count(item.recharges60d, '次')}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">当前余额</text>
<text class="grid-val grid-val--warning">{{item.currentBalance}}</text>
<text class="grid-val grid-val--warning">{{fmt.money(item.currentBalance)}}</text>
</view>
</view>
@@ -220,15 +218,15 @@
<view class="card-grid card-grid--3" wx:elif="{{dimType === 'spend60'}}">
<view class="grid-cell">
<text class="grid-label">近60天消费</text>
<text class="grid-val grid-val--warning grid-val--lg">{{item.spend60d}}</text>
<text class="grid-val grid-val--warning grid-val--lg">{{fmt.money(item.spend60d)}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">到店次数</text>
<text class="grid-val">{{item.visits60d}}</text>
<text class="grid-label">近60天到店</text>
<text class="grid-val">{{fmt.count(item.visits60d, '次')}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
<text class="grid-val">{{fmt.money(item.avgSpend)}}</text>
</view>
</view>
@@ -268,18 +266,18 @@
<text class="assistant-badge assistant-badge--follow" wx:if="{{cd.badge === '跟'}}">跟</text>
<text class="assistant-badge assistant-badge--drop" wx:elif="{{cd.badge === '弃'}}">弃</text>
</view>
<text class="loyal-col loyal-val">{{cd.avgDuration}}</text>
<text class="loyal-col loyal-val">{{cd.serviceCount}}</text>
<text class="loyal-col loyal-val">{{cd.coachSpend}}</text>
<text class="loyal-col {{cd.relationIdx >= 8 ? 'loyal-val--primary' : 'loyal-val--gray'}}">{{cd.relationIdx}}</text>
<text class="loyal-col loyal-val">{{fmt.safe(cd.avgDuration)}}</text>
<text class="loyal-col loyal-val">{{fmt.safe(cd.serviceCount)}}</text>
<text class="loyal-col loyal-val">{{fmt.money(cd.coachSpend)}}</text>
<text class="loyal-col {{cd.relationIdx >= 8 ? 'loyal-val--primary' : 'loyal-val--gray'}}">{{fmt.safe(cd.relationIdx)}}</text>
</view>
</view>
<!-- 最近到店:理想间隔 / 60天到店 / 次均消费 -->
<view class="card-mid-row" wx:elif="{{dimType === 'recent'}}">
<text class="mid-text">理想间隔 <text class="mid-dark">{{item.idealDays}}天</text></text>
<text class="mid-text mid-ml">60天到店 <text class="mid-dark">{{item.visits60d}}</text></text>
<text class="mid-text mid-ml-right">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
<text class="mid-text">理想间隔 <text class="mid-dark">{{fmt.safe(item.idealDays)}}天</text></text>
<text class="mid-text mid-ml">60天到店 <text class="mid-dark">{{fmt.count(item.visits60d, '次')}}</text></text>
<text class="mid-text mid-ml-right">次均消费 <text class="mid-dark">{{fmt.money(item.avgSpend)}}</text></text>
</view>
<!-- ===== 卡片底部:助教行(最专一维度不显示,因为助教信息在表格中) ===== -->
@@ -298,6 +296,15 @@
</view>
</view>
<!-- 加载更多状态 -->
<view class="load-more" wx:if="{{isLoadingMore}}">
<t-loading theme="circular" size="40rpx" />
<text class="load-more-text">加载中...</text>
</view>
<view class="load-more" wx:elif="{{!hasMore && customers.length > 0}}">
<text class="load-more-text load-more-text--end">已加载全部</text>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</block>

View File

@@ -196,6 +196,18 @@
flex-shrink: 0;
}
/* CHANGE 2026-03-29 | 客户项目标签(样式同助教看板) */
.skill-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.skill--chinese { background: rgba(0, 82, 217, 0.1); color: #0052d9; }
.skill--snooker { background: rgba(0, 168, 112, 0.1); color: #00a870; }
.skill--mahjong { background: rgba(237, 123, 47, 0.1); color: #ed7b2f; }
.skill--karaoke { background: rgba(227, 77, 89, 0.1); color: #e34d59; }
/* 最频繁:姓名+小字垂直排列 */
.card-name-group {
display: flex;
@@ -405,7 +417,7 @@ margin-left: auto;
}
.card-grid--4 {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: 1fr 0.6fr 0.5fr 0.6fr;
padding-bottom: 22rpx;
text-align: center;
}
@@ -685,6 +697,22 @@ margin-left: auto;
color: #0052d9;
}
/* ===== 加载更多 ===== */
.load-more {
display: flex;
align-items: center;
justify-content: center;
padding: 36rpx 0;
gap: 12rpx;
}
.load-more-text {
font-size: 24rpx;
color: #c5c5c5;
}
.load-more-text--end {
color: #e0e0e0;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: 200rpx;

View File

@@ -1,77 +1,49 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | board-finance-integration T3.1-T3.3 | _loadGiftRows→_loadData 绑定全部 6 板块;筛选联动调 _loadDataareaOptions 9 项 |
| 2026-03-27 | board-finance-integration T3.1 fix | _loadData 移除 formatMoney所有金额/百分比字段传原始数字,由 WXS 负责格式化,修复双重格式化导致 NaN |
| 2026-07-22 | 财务看板多问题修复 | discountRate/balanceRate ×100 修复百分比计算后端返回小数WXS 期望百分比数字) |
| 2026-03-28 | 环比修复 | _loadData 绑定所有板块的 is_down/is_flatmapExpenseItems/mapCoachTable 读取后端真实涨跌方向 |
*/
// 财务看板页 — 忠于 H5 原型结构
// CHANGE 2026-07-22 | Prompt: gift-card-breakdown Task 9.1 | 直接原因: 赠送卡矩阵 giftRows 从 mock 替换为真实 API 数据
import { checkPageAccess, getVisibleBoardTabs } from '../../utils/auth-guard'
import { getRandomAiColor } from '../../utils/ai-color'
import { fetchBoardFinance } from '../../services/api'
import { formatMoney } from '../../utils/money'
/** 目录板块定义 */
function isCurrentMonthFilter(selectedTime: string): boolean {
return selectedTime === 'month' && new Date().getDate() <= 5
}
interface TocItem {
emoji: string
title: string
sectionId: string
}
/** 指标解释映射 */
const tipContents: Record<string, { title: string; content: string }> = {
occurrence: {
title: '发生额/正价',
content: '所有消费项目按标价计算的总金额,不扣除任何优惠。\n\n即"如果没有任何折扣,客户应付多少"。',
},
discount: {
title: '总优惠',
content: '包含会员折扣、赠送卡抵扣、团购差价等所有优惠金额。\n\n优惠越高实际收入越低。',
},
confirmed: {
title: '成交/确认收入',
content: '发生额减去总优惠后的实际入账金额。\n\n成交收入 = 发生额 - 总优惠',
},
cashIn: {
title: '实收/现金流入',
content: '实际到账的现金,包含消费直接支付和储值充值。\n\n往期为已结算金额本期为截至当前的发生额。',
},
cashOut: {
title: '现金支出',
content: '包含人工、房租、水电、进货等所有经营支出。',
},
balance: {
title: '现金结余',
content: '现金流入减去现金支出。\n\n现金结余 = 现金流入 - 现金支出',
},
rechargeActual: {
title: '储值卡充值实收',
content: '会员储值卡首充和续费的实际到账金额。\n\n不含赠送金额。',
},
firstCharge: {
title: '首充',
content: '新会员首次充值的金额。',
},
renewCharge: {
title: '续费',
content: '老会员续费充值的金额。',
},
consume: {
title: '消耗',
content: '会员使用储值卡消费的金额。',
},
cardBalance: {
title: '储值卡总余额',
content: '所有储值卡的剩余可用余额。',
},
allCardBalance: {
title: '全类别会员卡余额合计',
content: '储值卡 + 赠送卡(酒水卡、台费卡、抵用券)的总余额。\n\n仅供经营参考非财务属性。',
},
occurrence: { title: '发生额/正价', content: '所有消费项目按标价计算的总金额,不扣除任何优惠。\n\n即"如果没有任何折扣,客户应付多少"。' },
discount: { title: '总优惠', content: '包含会员折扣、赠送卡抵扣、团购差价等所有优惠金额。\n\n优惠越高实际收入越低。' },
confirmed: { title: '成交/确认收入', content: '发生额减去总优惠后的实际入账金额。\n\n成交收入 = 发生额 - 总优惠' },
cashIn: { title: '实收/现金流入', content: '实际到账的现金,包含消费直接支付和储值充值。\n\n往期为已结算金额本期为截至当前的发生额。' },
cashOut: { title: '现金支出', content: '包含人工、房租、水电、进货等所有经营支出。' },
balance: { title: '现金结余', content: '现金流入减去现金支出。\n\n现金结余 = 现金流入 - 现金支出' },
rechargeActual: { title: '储值卡充值实收', content: '会员储值卡首充和续费的实际到账金额。\n\n不含赠送金额。' },
firstCharge: { title: '首充', content: '新会员首次充值的金额。' },
renewCharge: { title: '续费', content: '老会员续费充值的金额。' },
consume: { title: '消耗', content: '会员使用储值卡消费的金额。' },
cardBalance: { title: '储值卡总余额', content: '所有储值卡的剩余可用余额。' },
allCardBalance: { title: '全类别会员卡余额合计', content: '储值卡 + 赠送卡(酒水卡、台费卡、抵用券)的总余额。\n\n仅供经营参考非财务属性。' },
}
Page({
data: {
pageState: 'normal' as 'loading' | 'empty' | 'error' | 'normal',
/** AI 配色 */
aiColorClass: '',
boardTabs: [] as Array<{ key: string; label: string; active: boolean }>,
/** 时间筛选 */
selectedTime: 'month',
selectedTimeText: '本月',
timeOptions: [
@@ -85,7 +57,6 @@ Page({
{ value: 'half6', text: '最近6个月不含本月' },
],
/** 区域筛选 */
selectedArea: 'all',
selectedAreaText: '全部区域',
areaOptions: [
@@ -94,14 +65,16 @@ Page({
{ value: 'hallA', text: 'A区' },
{ value: 'hallB', text: 'B区' },
{ value: 'hallC', text: 'C区' },
{ value: 'vip', text: '台球包厢' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '麻将房' },
{ value: 'teamBuilding', text: '团建房' },
{ value: 'ktv', text: '团建房' },
],
/** 环比开关 */
compareEnabled: false,
isCurrentMonth: true,
aiInsights: [] as Array<{ icon: string; text: string }>,
/** 目录导航 */
tocVisible: false,
tocItems: [
{ emoji: '📈', title: '经营一览', sectionId: 'section-overview' },
@@ -113,191 +86,116 @@ Page({
] as TocItem[],
currentSectionIndex: 0,
/** P1: 吸顶板块头H5: scaleX 从左滑入,同时筛选按钮 opacity 淡出) */
stickyHeaderVisible: false,
stickyHeaderEmoji: '',
stickyHeaderTitle: '',
stickyHeaderDesc: '',
/** 提示弹窗 */
tipVisible: false,
tipTitle: '',
tipContent: '',
/** 经营一览 */
overview: {
occurrence: '¥823,456',
occurrenceCompare: '12.5%',
discount: '-¥113,336',
discountCompare: '3.2%',
discountRate: '13.8%',
discountRateCompare: '1.5%',
confirmedRevenue: '¥710,120',
confirmedCompare: '8.7%',
cashIn: '¥698,500',
cashInCompare: '5.3%',
cashOut: '¥472,300',
cashOutCompare: '2.1%',
cashBalance: '¥226,200',
cashBalanceCompare: '15.2%',
balanceRate: '32.4%',
balanceRateCompare: '3.8%',
occurrence: 0 as any, occurrenceCompare: '', occurrenceDown: false, occurrenceFlat: false,
discount: 0 as any, discountCompare: '', discountDown: false, discountFlat: false,
discountRate: 0 as any, discountRateCompare: '', discountRateDown: false, discountRateFlat: false,
confirmedRevenue: 0 as any, confirmedCompare: '', confirmedDown: false, confirmedFlat: false,
cashIn: 0 as any, cashInCompare: '', cashInDown: false, cashInFlat: false,
cashOut: 0 as any, cashOutCompare: '', cashOutDown: false, cashOutFlat: false,
cashBalance: 0 as any, cashBalanceCompare: '', cashBalanceDown: false, cashBalanceFlat: false,
balanceRate: 0 as any, balanceRateCompare: '', balanceRateDown: false, balanceRateFlat: false,
},
/** 预收资产 */
recharge: {
actualIncome: '¥352,800',
actualCompare: '18.5%',
firstCharge: '¥188,500',
firstChargeCompare: '12.3%',
renewCharge: '¥164,300',
renewChargeCompare: '8.7%',
consumed: '¥238,200',
consumedCompare: '5.2%',
cardBalance: '¥642,600',
cardBalanceCompare: '11.4%',
actualIncome: 0 as any, actualCompare: '', actualDown: false,
firstCharge: 0 as any, firstChargeCompare: '', firstChargeDown: false,
renewCharge: 0 as any, renewChargeCompare: '', renewChargeDown: false,
consumed: 0 as any, consumedCompare: '', consumedDown: false,
cardBalance: 0 as any, cardBalanceCompare: '', cardBalanceDown: false,
allCardBalance: 0 as any, allCardBalanceCompare: '', allCardBalanceDown: false,
giftRows: [] as Array<{
label: string; total: string; totalCompare: string;
wine: string; wineCompare: string;
table: string; tableCompare: string;
coupon: string; couponCompare: string;
label: string; total: any; totalCompare: string;
wine: any; wineCompare: string;
table: any; tableCompare: string;
coupon: any; couponCompare: string;
}>,
allCardBalance: '¥586,500',
allCardBalanceCompare: '6.2%',
},
/** 应计收入确认 */
revenue: {
structureRows: [
{ id: 'table', name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
{ id: 'area-a', name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
{ id: 'area-b', name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
{ id: 'area-c', name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
{ id: 'team', name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
{ id: 'mahjong', name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
{ id: 'coach-basic', name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
{ id: 'coach-incentive', name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
{ id: 'food', name: '食品酒水', amount: '¥119,556', discount: '-¥68,136', booked: '¥51,420', bookedCompare: '6.5%' },
],
priceItems: [
{ name: '开台消费', value: '¥358,600', compare: '9.2%' },
{ name: '酒水商品', value: '¥186,420', compare: '18.5%' },
{ name: '包厢费用', value: '¥165,636', compare: '12.1%' },
{ name: '助教服务', value: '¥112,800', compare: '15.3%' },
],
totalOccurrence: '¥823,456',
totalOccurrenceCompare: '12.5%',
discountItems: [
{ name: '团购优惠', value: '-¥56,200', compare: '5.2%' },
{ name: '手动调整 + 大客户优惠', value: '-¥34,800', compare: '3.1%' },
{ name: '赠送卡抵扣', desc: '台桌卡+酒水卡+抵用券', value: '-¥22,336', compare: '8.6%' },
{ name: '其他优惠', desc: '免单+抹零', value: '-¥0', compare: '' },
],
confirmedTotal: '¥710,120',
confirmedTotalCompare: '8.7%',
channelItems: [
{ name: '储值卡结算冲销', value: '¥238,200', compare: '11.2%' },
{ name: '现金/线上支付', value: '¥345,800', compare: '7.8%' },
{ name: '团购核销确认收入', desc: '团购成交价', value: '¥126,120', compare: '5.3%' },
],
structureRows: [] as any[],
priceItems: [] as any[],
totalOccurrence: 0 as any,
totalOccurrenceCompare: '',
discountItems: [] as any[],
discountTotal: 0,
confirmedTotal: 0 as any,
confirmedTotalCompare: '',
channelItems: [] as any[],
},
/** 现金流入 */
cashflow: {
consumeItems: [
{ name: '纸币现金', desc: '柜台现金收款', value: '¥85,600', compare: '12.3%', isDown: true },
{ name: '线上收款', desc: '微信/支付宝/刷卡 已扣除平台服务费', value: '¥260,200', compare: '8.5%', isDown: false },
{ name: '团购平台', desc: '美团/抖音回款 已扣除平台服务费', value: '¥126,120', compare: '15.2%', isDown: false },
],
rechargeItems: [
{ name: '会员充值到账', desc: '首充/续费实收', value: '¥352,800', compare: '18.5%' },
],
total: '¥824,720',
totalCompare: '10.2%',
consumeItems: [] as any[],
rechargeItems: [] as any[],
total: 0 as any,
totalCompare: '',
},
/** 现金流出 */
expense: {
operationItems: [
{ name: '食品饮料', value: '¥108,200', compare: '4.5%', isDown: false },
{ name: '耗材', value: '¥21,850', compare: '2.1%', isDown: true },
{ name: '报销', value: '¥10,920', compare: '6.8%', isDown: false },
],
fixedItems: [
{ name: '房租', value: '¥125,000', compare: '持平', isFlat: true },
{ name: '水电', value: '¥24,200', compare: '3.2%', isFlat: false },
{ name: '物业', value: '¥11,500', compare: '持平', isFlat: true },
{ name: '人员工资', value: '¥112,000', compare: '持平', isFlat: true },
],
coachItems: [
{ name: '基础课分成', value: '¥116,250', compare: '8.2%', isDown: false },
{ name: '激励课分成', value: '¥23,840', compare: '5.6%', isDown: false },
{ name: '充值提成', value: '¥12,640', compare: '12.3%', isDown: false },
{ name: '额外奖金', value: '¥11,500', compare: '3.1%', isDown: true },
],
platformItems: [
{ name: '汇来米', value: '¥10,680', compare: '1.5%' },
{ name: '美团', value: '¥11,240', compare: '2.8%' },
{ name: '抖音', value: '¥10,580', compare: '3.5%' },
],
total: '¥600,400',
totalCompare: '2.1%',
operationItems: [] as any[],
fixedItems: [] as any[],
coachItems: [] as any[],
platformItems: [] as any[],
total: 0 as any,
totalCompare: '',
totalDown: false,
totalFlat: false,
},
/** 助教分析 */
coachAnalysis: {
basic: {
totalPay: '¥232,500',
totalPayCompare: '15.3%',
totalShare: '¥116,250',
totalShareCompare: '15.3%',
avgHourly: '¥25/h',
avgHourlyCompare: '4.2%',
rows: [
{ level: '初级', pay: '¥68,600', payCompare: '12.5%', share: '¥34,300', shareCompare: '12.5%', hourly: '¥20/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '中级', pay: '¥82,400', payCompare: '18.2%', share: '¥41,200', shareCompare: '18.2%', hourly: '¥25/h', hourlyCompare: '8.7%' },
{ level: '高级', pay: '¥57,800', payCompare: '14.6%', share: '¥28,900', shareCompare: '14.6%', hourly: '¥30/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '星级', pay: '¥23,700', payCompare: '3.2%', payDown: true, share: '¥11,850', shareCompare: '3.2%', shareDown: true, hourly: '¥35/h', hourlyCompare: '持平', hourlyFlat: true },
],
totalPay: 0 as any, totalPayCompare: '', totalPayDown: false,
totalShare: 0 as any, totalShareCompare: '', totalShareDown: false,
avgHourly: 0 as any, avgHourlyCompare: '', avgHourlyFlat: false,
rows: [] as any[],
},
incentive: {
totalPay: '¥112,800',
totalPayCompare: '8.2%',
totalShare: '¥33,840',
totalShareCompare: '8.2%',
avgHourly: '¥15/h',
avgHourlyCompare: '2.1%',
rows: [
{ level: '初级', pay: '¥32,400', payCompare: '6.8%', share: '¥9,720', shareCompare: '6.8%', hourly: '¥12/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '中级', pay: '¥38,600', payCompare: '10.5%', share: '¥11,580', shareCompare: '10.5%', hourly: '¥15/h', hourlyCompare: '5.2%' },
{ level: '高级', pay: '¥28,200', payCompare: '7.3%', share: '¥8,460', shareCompare: '7.3%', hourly: '¥18/h', hourlyCompare: '持平', hourlyFlat: true },
{ level: '星级', pay: '¥13,600', payCompare: '2.1%', payDown: true, share: '¥4,080', shareCompare: '2.1%', shareDown: true, hourly: '¥22/h', hourlyCompare: '持平', hourlyFlat: true },
],
totalPay: 0 as any, totalPayCompare: '', totalPayDown: false,
totalShare: 0 as any, totalShareCompare: '', totalShareDown: false,
avgHourly: 0 as any, avgHourlyCompare: '', avgHourlyFlat: false,
rows: [] as any[],
},
},
},
onLoad() {
// P5: AI 配色
const aiColor = getRandomAiColor()
this.setData({ aiColorClass: aiColor.className })
},
onShow() {
// 同步 custom-tab-bar 选中态
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'board' })
// 加载赠送卡矩阵真实数据
this._loadGiftRows()
checkPageAccess('pages/board-finance/board-finance').then((allowed) => {
if (!allowed) return
const TAB_LABELS: Record<string, string> = { finance: '财务', customer: '客户', coach: '助教' }
const visibleKeys = getVisibleBoardTabs()
this.setData({
boardTabs: visibleKeys.map(k => ({ key: k, label: TAB_LABELS[k] || k, active: k === 'finance' })),
})
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'board' })
this._loadData()
})
},
onReady() {
// P1: 缓存各 section 的 top 位置
this._cacheSectionPositions()
},
/** P1/P2: 页面滚动监听(节流 100ms— 匹配 H5 原型行为 */
/* CHANGE 2026-03-13 | intent: H5 原型下滑→显示吸顶头+隐藏筛选按钮,上滑→隐藏吸顶头+恢复筛选按钮;不再使用独立的 filterBarHidden 状态 */
onPageScroll(e: { scrollTop: number }) {
const now = Date.now()
if (now - this._lastScrollTime < 100) return
@@ -307,10 +205,8 @@ Page({
const isScrollingDown = scrollTop > this._lastScrollTop
this._lastScrollTop = scrollTop
// P1: 吸顶板块头 — 与 H5 updateStickyHeader 逻辑对齐
if (this._sectionTops.length === 0) return
// 偏移量tabs(~78rpx) + filter-bar(~70rpx) 约 148rpx ≈ 93px取 100 作为阈值
const offset = 100
let currentIdx = 0
for (let i = this._sectionTops.length - 1; i >= 0; i--) {
@@ -320,7 +216,6 @@ Page({
}
}
// H5: scrollY < 80 时隐藏吸顶头
if (scrollTop < 80) {
if (this.data.stickyHeaderVisible) {
this.setData({ stickyHeaderVisible: false })
@@ -331,7 +226,6 @@ Page({
const toc = this.data.tocItems[currentIdx]
if (isScrollingDown && !this.data.stickyHeaderVisible) {
// H5: 下滑且吸顶头未显示 → 显示吸顶头(筛选按钮通过 CSS opacity 自动淡出)
this.setData({
stickyHeaderVisible: true,
stickyHeaderEmoji: toc?.emoji || '',
@@ -340,10 +234,8 @@ Page({
currentSectionIndex: currentIdx,
})
} else if (!isScrollingDown && this.data.stickyHeaderVisible) {
// H5: 上滑且吸顶头显示 → 隐藏吸顶头(筛选按钮通过 CSS opacity 自动恢复)
this.setData({ stickyHeaderVisible: false })
} else if (this.data.stickyHeaderVisible && currentIdx !== this.data.currentSectionIndex) {
// H5: 吸顶头显示时板块切换 → 更新内容
this.setData({
stickyHeaderEmoji: toc?.emoji || '',
stickyHeaderTitle: toc?.title || '',
@@ -353,12 +245,10 @@ Page({
}
},
/** 缓存 section 位置(私有) */
_sectionTops: [] as number[],
_lastScrollTop: 0,
_lastScrollTime: 0,
/** H5 原型吸顶头包含板块描述,从 data-section-desc 映射 */
_sectionDescs: [
'快速了解收入与现金流的整体健康度',
'会员卡充值与余额 掌握资金沉淀',
@@ -384,135 +274,260 @@ Page({
})
},
/**
* 从 Finance_Board_API 加载赠送卡矩阵数据
* 字段映射liquor→wine、tableFee→table、voucher→coupon
*/
async _loadGiftRows() {
// CHANGE 2026-03-28 | 环比修复 | 绑定所有板块 is_down/is_flat修复箭头方向硬编码问题
async _loadData() {
wx.showLoading({ title: '加载中' })
this.setData({ pageState: 'loading' })
try {
const data = await fetchBoardFinance({
time: this.data.selectedTime,
area: this.data.selectedArea,
compare: this.data.compareEnabled ? 1 : 0,
})
// API 返回 camelCasegiftRows[].liquor/tableFee/voucher每个是 GiftCell {value, compare?, down?, flat?}
const rechargePanel = data.rechargePanel || data
const apiRows = rechargePanel.giftRows || []
const giftRows = apiRows.map((row: any) => ({
label: row.label || '',
total: formatMoney(row.total?.value),
totalCompare: row.total?.compare || '',
wine: formatMoney(row.liquor?.value),
wineCompare: row.liquor?.compare || '',
table: formatMoney(row.tableFee?.value),
tableCompare: row.tableFee?.compare || '',
coupon: formatMoney(row.voucher?.value),
couponCompare: row.voucher?.compare || '',
// 1. 经营一览
const ov = data.overview || {} as any
this.setData({
'overview.occurrence': ov.occurrence ?? 0,
'overview.discount': ov.discount ?? 0,
'overview.discountRate': (ov.discountRate ?? 0) * 100,
'overview.confirmedRevenue': ov.confirmedRevenue ?? 0,
'overview.cashIn': ov.cashIn ?? 0,
'overview.cashOut': ov.cashOut ?? 0,
'overview.cashBalance': ov.cashBalance ?? 0,
'overview.balanceRate': (ov.balanceRate ?? 0) * 100,
'overview.occurrenceCompare': ov.occurrenceCompare || '',
'overview.occurrenceDown': ov.occurrenceDown ?? false,
'overview.occurrenceFlat': ov.occurrenceFlat ?? false,
'overview.discountCompare': ov.discountCompare || '',
'overview.discountDown': ov.discountDown ?? false,
'overview.discountFlat': ov.discountFlat ?? false,
'overview.discountRateCompare': ov.discountRateCompare || '',
'overview.discountRateDown': ov.discountRateDown ?? false,
'overview.discountRateFlat': ov.discountRateFlat ?? false,
'overview.confirmedCompare': ov.confirmedRevenueCompare || '',
'overview.confirmedDown': ov.confirmedRevenueDown ?? false,
'overview.confirmedFlat': ov.confirmedRevenueFlat ?? false,
'overview.cashInCompare': ov.cashInCompare || '',
'overview.cashInDown': ov.cashInDown ?? false,
'overview.cashInFlat': ov.cashInFlat ?? false,
'overview.cashOutCompare': ov.cashOutCompare || '',
'overview.cashOutDown': ov.cashOutDown ?? false,
'overview.cashOutFlat': ov.cashOutFlat ?? false,
'overview.cashBalanceCompare': ov.cashBalanceCompare || '',
'overview.cashBalanceDown': ov.cashBalanceDown ?? false,
'overview.cashBalanceFlat': ov.cashBalanceFlat ?? false,
'overview.balanceRateCompare': ov.balanceRateCompare || '',
'overview.balanceRateDown': ov.balanceRateDown ?? false,
'overview.balanceRateFlat': ov.balanceRateFlat ?? false,
})
// 2. 预收资产area=all 时后端才返回)
if (data.recharge) {
const rc = data.recharge as any
const giftRows = (rc.giftRows || []).map((row: any) => ({
label: row.label || '',
total: row.total?.value ?? 0,
totalCompare: row.total?.compare || '',
wine: row.liquor?.value ?? 0,
wineCompare: row.liquor?.compare || '',
table: row.tableFee?.value ?? 0,
tableCompare: row.tableFee?.compare || '',
coupon: row.voucher?.value ?? 0,
couponCompare: row.voucher?.compare || '',
}))
this.setData({
'recharge.actualIncome': rc.actualIncome ?? 0,
'recharge.firstCharge': rc.firstCharge ?? 0,
'recharge.renewCharge': rc.renewCharge ?? 0,
'recharge.consumed': rc.consumed ?? 0,
'recharge.cardBalance': rc.cardBalance ?? 0,
'recharge.allCardBalance': rc.allCardBalance ?? 0,
'recharge.giftRows': giftRows,
'recharge.actualCompare': rc.actualIncomeCompare || '',
'recharge.actualDown': rc.actualIncomeDown ?? false,
'recharge.firstChargeCompare': rc.firstChargeCompare || '',
'recharge.firstChargeDown': rc.firstChargeDown ?? false,
'recharge.renewChargeCompare': rc.renewChargeCompare || '',
'recharge.renewChargeDown': rc.renewChargeDown ?? false,
'recharge.consumedCompare': rc.consumedCompare || '',
'recharge.consumedDown': rc.consumedDown ?? false,
'recharge.cardBalanceCompare': rc.cardBalanceCompare || '',
'recharge.cardBalanceDown': rc.cardBalanceDown ?? false,
'recharge.allCardBalanceCompare': rc.allCardBalanceCompare || '',
'recharge.allCardBalanceDown': rc.allCardBalanceDown ?? false,
})
}
// 3. 应计收入确认
const rv = data.revenue || {} as any
const structureRows = (rv.structureRows || []).map((r: any) => ({
id: r.id || '', name: r.name || '', desc: r.desc || '',
isSub: r.isSub || false,
amount: r.amount ?? 0, discount: r.discount ?? 0,
booked: r.booked ?? 0, bookedCompare: r.bookedCompare || '',
}))
this.setData({ 'recharge.giftRows': giftRows })
const priceItems = (rv.priceItems || []).map((r: any) => ({ name: r.label || '', value: r.amount ?? 0, compare: r.compare || '' }))
const discountItems = (rv.discountItems || []).map((r: any) => ({ name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '' }))
const channelItems = (rv.channelItems || []).map((r: any) => ({ name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '' }))
this.setData({
'revenue.structureRows': structureRows,
'revenue.priceItems': priceItems,
'revenue.totalOccurrence': rv.totalOccurrence ?? 0,
// CHANGE 2026-03-28 | 环比字段映射
'revenue.totalOccurrenceCompare': rv.totalOccurrenceCompare || '',
'revenue.discountItems': discountItems,
'revenue.discountTotal': rv.discountTotal ?? 0,
'revenue.confirmedTotal': rv.confirmedTotal ?? 0,
'revenue.confirmedTotalCompare': rv.confirmedTotalCompare || '',
'revenue.channelItems': channelItems,
})
// 4. 现金流入
const cf = data.cashflow || {} as any
const consumeItems = (cf.consumeItems || []).map((r: any) => ({
name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '', isDown: r.down ?? false,
}))
const rechargeItems = (cf.rechargeItems || []).map((r: any) => ({
name: r.label || '', desc: r.desc || '', value: r.amount ?? 0, compare: r.compare || '',
}))
this.setData({
'cashflow.consumeItems': consumeItems,
'cashflow.rechargeItems': rechargeItems,
'cashflow.total': cf.total ?? 0,
// CHANGE 2026-03-28 | 环比字段映射
'cashflow.totalCompare': cf.totalCompare || '',
})
// 5. 现金流出
const ex = data.expense || {} as any
const mapExpenseItems = (items: any[]) => (items || []).map((r: any) => ({
name: r.label || '', value: r.amount ?? 0,
compare: r.compare || '',
isDown: r.down ?? false,
isFlat: r.flat ?? false,
}))
this.setData({
'expense.operationItems': mapExpenseItems(ex.operationItems),
'expense.fixedItems': mapExpenseItems(ex.fixedItems),
'expense.coachItems': mapExpenseItems(ex.coachItems),
'expense.platformItems': mapExpenseItems(ex.platformItems),
'expense.total': ex.total ?? 0,
'expense.totalCompare': ex.totalCompare || '',
'expense.totalDown': ex.totalDown ?? false,
'expense.totalFlat': ex.totalFlat ?? false,
})
// 6. 助教分析
const ca = data.coachAnalysis || {} as any
const mapCoachTable = (table: any) => {
if (!table) return { totalPay: 0, totalPayCompare: '', totalPayDown: false, totalShare: 0, totalShareCompare: '', totalShareDown: false, avgHourly: 0, avgHourlyCompare: '', avgHourlyFlat: false, rows: [] }
return {
totalPay: table.totalPay ?? 0,
totalPayCompare: table.totalPayCompare || '',
totalPayDown: table.totalPayDown ?? false,
totalShare: table.totalShare ?? 0,
totalShareCompare: table.totalShareCompare || '',
totalShareDown: table.totalShareDown ?? false,
avgHourly: table.avgHourly ?? 0,
avgHourlyCompare: table.avgHourlyCompare || '',
avgHourlyFlat: table.avgHourlyFlat ?? false,
rows: (table.rows || []).map((r: any) => ({
level: r.level || '',
pay: r.pay ?? 0,
payCompare: r.payCompare || '',
payDown: r.payDown ?? false,
share: r.share ?? 0,
shareCompare: r.shareCompare || '',
shareDown: r.shareDown ?? false,
hourly: r.hourly ?? 0,
hourlyCompare: r.hourlyCompare || '',
hourlyFlat: r.hourlyFlat ?? false,
})),
}
}
this.setData({
coachAnalysis: {
basic: mapCoachTable(ca.basic),
incentive: mapCoachTable(ca.incentive),
},
})
const aiInsights = (data.aiInsights || []) as Array<{ icon: string; text: string }>
this.setData({ aiInsights, pageState: 'normal' })
} catch (err) {
console.error('[board-finance] 赠送卡数据加载失败', err)
// 加载失败时清空 giftRows不显示 mock 数据
this.setData({ 'recharge.giftRows': [] })
wx.showToast({ title: '赠送卡数据加载失败', icon: 'none', duration: 2000 })
console.error('[board-finance] 数据加载失败', err)
this.setData({ pageState: 'error' })
wx.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
} finally {
wx.hideLoading()
}
},
onPullDownRefresh() {
this._loadGiftRows()
this._loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 看板 Tab 切换 */
// CHANGE 2026-03-29 | P1redirectTo 替代 navigateTo
onTabChange(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === 'customer') {
wx.navigateTo({ url: '/pages/board-customer/board-customer' })
} else if (tab === 'coach') {
wx.navigateTo({ url: '/pages/board-coach/board-coach' })
if (tab === 'finance') return
const routes: Record<string, { url: string; isTab: boolean }> = {
finance: { url: '/pages/board-finance/board-finance', isTab: true },
customer: { url: '/pages/board-customer/board-customer', isTab: false },
coach: { url: '/pages/board-coach/board-coach', isTab: false },
}
const route = routes[tab]
if (!route) return
if (route.isTab) { wx.switchTab({ url: route.url }) } else { wx.redirectTo({ url: route.url }) }
},
/** 时间筛选变更 */
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const value = e.detail.value
const option = this.data.timeOptions.find(o => o.value === value)
this.setData({
selectedTime: value,
selectedTimeText: option?.text || '本月',
})
this.setData({ selectedTime: value, selectedTimeText: option?.text || '本月', isCurrentMonth: isCurrentMonthFilter(value) })
this._loadData()
},
/** 区域筛选变更 */
onAreaChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const value = e.detail.value
const option = this.data.areaOptions.find(o => o.value === value)
this.setData({
selectedArea: value,
selectedAreaText: option?.text || '全部区域',
})
// P3: 区域变更后重新缓存 section 位置(预收资产可能隐藏/显示)
this.setData({ selectedArea: value, selectedAreaText: option?.text || '全部区域' })
this._loadData()
setTimeout(() => this._cacheSectionPositions(), 300)
},
/** 环比开关切换 */
toggleCompare() {
this.setData({ compareEnabled: !this.data.compareEnabled })
this._loadData()
},
/** 目录导航开关 */
toggleToc() {
this.setData({ tocVisible: !this.data.tocVisible })
},
toggleToc() { this.setData({ tocVisible: !this.data.tocVisible }) },
closeToc() { this.setData({ tocVisible: false }) },
closeToc() {
this.setData({ tocVisible: false })
},
/** 目录项点击 → 滚动到对应板块P0: 使用 pageScrollTo 替代 scrollIntoView */
onTocItemTap(e: WechatMiniprogram.TouchEvent) {
const index = e.currentTarget.dataset.index as number
const sectionId = this.data.tocItems[index]?.sectionId
if (sectionId) {
this.setData({
tocVisible: false,
currentSectionIndex: index,
})
wx.createSelectorQuery().in(this)
.select(`#${sectionId}`)
.boundingClientRect((rect) => {
if (rect) {
wx.pageScrollTo({
scrollTop: rect.top + (this._lastScrollTop || 0) - 140,
duration: 300,
})
}
})
.exec()
this.setData({ tocVisible: false, currentSectionIndex: index })
wx.createSelectorQuery().in(this).select(`#${sectionId}`).boundingClientRect((rect) => {
if (rect) wx.pageScrollTo({ scrollTop: rect.top + (this._lastScrollTop || 0) - 140, duration: 300 })
}).exec()
}
},
/** 帮助图标点击 → 弹出说明 */
onHelpTap(e: WechatMiniprogram.TouchEvent) {
const key = e.currentTarget.dataset.key as string
const tip = tipContents[key]
if (tip) {
this.setData({
tipVisible: true,
tipTitle: tip.title,
tipContent: tip.content,
})
}
if (tip) this.setData({ tipVisible: true, tipTitle: tip.title, tipContent: tip.content })
},
/** 关闭提示弹窗 */
closeTip() {
this.setData({ tipVisible: false })
},
closeTip() { this.setData({ tipVisible: false }) },
/** P4: 错误态重试 */
onRetry() {
this.setData({ pageState: 'normal' })
this._loadGiftRows()
this._loadData()
},
})

View File

@@ -1,15 +1,10 @@
<!-- 财务看板页 — 忠于 H5 原型结构 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- CHANGE 2026-03-28 | 加载中禁止滚动 -->
<page-meta page-style="{{pageState === 'loading' ? 'overflow:hidden;' : ''}}" />
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-empty description="暂无财务数据" />
</view>
@@ -23,16 +18,16 @@
<!-- 正常态 -->
<block wx:else>
<!-- 顶部看板 Tab 导航 -->
<view class="board-tabs">
<view class="board-tab board-tab--active" data-tab="finance">
<text>财务</text>
</view>
<view class="board-tab" data-tab="customer" bindtap="onTabChange">
<text>客户</text>
</view>
<view class="board-tab" data-tab="coach" bindtap="onTabChange">
<text>助教</text>
<!-- CHANGE 2026-03-27 | 权限改造 W5动态看板二级 tab均分宽度居中 -->
<view class="board-tabs board-tabs--{{boardTabs.length}}">
<view
wx:for="{{boardTabs}}"
wx:key="key"
class="board-tab {{item.active ? 'board-tab--active' : ''}}"
data-tab="{{item.key}}"
bindtap="{{item.active ? '' : 'onTabChange'}}"
>
<text>{{item.label}}</text>
</view>
</view>
@@ -96,14 +91,14 @@
<view class="card-header-dark">
<text class="card-header-emoji">📈</text>
<view class="card-header-text">
<text class="card-header-title-dark">经营一览</text>
<text class="card-header-title-dark">经营一览{{isCurrentMonth ? '(预估)' : ''}}</text>
<text class="card-header-desc-dark">快速了解收入与现金流的整体健康度</text>
</view>
</view>
<!-- 收入概览 -->
<view class="sub-section-label">
<text class="sub-label-text">收入概览</text>
<text class="sub-label-text">收入记账</text>
<text class="sub-label-desc">记账口径收入与优惠</text>
</view>
@@ -113,9 +108,9 @@
<text class="cell-label-light">发生额/正价</text>
<view class="help-icon-light" data-key="occurrence" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white">{{overview.occurrence}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.occurrenceCompare}}</text>
<text class="cell-value-white">{{fmt.money(overview.occurrence)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.occurrenceCompare}}">
<text class="{{fmt.compareClass(overview.occurrenceCompare, overview.occurrenceDown, 'xs')}}">{{fmt.compareText(overview.occurrenceCompare, overview.occurrenceDown)}}</text>
</view>
</view>
<view class="overview-cell">
@@ -123,18 +118,18 @@
<text class="cell-label-light">总优惠</text>
<view class="help-icon-light" data-key="discount" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-red">{{overview.discount}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-down">{{overview.discountCompare}}</text>
<text class="cell-value-red">{{fmt.money(overview.discount)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.discountCompare}}">
<text class="{{fmt.compareClass(overview.discountCompare, overview.discountDown, 'xs')}}">{{fmt.compareText(overview.discountCompare, overview.discountDown)}}</text>
</view>
</view>
<view class="overview-cell">
<view class="cell-label-row">
<text class="cell-label-light">优惠占比</text>
</view>
<text class="cell-value-gray">{{overview.discountRate}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-down">{{overview.discountRateCompare}}</text>
<text class="cell-value-gray">{{fmt.percent(overview.discountRate)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.discountRateCompare}}">
<text class="{{fmt.compareClass(overview.discountRateCompare, overview.discountRateDown, 'xs')}}">{{fmt.compareText(overview.discountRateCompare, overview.discountRateDown)}}</text>
</view>
</view>
</view>
@@ -146,19 +141,20 @@
<view class="help-icon-light" data-key="confirmed" bindtap="onHelpTap">?</view>
</view>
<view class="confirmed-right">
<text class="confirmed-value">{{overview.confirmedRevenue}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.confirmedCompare}}</text>
<text class="confirmed-value">{{fmt.money(overview.confirmedRevenue)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled && overview.confirmedCompare}}">
<text class="{{fmt.compareClass(overview.confirmedCompare, overview.confirmedDown, 'xs')}}">{{fmt.compareText(overview.confirmedCompare, overview.confirmedDown)}}</text>
</view>
</view>
</view>
<view class="section-divider-light"></view>
<!-- 现金流水概览 -->
<!-- 现金流水概览(仅"全部区域"时显示) -->
<view wx:if="{{selectedArea === 'all'}}">
<view class="sub-section-label">
<text class="sub-label-text">现金流水概览</text>
<text class="sub-label-desc">往期为已结算 本期为截至当前的发生额</text>
<text class="sub-label-text">实收流水</text>
<text class="sub-label-desc">实际有多少钱的收入。往期为已结算本期为截至当前的发生额</text>
</view>
<view class="overview-grid-2">
@@ -167,9 +163,9 @@
<text class="cell-label-light">实收/现金流入</text>
<view class="help-icon-light" data-key="cashIn" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white-sm">{{overview.cashIn}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.cashInCompare}}</text>
<text class="cell-value-white-sm">{{fmt.money(overview.cashIn)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.cashInCompare}}">
<text class="{{fmt.compareClass(overview.cashInCompare, overview.cashInDown, 'xs')}}">{{fmt.compareText(overview.cashInCompare, overview.cashInDown)}}</text>
</view>
</view>
<view class="overview-cell-bg">
@@ -177,9 +173,9 @@
<text class="cell-label-light">现金支出</text>
<view class="help-icon-light" data-key="cashOut" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-gray-sm">{{overview.cashOut}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.cashOutCompare}}</text>
<text class="cell-value-gray-sm">{{fmt.money(overview.cashOut)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.cashOutCompare}}">
<text class="{{fmt.compareClass(overview.cashOutCompare, overview.cashOutDown, 'xs')}}">{{fmt.compareText(overview.cashOutCompare, overview.cashOutDown)}}</text>
</view>
</view>
<view class="overview-cell-bg">
@@ -187,24 +183,26 @@
<text class="cell-label-light">现金结余</text>
<view class="help-icon-light" data-key="balance" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-white-sm">{{overview.cashBalance}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.cashBalanceCompare}}</text>
<text class="cell-value-white-sm">{{fmt.money(overview.cashBalance)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.cashBalanceCompare}}">
<text class="{{fmt.compareClass(overview.cashBalanceCompare, overview.cashBalanceDown, 'xs')}}">{{fmt.compareText(overview.cashBalanceCompare, overview.cashBalanceDown)}}</text>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<text class="cell-label-light">结余率</text>
</view>
<text class="cell-value-white-sm">{{overview.balanceRate}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">{{overview.balanceRateCompare}}</text>
<text class="cell-value-white-sm">{{fmt.percent(overview.balanceRate)}}</text>
<view class="compare-row" wx:if="{{compareEnabled && overview.balanceRateCompare}}">
<text class="{{fmt.compareClass(overview.balanceRateCompare, overview.balanceRateDown, 'xs')}}">{{fmt.compareText(overview.balanceRateCompare, overview.balanceRateDown)}}</text>
</view>
</view>
</view>
</view>
<!-- AI 洞察 -->
<!-- CHANGE 2026-03-12 | intent: H5 原型使用 SVG 机器人图标,不可用 emoji 替代;规范要求内联 SVG 导出为文件用 image 引用 -->
<!-- CHANGE 2026-03-21 | P13 T6.1: AI 洞察改为动态渲染,移除硬编码文案 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">
@@ -212,11 +210,11 @@
</view>
<text class="ai-insight-title">AI 智能洞察</text>
</view>
<view class="ai-insight-body">
<text class="ai-insight-line"><text class="ai-insight-dim">优惠率Top</text>团购(5.2%) / 大客户(3.1%) / 赠送卡(2.5%)</text>
<text class="ai-insight-line"><text class="ai-insight-dim">差异最大:</text>酒水(+18%) / 台桌(-5%) / 包厢(+12%)</text>
<!-- CHANGE 2026-03-12 | intent: H5 原型第三行"充值高但消耗低"有 underline 样式 -->
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text><text class="ai-insight-underline">充值高但消耗低</text>,会员活跃度需提升</text>
<view class="ai-insight-body" wx:if="{{aiInsights.length > 0}}">
<text class="ai-insight-line" wx:for="{{aiInsights}}" wx:key="index"><text class="ai-insight-dim">{{item.icon}} </text>{{item.text}}</text>
</view>
<view class="ai-insight-body" wx:else>
<text class="ai-insight-line ai-insight-dim">暂无洞察数据</text>
</view>
</view>
</view>
@@ -244,9 +242,9 @@
<text class="total-balance-note">仅经营参考,非财务属性</text>
</view>
<view class="total-balance-right">
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
<text class="total-balance-value">{{fmt.money(recharge.allCardBalance)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{recharge.allCardBalanceCompare}}</text>
<text class="{{fmt.compareClass(recharge.allCardBalanceCompare, recharge.allCardBalanceDown, 'sm')}}">{{fmt.compareText(recharge.allCardBalanceCompare, recharge.allCardBalanceDown)}}</text>
</view>
</view>
</view>
@@ -259,9 +257,9 @@
<view class="help-icon-dark" data-key="rechargeActual" bindtap="onHelpTap">?</view>
</view>
<view class="table-row-right">
<text class="table-row-value-lg">{{recharge.actualIncome}}</text>
<text class="table-row-value-lg">{{fmt.money(recharge.actualIncome)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{recharge.actualCompare}}</text>
<text class="{{fmt.compareClass(recharge.actualCompare, recharge.actualDown, 'sm')}}">{{fmt.compareText(recharge.actualCompare, recharge.actualDown)}}</text>
</view>
</view>
</view>
@@ -272,9 +270,9 @@
<text class="cell-label-sm">首充</text>
<view class="help-icon-dark-sm" data-key="firstCharge" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.firstCharge}}</text>
<text class="cell-value-sm">{{fmt.money(recharge.firstCharge)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{recharge.firstChargeCompare}}</text>
<text class="{{fmt.compareClass(recharge.firstChargeCompare, recharge.firstChargeDown, 'xs')}}">{{fmt.compareText(recharge.firstChargeCompare, recharge.firstChargeDown)}}</text>
</view>
</view>
<view class="grid-cell">
@@ -282,9 +280,9 @@
<text class="cell-label-sm">续费</text>
<view class="help-icon-dark-sm" data-key="renewCharge" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.renewCharge}}</text>
<text class="cell-value-sm">{{fmt.money(recharge.renewCharge)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{recharge.renewChargeCompare}}</text>
<text class="{{fmt.compareClass(recharge.renewChargeCompare, recharge.renewChargeDown, 'xs')}}">{{fmt.compareText(recharge.renewChargeCompare, recharge.renewChargeDown)}}</text>
</view>
</view>
<view class="grid-cell">
@@ -292,9 +290,9 @@
<text class="cell-label-sm">消耗</text>
<view class="help-icon-dark-sm" data-key="consume" bindtap="onHelpTap">?</view>
</view>
<text class="cell-value-sm">{{recharge.consumed}}</text>
<text class="cell-value-sm">{{fmt.money(recharge.consumed)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{recharge.consumedCompare}}</text>
<text class="{{fmt.compareClass(recharge.consumedCompare, recharge.consumedDown, 'xs')}}">{{fmt.compareText(recharge.consumedCompare, recharge.consumedDown)}}</text>
</view>
</view>
</view>
@@ -305,9 +303,9 @@
<view class="help-icon-dark" data-key="cardBalance" bindtap="onHelpTap">?</view>
</view>
<view class="table-row-right">
<text class="table-row-value-lg">{{recharge.cardBalance}}</text>
<text class="table-row-value-lg">{{fmt.money(recharge.cardBalance)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{recharge.cardBalanceCompare}}</text>
<text class="{{fmt.compareClass(recharge.cardBalanceCompare, recharge.cardBalanceDown, 'sm')}}">{{fmt.compareText(recharge.cardBalanceCompare, recharge.cardBalanceDown)}}</text>
</view>
</view>
</view>
@@ -328,30 +326,31 @@
<!-- 左列:标题 + 环比 / 总金额 -->
<view class="gift-col gift-col--name">
<view class="gift-label-line">
<text class="gift-row-label">{{item.label}}</text>
<text class="compare-text-up-xs" wx:if="{{compareEnabled}}">↑{{item.totalCompare}}</text>
<text class="gift-row-label">{{fmt.safe(item.label)}}</text>
</view>
<text class="gift-row-total">{{item.total}}</text>
<text class="gift-row-total">{{fmt.safe(item.total)}}</text>
<text class="{{fmt.compareClass(item.totalCompare, false, 'xs')}}" wx:if="{{compareEnabled}}">{{fmt.compareText(item.totalCompare, false)}}</text>
</view>
<!-- 酒水卡 -->
<view class="gift-col">
<text class="gift-col-val">{{item.wine}}</text>
<text class="gift-col-val">{{fmt.safe(item.wine)}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.wineCompare}}</text>
<text class="{{fmt.compareClass(item.wineCompare, false, 'xs')}}">{{fmt.compareText(item.wineCompare, false)}}</text>
</view>
</view>
<!-- 台费卡 -->
<view class="gift-col">
<text class="gift-col-val">{{item.table}}</text>
<text class="gift-col-val">{{fmt.safe(item.table)}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.tableCompare}}</text>
<text class="{{fmt.compareClass(item.tableCompare, false, 'xs')}}">{{fmt.compareText(item.tableCompare, false)}}</text>
</view>
</view>
<!-- 抵用券 -->
<view class="gift-col">
<text class="gift-col-val">{{item.coupon}}</text>
<text class="gift-col-val">{{fmt.safe(item.coupon)}}</text>
<view class="gift-label-line" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.couponCompare}}</text>
<text class="{{fmt.compareClass(item.couponCompare, false, 'xs')}}">{{fmt.compareText(item.couponCompare, false)}}</text>
</view>
</view>
</view>
@@ -394,12 +393,12 @@
<text class="{{item.isSub ? 'rev-name-sub' : 'rev-name'}}">{{item.name}}</text>
<text class="rev-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
</view>
<text class="rev-col rev-val">{{item.amount}}</text>
<text class="rev-col rev-val {{item.discount !== '-' ? 'rev-val--red' : 'rev-val--muted'}}">{{item.discount}}</text>
<text class="rev-col rev-val">{{fmt.money(item.amount)}}</text>
<text class="rev-col rev-val {{item.discount > 0 ? 'rev-val--red' : 'rev-val--muted'}}">{{item.discount > 0 ? fmt.negativeMoney(item.discount) : '-'}}</text>
<view class="rev-col">
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{item.booked}}</text>
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{fmt.money(item.booked)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled && item.bookedCompare}}">
<text class="compare-text-up-xs">{{item.bookedCompare}}</text>
<text class="{{fmt.compareClass(item.bookedCompare, false, 'xs')}}">{{fmt.compareText(item.bookedCompare, false)}}</text>
</view>
</view>
</view>
@@ -421,11 +420,11 @@
<!-- 正价明细(左侧竖线) -->
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.priceItems}}" wx:key="name">
<text class="flow-detail-name">{{item.name}}</text>
<text class="flow-detail-name">{{fmt.safe(item.name)}}</text>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<text class="flow-detail-val">{{fmt.money(item.value)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, false, 'xs')}}">{{fmt.compareText(item.compare, false)}}</text>
</view>
</view>
</view>
@@ -437,26 +436,27 @@
<text class="flow-total-desc">即上列正价合计</text>
</view>
<view class="flow-total-right">
<text class="flow-total-value">{{revenue.totalOccurrence}}</text>
<text class="flow-total-value">{{fmt.money(revenue.totalOccurrence)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{revenue.totalOccurrenceCompare}}</text>
<text class="{{fmt.compareClass(revenue.totalOccurrenceCompare, false, 'sm')}}">{{fmt.compareText(revenue.totalOccurrenceCompare, false)}}</text>
</view>
</view>
</view>
<!-- 优惠扣减 -->
<view class="flow-header flow-header--deduct">
<text class="flow-header-title">优惠扣减</text>
<text class="flow-header-total flow-header-total--red">{{fmt.negativeMoney(revenue.discountTotal)}}</text>
</view>
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.discountItems}}" wx:key="name">
<view class="flow-detail-name-group">
<text class="flow-detail-name">{{item.name}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
<text class="flow-detail-name">{{fmt.safe(item.name)}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{fmt.safe(item.desc)}}</text>
</view>
<view class="flow-detail-right">
<text class="flow-detail-val {{item.value === '-¥0' ? 'flow-detail-val--muted' : 'flow-detail-val--red'}}">{{item.value}}</text>
<text class="flow-detail-val {{item.value === 0 ? 'flow-detail-val--muted' : 'flow-detail-val--red'}}">{{fmt.negativeMoney(item.value)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled && item.compare}}">
<text class="compare-text-down-xs">{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, item.isDown, 'xs')}}">{{fmt.compareText(item.compare, item.isDown)}}</text>
</view>
</view>
</view>
@@ -469,12 +469,13 @@
<text class="flow-total-desc">此金额收款渠道分布如下</text>
</view>
<view class="flow-total-right">
<text class="flow-total-value">{{revenue.confirmedTotal}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{revenue.confirmedTotalCompare}}</text>
<text class="flow-total-value">{{fmt.money(revenue.confirmedTotal)}}</text>
<view class="compare-row-standalone" wx:if="{{compareEnabled && revenue.confirmedTotalCompare}}">
<text class="{{fmt.compareClass(revenue.confirmedTotalCompare, revenue.confirmedTotalDown, 'sm')}}">{{fmt.compareText(revenue.confirmedTotalCompare, revenue.confirmedTotalDown)}}</text>
</view>
</view>
</view>
<!-- 收款渠道 -->
<view class="flow-header">
<text class="flow-header-title">收款渠道明细</text>
@@ -482,13 +483,13 @@
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.channelItems}}" wx:key="name">
<view class="flow-detail-name-group">
<text class="flow-detail-name">{{item.name}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
<text class="flow-detail-name">{{fmt.safe(item.name)}}</text>
<text class="flow-detail-name-desc" wx:if="{{item.desc}}">{{fmt.safe(item.desc)}}</text>
</view>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<text class="flow-detail-val">{{fmt.money(item.value)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, false, 'xs')}}">{{fmt.compareText(item.compare, false)}}</text>
</view>
</view>
</view>
@@ -499,8 +500,8 @@
<view class="card-tear"></view>
</view>
<!-- ===== 板块 4: 现金流入 ===== -->
<view id="section-cashflow" class="card-section">
<!-- ===== 板块 4: 现金流入(仅"全部区域"时显示) ===== -->
<view id="section-cashflow" class="card-section" wx:if="{{selectedArea === 'all'}}">
<view class="card-header-light">
<text class="card-header-emoji">🧾</text>
<view class="card-header-text">
@@ -515,13 +516,13 @@
<view class="flow-item-list">
<view class="flow-item" wx:for="{{cashflow.consumeItems}}" wx:key="name">
<view class="flow-item-left">
<text class="flow-item-name">{{item.name}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
<text class="flow-item-name">{{fmt.safe(item.name)}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{fmt.safe(item.desc)}}</text>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{item.value}}</text>
<text class="flow-item-value">{{fmt.money(item.value)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, item.isDown, 'xs')}}">{{fmt.compareText(item.compare, item.isDown)}}</text>
</view>
</view>
</view>
@@ -533,13 +534,13 @@
<view class="flow-item-list">
<view class="flow-item" wx:for="{{cashflow.rechargeItems}}" wx:key="name">
<view class="flow-item-left">
<text class="flow-item-name">{{item.name}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{item.desc}}</text>
<text class="flow-item-name">{{fmt.safe(item.name)}}</text>
<text class="flow-item-desc" wx:if="{{item.desc}}">{{fmt.safe(item.desc)}}</text>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{item.value}}</text>
<text class="flow-item-value">{{fmt.money(item.value)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, false, 'xs')}}">{{fmt.compareText(item.compare, false)}}</text>
</view>
</view>
</view>
@@ -549,9 +550,9 @@
<view class="flow-sum-row">
<text class="flow-sum-label">现金流入合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{cashflow.total}}</text>
<text class="flow-sum-value">{{fmt.money(cashflow.total)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{cashflow.totalCompare}}</text>
<text class="{{fmt.compareClass(cashflow.totalCompare, false, 'sm')}}">{{fmt.compareText(cashflow.totalCompare, false)}}</text>
</view>
</view>
</view>
@@ -560,8 +561,8 @@
<view class="card-tear"></view>
</view>
<!-- ===== 板块 5: 现金流出 ===== -->
<view id="section-expense" class="card-section">
<!-- ===== 板块 5: 现金流出(仅"全部区域"时显示) ===== -->
<view id="section-expense" class="card-section" wx:if="{{selectedArea === 'all'}}">
<view class="card-header-light">
<text class="card-header-emoji">📤</text>
<view class="card-header-text">
@@ -575,10 +576,10 @@
<text class="expense-group-label">进货与运营</text>
<view class="expense-grid-3">
<view class="expense-cell" wx:for="{{expense.operationItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<text class="expense-cell-label">{{fmt.safe(item.name)}}</text>
<text class="expense-cell-value">{{fmt.money(item.value)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, item.isDown, 'xs')}}">{{fmt.compareText(item.compare, item.isDown)}}</text>
</view>
</view>
</view>
@@ -587,10 +588,10 @@
<text class="expense-group-label">固定支出</text>
<view class="expense-grid-2">
<view class="expense-cell" wx:for="{{expense.fixedItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<text class="expense-cell-label">{{fmt.safe(item.name)}}</text>
<text class="expense-cell-value">{{fmt.money(item.value)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.isFlat ? '' : '↑'}}{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, false, 'xs')}}">{{fmt.compareText(item.compare, false)}}</text>
</view>
</view>
</view>
@@ -599,10 +600,10 @@
<text class="expense-group-label">助教薪资</text>
<view class="expense-grid-2">
<view class="expense-cell" wx:for="{{expense.coachItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<text class="expense-cell-label">{{fmt.safe(item.name)}}</text>
<text class="expense-cell-value">{{fmt.money(item.value)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, item.isDown, 'xs')}}">{{fmt.compareText(item.compare, item.isDown)}}</text>
</view>
</view>
</view>
@@ -612,10 +613,10 @@
<text class="expense-group-note">服务费在流水流入时,平台已经扣除。不产生支出流水。</text>
<view class="expense-grid-3">
<view class="expense-cell" wx:for="{{expense.platformItems}}" wx:key="name">
<text class="expense-cell-label">{{item.name}}</text>
<text class="expense-cell-value">{{item.value}}</text>
<text class="expense-cell-label">{{fmt.safe(item.name)}}</text>
<text class="expense-cell-value">{{fmt.money(item.value)}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{item.compare}}</text>
<text class="{{fmt.compareClass(item.compare, false, 'xs')}}">{{fmt.compareText(item.compare, false)}}</text>
</view>
</view>
</view>
@@ -624,9 +625,9 @@
<view class="flow-sum-row">
<text class="flow-sum-label">支出合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{expense.total}}</text>
<text class="flow-sum-value">{{fmt.money(expense.total)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">{{expense.totalCompare}}</text>
<text class="{{fmt.compareClass(expense.totalCompare, false, 'sm')}}">{{fmt.compareText(expense.totalCompare, false)}}</text>
</view>
</view>
</view>
@@ -659,43 +660,43 @@
<view class="coach-fin-row coach-fin-row--total">
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalPay}}</text>
<text class="coach-fin-bold">{{fmt.money(coachAnalysis.basic.totalPay)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.basic.totalPayCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.basic.totalPayCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.basic.totalPayCompare, false)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalShare}}</text>
<text class="coach-fin-bold">{{fmt.money(coachAnalysis.basic.totalShare)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.basic.totalShareCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.basic.totalShareCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.basic.totalShareCompare, false)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.basic.avgHourly}}</text>
<text class="coach-fin-val-sm">{{fmt.money(coachAnalysis.basic.avgHourly)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.basic.avgHourlyCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.basic.avgHourlyCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.basic.avgHourlyCompare, false)}}</text>
</view>
</view>
</view>
<!-- 明细行 -->
<view class="coach-fin-row coach-fin-row--detail" wx:for="{{coachAnalysis.basic.rows}}" wx:key="level">
<text class="coach-fin-col coach-fin-col--name">{{item.level}}</text>
<text class="coach-fin-col coach-fin-col--name">{{fmt.safe(item.level)}}</text>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.pay}}</text>
<text class="coach-fin-val">{{fmt.money(item.pay)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.payDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.payDown ? '↓' : '↑'}}{{item.payCompare}}</text>
<text class="{{fmt.compareClass(item.payCompare, item.payDown, 'xs')}}">{{fmt.compareText(item.payCompare, item.payDown)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.share}}</text>
<text class="coach-fin-val">{{fmt.money(item.share)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.shareDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.shareDown ? '↓' : '↑'}}{{item.shareCompare}}</text>
<text class="{{fmt.compareClass(item.shareCompare, item.shareDown, 'xs')}}">{{fmt.compareText(item.shareCompare, item.shareDown)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{item.hourly}}</text>
<text class="coach-fin-val-sm">{{fmt.money(item.hourly)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="{{item.hourlyFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.hourlyFlat ? '' : '↑'}}{{item.hourlyCompare}}</text>
<text class="{{fmt.compareClass(item.hourlyCompare, false, 'xs')}}">{{fmt.compareText(item.hourlyCompare, false)}}</text>
</view>
</view>
</view>
@@ -713,21 +714,21 @@
<view class="coach-fin-row coach-fin-row--incentive-total">
<text class="coach-fin-col coach-fin-col--name coach-fin-bold">合计</text>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalPay}}</text>
<text class="coach-fin-bold">{{fmt.money(coachAnalysis.incentive.totalPay)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.incentive.totalPayCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.incentive.totalPayCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.incentive.totalPayCompare, false)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalShare}}</text>
<text class="coach-fin-bold">{{fmt.money(coachAnalysis.incentive.totalShare)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.incentive.totalShareCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.incentive.totalShareCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.incentive.totalShareCompare, false)}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.incentive.avgHourly}}</text>
<text class="coach-fin-val-sm">{{fmt.money(coachAnalysis.incentive.avgHourly)}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">{{coachAnalysis.incentive.avgHourlyCompare}}</text>
<text class="{{fmt.compareClass(coachAnalysis.incentive.avgHourlyCompare, false, 'xs')}}">{{fmt.compareText(coachAnalysis.incentive.avgHourlyCompare, false)}}</text>
</view>
</view>
</view>

View File

@@ -276,15 +276,17 @@ AI_CHANGELOG
/* ===== 板块正文容器 ===== */
/* CHANGE 2026-03-12 | intent: 校对 H5 p-4(16px→32rpx) */
.section-body {
padding: 30rpx 30rpx;
padding: 30rpx 40rpx;
}
/* ===== 子标题 ===== */
/* CHANGE 2026-03-12 | intent: 校对 H5 gap-2(8px→16rpx)、mb-3(12px→24rpx)、padding 匹配 summary-content 16px→32rpx */
/* CHANGE 2026-03-27 | 各 text 独立一行 */
.sub-section-label {
display: flex;
align-items: center;
gap: 14rpx;
flex-direction: column;
align-items: flex-start;
gap: 6rpx;
padding: 0rpx 30rpx 22rpx;
}
@@ -688,7 +690,7 @@ AI_CHANGELOG
/* CHANGE 2026-03-13 | intent: 校对 H5 gift-table-header padding:8px 16px→16rpx 32rpx */
.gift-table-header {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 1fr;
grid-template-columns: 0.2fr 0.3fr 0.3fr 0.3fr;
gap: 8rpx;
padding: 14rpx 30rpx;
background: #f0f0f0;
@@ -728,7 +730,7 @@ AI_CHANGELOG
/* CHANGE 2026-03-13 | intent: 校对 H5 gift-table-row padding:12px 16px→24rpx 32rpx */
.gift-table-row {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 1fr;
grid-template-columns:0.2fr 0.5fr 0.5fr 0.5fr;
gap: 8rpx;
padding: 22rpx 30rpx;
border-bottom: 2rpx solid #f3f3f3;
@@ -857,7 +859,7 @@ AI_CHANGELOG
/* CHANGE 2026-03-14 | intent: H5 gap-1=4px → spec p-1=8rpx之前误转为 4rpx */
.rev-table-header {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 8rpx;
padding: 14rpx 30rpx;
background: #f0f0f0;
@@ -886,9 +888,9 @@ AI_CHANGELOG
/* CHANGE 2026-03-14 | intent: H5 gap-1=4px → spec p-1=8rpx */
.rev-table-row {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 8rpx;
padding: 22rpx 30rpx;
padding: 22rpx 18rpx;
border-bottom: 2rpx solid #f3f3f3;
align-items: start;
}
@@ -915,7 +917,7 @@ AI_CHANGELOG
font-size: 26rpx;
line-height: 36rpx;
color: #8b8b8b;
padding-left: 30rpx;
padding-left: 10rpx;
}
/* CHANGE 2026-03-14 | intent: H5 text-xs=12px → spec 22rpx */
@@ -950,8 +952,11 @@ AI_CHANGELOG
/* ===== 损益链流程 ===== */
/* CHANGE 2026-03-13 | intent: 校对 H5 flow-header padding:10px 16px→20rpx 32rpx */
.flow-header {
display: flex;
align-items: center;
padding: 18rpx 30rpx;
background: #fafafa;
justify-content: space-between;
}
.flow-header--deduct {
@@ -972,7 +977,7 @@ AI_CHANGELOG
line-height: 29rpx;
color: #8b8b8b;
display: block;
margin-top: 2rpx;
margin: 2rpx auto 2rpx 30rpx;
}
/* CHANGE 2026-03-13 | intent: 校对 H5 flow-detail-list padding:12px 16px→24rpx 32rpx, margin:8px 16px→16rpx 32rpx */
@@ -1061,7 +1066,7 @@ AI_CHANGELOG
}
.flow-total-right {
display: flex;
display: inline-block;
align-items: center;
gap: 20rpx;
}
@@ -1623,3 +1628,36 @@ AI_CHANGELOG
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}
/* CHANGE 2026-03-27 | board-finance-phase2 T3 | 优惠总计行样式 */
.flow-total-row--discount {
border-top: none;
padding-top: 0;
margin-top: -8rpx;
}
.flow-total-label--red {
color: #e74c3c;
}
.flow-total-value--red {
color: #e74c3c;
}
/* CHANGE 2026-03-28 | board-finance-phase2 bugfix | 优惠扣减标题右侧总计 */
.flow-header-total {
margin-right: 30rpx;
font-size: 26rpx;
font-weight: 600;
}
.flow-header-total--red {
color: #e74c3c;
}
/* CHANGE 2026-03-28 | 环比持平样式(灰色无箭头) */
.compare-text-flat-sm { font-size: 24rpx; line-height: 29rpx; color: #a6a6a6; }
/* CHANGE 2026-03-28 | 成交/确认收入环比独立行 */
.compare-row-standalone {
display: flex;
justify-content: flex-end;
padding: 0 30rpx 12rpx;
}

View File

@@ -1,3 +1,9 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchChatHistory } from '../../services/api'
import { formatRelativeTime } from '../../utils/time'
@@ -40,9 +46,15 @@ Page({
this.loadData()
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat-history/chat-history')
},
/** 加载数据 */
async loadData() {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchChatHistory()
@@ -62,6 +74,8 @@ Page({
})
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},

View File

@@ -1,15 +1,8 @@
<!-- pages/chat-history/chat-history.wxml — 对话历史 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="page-error" wx:if="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-icon">😵</text>
<text class="error-text">加载失败,请重试</text>
@@ -44,12 +37,12 @@
</view>
<view class="chat-content">
<view class="chat-top">
<text class="chat-title text-ellipsis">{{item.title}}</text>
<text class="chat-time">{{item.timeLabel}}</text>
<text class="chat-title text-ellipsis">{{fmt.safe(item.title)}}</text>
<text class="chat-time">{{fmt.safe(item.timeLabel)}}</text>
</view>
<view class="chat-bottom">
<text class="chat-summary text-ellipsis" wx:if="{{item.customerName}}">{{item.customerName}} · {{item.lastMessage}}</text>
<text class="chat-summary text-ellipsis" wx:else>{{item.lastMessage}}</text>
<text class="chat-summary text-ellipsis" wx:if="{{item.customerName}}">{{fmt.safe(item.customerName)}} · {{fmt.safe(item.lastMessage)}}</text>
<text class="chat-summary text-ellipsis" wx:else>{{fmt.safe(item.lastMessage)}}</text>
</view>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />

View File

@@ -1,3 +1,9 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// pages/chat/chat.ts — AI 对话页
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由,替换 mock 为真实 API
// CHANGE 2026-03-20 | RNS1.4 T8.2: 替换 simulateStreamOutput 为真实 SSE 连接
@@ -177,6 +183,10 @@ Page({
customerId: '',
/** 输入栏距底部距离(软键盘弹出时动态调整)*/
inputBarBottom: 0,
/** 来源页面标识(看板入口) */
sourcePage: '',
/** 看板筛选参数 */
pageFilters: {} as Record<string, string>,
},
/** 消息计数器,用于生成唯一 ID */
@@ -188,6 +198,11 @@ Page({
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat/chat')
},
// CHANGE 2026-03-20 | RNS1.4 T8.1: 多入口参数路由
// 优先级historyId → taskId → customerId → coachId → general
onLoad(options) {
@@ -210,6 +225,15 @@ Page({
} else if (options?.coachId) {
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
this.loadMessagesByContext('coach', options.coachId)
} else if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
const pageFilters: Record<string, string> = {}
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
}
this.setData({ sourcePage: options.sourcePage, pageFilters })
this.loadMessagesByContext(options.sourcePage, '')
} else {
// 无参数入口:始终新建通用对话
this.loadMessagesByContext('general', '')
@@ -219,6 +243,7 @@ Page({
/** 通过 chatId 加载历史消息historyId 入口) */
async loadMessages(chatId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchChatMessages(chatId)
this.setData({ chatId: String(res.chatId) })
@@ -231,12 +256,15 @@ Page({
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 通过上下文类型加载消息task/customer/coach/general 入口) */
async loadMessagesByContext(contextType: string, contextId: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchChatMessagesByContext(contextType, contextId)
// 缓存后端返回的 chatId后续发送消息使用
@@ -258,6 +286,8 @@ Page({
this.scrollToBottom()
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
@@ -427,7 +457,14 @@ Page({
const requestTask = wx.request({
url: `${API_BASE}/api/xcx/chat/stream`,
method: 'POST',
data: { chatId: Number(chatId), content },
data: {
chatId: Number(chatId),
content,
...(this.data.sourcePage ? {
sourcePage: this.data.sourcePage,
pageContext: this.data.pageFilters,
} : {}),
},
header: headers,
enableChunked: true,
responseType: 'arraybuffer',

View File

@@ -1,16 +1,9 @@
<!-- pages/chat/chat.wxml — AI 对话页 -->
<wxs src="../../utils/time.wxs" module="timefmt" />
<!-- 加载态 -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="page-error" wx:if="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">
@@ -37,9 +30,9 @@
<view class="reference-card" wx:if="{{referenceCard}}">
<view class="reference-label-row">
<text class="reference-tag">引用内容</text>
<text class="reference-source">{{referenceCard.title}}</text>
<text class="reference-source">{{fmt.safe(referenceCard.title)}}</text>
</view>
<text class="reference-summary">{{referenceCard.summary}}</text>
<text class="reference-summary">{{fmt.safe(referenceCard.summary)}}</text>
</view>
<!-- 空对话提示 -->
@@ -76,13 +69,13 @@
<view class="inline-ref-card inline-ref-card--user" wx:if="{{item.referenceCard}}">
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
<text class="inline-ref-title">{{fmt.safe(item.referenceCard.title)}}</text>
</view>
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
<text class="inline-ref-summary">{{fmt.safe(item.referenceCard.summary)}}</text>
<view class="inline-ref-data">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{entry.key}}</text>
<text class="ref-data-value">{{entry.value}}</text>
<text class="ref-data-key">{{fmt.safe(entry.key)}}</text>
<text class="ref-data-value">{{fmt.safe(entry.value)}}</text>
</view>
</view>
</view>
@@ -102,13 +95,13 @@
<view class="inline-ref-card inline-ref-card--assistant" wx:if="{{item.referenceCard}}">
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-title">{{item.referenceCard.title}}</text>
<text class="inline-ref-title">{{fmt.safe(item.referenceCard.title)}}</text>
</view>
<text class="inline-ref-summary">{{item.referenceCard.summary}}</text>
<text class="inline-ref-summary">{{fmt.safe(item.referenceCard.summary)}}</text>
<view class="inline-ref-data">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{entry.key}}</text>
<text class="ref-data-value">{{entry.value}}</text>
<text class="ref-data-key">{{fmt.safe(entry.key)}}</text>
<text class="ref-data-value">{{fmt.safe(entry.value)}}</text>
</view>
</view>
</view>

View File

@@ -1,4 +1,12 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCoachDetail } from '../../services/api'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { sortByTimestamp } from '../../utils/sort'
/* ── 进度条动画参数(与 task-list 共享相同逻辑) ──
@@ -128,101 +136,57 @@ interface HistoryMonth {
recallDone: number
}
/** Mock 数据 */
/** Mock 数据(已清空,用于排查 MOCK 覆盖完整性) */
const mockCoachDetail: CoachDetail = {
id: 'coach-001',
name: '小燕',
avatar: '/assets/images/avatar-coach.png',
level: '星级',
skills: ['中🎱', '🎯斯诺克'],
workYears: 3,
customerCount: 68,
hireDate: '2023-03-15',
id: '',
name: '',
avatar: '',
level: '',
skills: [],
workYears: 0,
customerCount: 0,
hireDate: '',
performance: {
monthlyHours: 87.5,
monthlySalary: 6950,
customerBalance: 86200,
tasksCompleted: 38,
perfCurrent: 80,
perfTarget: 100,
monthlyHours: 0,
monthlySalary: 0,
customerBalance: 0,
tasksCompleted: 0,
perfCurrent: 0,
perfTarget: 0,
},
income: {
thisMonth: [
{ label: '基础课时费', amount: '¥3,500', color: 'primary' },
{ label: '激励课时费', amount: '¥1,800', color: 'success' },
{ label: '充值提成', amount: '¥1,200', color: 'warning' },
{ label: '酒水提成', amount: '¥450', color: 'purple' },
],
lastMonth: [
{ label: '基础课时费', amount: '¥3,800', color: 'primary' },
{ label: '激励课时费', amount: '¥1,900', color: 'success' },
{ label: '充值提成', amount: '¥1,100', color: 'warning' },
{ label: '酒水提成', amount: '¥400', color: 'purple' },
],
thisMonth: [],
lastMonth: [],
},
notes: [
{ id: 'n1', content: '本月表现优秀,客户好评率高', timestamp: '2026-03-05T14:30:00', score: 9, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-03-05 14:30' },
{ id: 'n2', content: '需要加强斯诺克教学技巧', timestamp: '2026-02-28T10:00:00', score: 7, customerName: '管理员', tagLabel: '管理员', createdAt: '2026-02-28 10:00' },
{ id: 'n3', content: '客户王先生反馈服务态度很好', timestamp: '2026-02-20T16:45:00', score: 8, customerName: '王先生', tagLabel: '王先生', createdAt: '2026-02-20 16:45' },
],
notes: [],
}
const mockVisibleTasks: TaskItem[] = [
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '王先生', noteCount: 2, pinned: true, notes: [{ pinned: true, text: '重点客户,每周必须联系', date: '2026-02-06' }, { text: '上次来说最近出差多', date: '2026-02-01' }] },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '李女士', noteCount: 0, pinned: true },
{ typeLabel: '高优先召回', typeClass: 'high-priority', customerName: '陈女士', noteCount: 1, pinned: true, notes: [{ text: '喜欢斯诺克,周末常来', date: '2026-01-28' }] },
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '张先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '赵总', noteCount: 3, pinned: false, notes: [{ pinned: true, text: '大客户,注意维护关系', date: '2026-02-03' }, { text: '上次带了3个朋友来', date: '2026-01-25' }, { text: '喜欢VIP包厢', date: '2026-01-15' }] },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '周女士', noteCount: 0, pinned: false },
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false, notes: [{ text: '', date: '' }] },
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false },
]
const mockHiddenTasks: TaskItem[] = [
{ typeLabel: '优先召回', typeClass: 'priority', customerName: '刘先生', noteCount: 0, pinned: false },
{ typeLabel: '客户回访', typeClass: 'callback', customerName: '孙先生', noteCount: 0, pinned: false },
{ typeLabel: '关系构建', typeClass: 'relationship', customerName: '吴女士', noteCount: 0, pinned: false },
{ typeLabel: '', typeClass: '', customerName: '', noteCount: 0, pinned: false },
]
const mockAbandonedTasks: AbandonedTask[] = [
{ customerName: '吴先生', reason: '客户拒绝' },
{ customerName: '郑女士', reason: '超时未响应' },
{ customerName: '', reason: '' },
]
const mockTopCustomers: TopCustomer[] = [
{ id: 'c1', name: '王先生', initial: '', avatarGradient: 'pink', heartEmoji: '❤️', score: '9.5', scoreColor: 'success', serviceCount: 25, balance: '¥8,600', consume: '¥12,800' },
{ id: 'c2', name: '李女士', initial: '', avatarGradient: 'amber', heartEmoji: '❤️', score: '9.2', scoreColor: 'success', serviceCount: 22, balance: '¥6,200', consume: '¥9,500' },
{ id: 'c3', name: '陈女士', initial: '陈', avatarGradient: 'green', heartEmoji: '❤️', score: '8.5', scoreColor: 'warning', serviceCount: 18, balance: '¥5,000', consume: '¥7,200' },
{ id: 'c4', name: '张先生', initial: '张', avatarGradient: 'blue', heartEmoji: '💛', score: '7.8', scoreColor: 'warning', serviceCount: 12, balance: '¥3,800', consume: '¥5,600' },
{ id: 'c5', name: '赵先生', initial: '赵', avatarGradient: 'violet', heartEmoji: '💛', score: '6.8', scoreColor: 'gray', serviceCount: 8, balance: '¥2,000', consume: '¥3,200' },
{ id: 'c6', name: '刘女士', initial: '刘', avatarGradient: 'pink', heartEmoji: '💛', score: '6.5', scoreColor: 'gray', serviceCount: 7, balance: '¥1,800', consume: '¥2,900' },
{ id: 'c7', name: '孙先生', initial: '孙', avatarGradient: 'teal', heartEmoji: '💛', score: '6.2', scoreColor: 'gray', serviceCount: 6, balance: '¥1,500', consume: '¥2,500' },
{ id: 'c8', name: '周女士', initial: '周', avatarGradient: 'amber', heartEmoji: '💛', score: '6.0', scoreColor: 'gray', serviceCount: 6, balance: '¥1,400', consume: '¥2,200' },
{ id: 'c9', name: '吴先生', initial: '吴', avatarGradient: 'blue', heartEmoji: '💛', score: '5.8', scoreColor: 'gray', serviceCount: 5, balance: '¥1,200', consume: '¥2,000' },
{ id: 'c10', name: '郑女士', initial: '郑', avatarGradient: 'green', heartEmoji: '💛', score: '5.5', scoreColor: 'gray', serviceCount: 5, balance: '¥1,000', consume: '¥1,800' },
{ id: 'c11', name: '冯先生', initial: '冯', avatarGradient: 'violet', heartEmoji: '🤍', score: '5.2', scoreColor: 'gray', serviceCount: 4, balance: '¥900', consume: '¥1,600' },
{ id: 'c12', name: '褚女士', initial: '褚', avatarGradient: 'pink', heartEmoji: '🤍', score: '5.0', scoreColor: 'gray', serviceCount: 4, balance: '¥800', consume: '¥1,400' },
{ id: 'c13', name: '卫先生', initial: '卫', avatarGradient: 'amber', heartEmoji: '🤍', score: '4.8', scoreColor: 'gray', serviceCount: 3, balance: '¥700', consume: '¥1,200' },
{ id: 'c14', name: '蒋女士', initial: '蒋', avatarGradient: 'teal', heartEmoji: '🤍', score: '4.5', scoreColor: 'gray', serviceCount: 3, balance: '¥600', consume: '¥1,000' },
{ id: 'c15', name: '沈先生', initial: '沈', avatarGradient: 'blue', heartEmoji: '🤍', score: '4.2', scoreColor: 'gray', serviceCount: 3, balance: '¥500', consume: '¥900' },
{ id: 'c16', name: '韩女士', initial: '韩', avatarGradient: 'green', heartEmoji: '🤍', score: '4.0', scoreColor: 'gray', serviceCount: 2, balance: '¥400', consume: '¥800' },
{ id: 'c17', name: '杨先生', initial: '杨', avatarGradient: 'violet', heartEmoji: '🤍', score: '3.8', scoreColor: 'gray', serviceCount: 2, balance: '¥300', consume: '¥700' },
{ id: 'c18', name: '朱女士', initial: '朱', avatarGradient: 'pink', heartEmoji: '🤍', score: '3.5', scoreColor: 'gray', serviceCount: 2, balance: '¥200', consume: '¥600' },
{ id: 'c19', name: '秦先生', initial: '秦', avatarGradient: 'amber', heartEmoji: '🤍', score: '3.2', scoreColor: 'gray', serviceCount: 1, balance: '¥100', consume: '¥500' },
{ id: 'c20', name: '尤女士', initial: '尤', avatarGradient: 'teal', heartEmoji: '🤍', score: '3.0', scoreColor: 'gray', serviceCount: 1, balance: '¥0', consume: '¥400' },
{ id: '', name: '', initial: '', avatarGradient: '', heartEmoji: '', score: '', scoreColor: '', serviceCount: 0, balance: '', consume: '' },
{ id: '', name: '', initial: '', avatarGradient: '', heartEmoji: '', score: '', scoreColor: '', serviceCount: 0, balance: '', consume: '' },
]
const mockServiceRecords: ServiceRecord[] = [
{ customerId: 'c1', customerName: '王先生', initial: '', avatarGradient: 'pink', type: '基础课', typeClass: 'basic', table: 'A12号台', duration: '2.5h', income: '¥200', date: '2026-02-07 21:30' },
{ customerId: 'c2', customerName: '李女士', initial: '', avatarGradient: 'amber', type: '激励课', typeClass: 'incentive', table: 'VIP1号房', duration: '1.5h', income: '¥150', date: '2026-02-07 19:00', perfHours: '2h' },
{ customerId: 'c3', customerName: '陈女士', initial: '陈', avatarGradient: 'green', type: '基础课', typeClass: 'basic', table: '2号台', duration: '2h', income: '¥160', date: '2026-02-06 20:00' },
{ customerId: 'c4', customerName: '张先生', initial: '张', avatarGradient: 'blue', type: '激励课', typeClass: 'incentive', table: '5号台', duration: '1h', income: '¥80', date: '2026-02-05 14:00' },
{ customerName: '', initial: '', avatarGradient: '', type: '', typeClass: '', table: '', duration: '', income: '', date: '' },
{ customerName: '', initial: '', avatarGradient: '', type: '', typeClass: '', table: '', duration: '', income: '', date: '' },
]
const mockHistoryMonths: HistoryMonth[] = [
{ month: '本月', estimated: true, customers: '22人', hours: '87.5h', salary: '¥6,950', callbackDone: 14, recallDone: 24 },
{ month: '上月', estimated: false, customers: '25人', hours: '92.0h', salary: '¥7,200', callbackDone: 16, recallDone: 28 },
{ month: '4月', estimated: false, customers: '20人', hours: '85.0h', salary: '¥6,600', callbackDone: 12, recallDone: 22 },
{ month: '3月', estimated: false, customers: '18人', hours: '78.5h', salary: '¥6,100', callbackDone: 10, recallDone: 18 },
{ month: '2月', estimated: false, customers: '15人', hours: '65.0h', salary: '¥5,200', callbackDone: 8, recallDone: 15 },
{ month: '', estimated: false, customers: '', hours: '', salary: '', callbackDone: 0, recallDone: 0 },
{ month: '', estimated: false, customers: '', hours: '', salary: '', callbackDone: 0, recallDone: 0 },
]
Page({
@@ -245,7 +209,7 @@ Page({
currentIncome: [] as IncomeItem[],
incomeTotal: '',
/** 任务执行 */
taskStats: { recall: 24, callback: 14 },
taskStats: { recall: 0, callback: 0 },
visibleTasks: [] as TaskItem[],
hiddenTasks: [] as TaskItem[],
abandonedTasks: [] as AbandonedTask[],
@@ -293,6 +257,8 @@ Page({
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/coach-detail/coach-detail')
if (this.data.pageState === 'normal' && !this._animTimer) {
this._startAnimLoop()
}
@@ -328,60 +294,58 @@ Page({
}, sparkTriggerDelay)
},
// CHANGE 2026-03-29 | P2 联调Mock → 真实 API所有字段从 fetchCoachDetail 映射
async loadData(id: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
// 从真实 API 获取助教基础信息
const basicCoach = await fetchCoachDetail(id)
const detail: CoachDetail = basicCoach
? { ...mockCoachDetail, id: basicCoach.id, name: basicCoach.name }
: mockCoachDetail
if (!detail) {
const d = await fetchCoachDetail(id)
if (!d) {
this.setData({ pageState: 'empty' })
return
}
const perf = detail.performance
const perf = d.performance || {} as any
const perfCards = [
{ label: '本月定档业绩', value: `${perf.monthlyHours}`, unit: 'h', sub: '折算前 89.0h', bgClass: 'perf-blue', valueColor: 'text-primary' },
{ label: '本月工资(预估)', value: `¥${perf.monthlySalary.toLocaleString()}`, unit: '', sub: '含预估部分', bgClass: 'perf-green', valueColor: 'text-success' },
{ label: '客源储值余额', value: `¥${perf.customerBalance.toLocaleString()}`, unit: '', sub: `${detail.customerCount}客户合计`, bgClass: 'perf-orange', valueColor: 'text-warning' },
{ label: '本月任务完成', value: `${perf.tasksCompleted}`, unit: '', sub: '覆盖 22 位客户', bgClass: 'perf-purple', valueColor: 'text-purple' },
{ 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' },
]
const perfGap = perf.perfTarget - perf.perfCurrent
const perfPercent = Math.min(Math.round((perf.perfCurrent / perf.perfTarget) * 100), 100)
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 tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
const maxHours = 220
const totalHours = perf.monthlyHours
// 档位节点从 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 sorted = sortByTimestamp(detail.notes || [], 'timestamp') as NoteItem[]
const sorted = sortByTimestamp(d.notes || [], 'timestamp') as NoteItem[]
const taskStats = d.taskStats ?? { recall: 0, callback: 0 }
this.setData({
pageState: 'normal',
detail,
detail: d,
perfCards,
perfCurrent: perf.perfCurrent,
perfTarget: perf.perfTarget,
perfCurrent: perf.perfCurrent ?? 0,
perfTarget: perf.perfTarget ?? 0,
perfGap,
perfPercent,
visibleTasks: mockVisibleTasks,
hiddenTasks: mockHiddenTasks,
abandonedTasks: mockAbandonedTasks,
topCustomers: mockTopCustomers,
serviceRecords: mockServiceRecords,
historyMonths: mockHistoryMonths,
taskStats,
visibleTasks: d.visibleTasks || [],
hiddenTasks: d.hiddenTasks || [],
abandonedTasks: d.abandonedTasks || [],
topCustomers: d.topCustomers || [],
serviceRecords: d.serviceRecords || [],
historyMonths: d.historyMonths || [],
sortedNotes: sorted,
pbFilledPct,
pbClampedSparkPct: Math.max(0, Math.min(100, pbFilledPct)),
@@ -392,10 +356,12 @@ Page({
})
this.switchIncomeTab('this')
// 数据加载完成后启动动画循环
setTimeout(() => this._startAnimLoop(), 300)
} catch (_e) {
} catch (e) {
console.error('[coach-detail] loadData 失败:', e)
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
@@ -413,7 +379,7 @@ Page({
this.setData({
incomeTab: tab,
currentIncome: items,
incomeTotal: `¥${total.toLocaleString()}`,
incomeTotal: formatMoney(total),
})
},

View File

@@ -1,13 +1,6 @@
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到助教信息</text>
</view>
@@ -31,7 +24,7 @@
</view>
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{detail.name}}</text>
<text class="coach-name">{{fmt.safe(detail.name)}}</text>
<coach-level-tag level="{{detail.level}}" />
</view>
<view class="skill-row">
@@ -41,12 +34,12 @@
<view class="info-right-stats">
<view class="right-stat">
<text class="right-stat-label">工龄</text>
<text class="right-stat-value">{{detail.workYears}}</text>
<text class="right-stat-value">{{fmt.safe(detail.workYears)}}</text>
<text class="right-stat-label">年</text>
</view>
<view class="right-stat">
<text class="right-stat-label">客户</text>
<text class="right-stat-value">{{detail.customerCount}}</text>
<text class="right-stat-value">{{fmt.safe(detail.customerCount)}}</text>
<text class="right-stat-label">人</text>
</view>
</view>
@@ -75,7 +68,7 @@
<view class="perf-progress-box">
<view class="perf-progress-header">
<text class="perf-progress-label">绩效档位进度</text>
<text class="perf-progress-hint">距下一档还差 {{perfGap}}h</text>
<text class="perf-progress-hint">距下一档还差 {{fmt.hours(perfGap)}}</text>
</view>
<perf-progress-bar
filledPct="{{pbFilledPct}}"
@@ -109,11 +102,11 @@
<view class="income-item" wx:for="{{currentIncome}}" wx:key="label">
<view class="income-dot dot-{{item.color}}"></view>
<text class="income-label">{{item.label}}</text>
<text class="income-amount">{{item.amount}}</text>
<text class="income-amount">{{fmt.safe(item.amount)}}</text>
</view>
<view class="income-total">
<text class="income-total-label">合计{{incomeTab === 'this' ? '(预估)' : ''}}</text>
<text class="income-total-value">{{incomeTotal}}</text>
<text class="income-total-value">{{fmt.safe(incomeTotal)}}</text>
</view>
</view>
</view>
@@ -124,8 +117,8 @@
<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">{{taskStats.callback}}</text></text>
<text class="task-summary-recall">召回<text class="task-summary-num">{{taskStats.recall}}</text></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>
</view>
</view>
@@ -174,7 +167,7 @@
class="top-customer-item"
hover-class="top-customer-item--hover"
wx:for="{{topCustomers}}"
wx:key="id"
wx:key="index"
wx:if="{{topCustomersExpanded || index < 5}}"
data-id="{{item.id}}"
bindtap="onCustomerTap"
@@ -186,12 +179,12 @@
<view class="top-customer-name-row">
<text class="top-customer-name">{{item.name}}</text>
<text class="top-customer-heart">{{item.heartEmoji}}</text>
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{item.score}}</text>
<text class="top-customer-score top-customer-score-{{item.scoreColor}}">{{fmt.safe(item.score)}}</text>
</view>
<view class="top-customer-stats">
<text class="top-customer-stat">服务 <text class="top-customer-stat-val">{{item.serviceCount}}</text></text>
<text class="top-customer-stat">储值 <text class="top-customer-stat-val">{{item.balance}}</text></text>
<text class="top-customer-stat">消费 <text class="top-customer-stat-val">{{item.consume}}</text></text>
<text class="top-customer-stat">服务 <text class="top-customer-stat-val">{{fmt.count(item.serviceCount, '次')}}</text></text>
<text class="top-customer-stat">储值 <text class="top-customer-stat-val">{{fmt.money(item.balance)}}</text></text>
<text class="top-customer-stat">消费 <text class="top-customer-stat-val">{{fmt.money(item.consume)}}</text></text>
</view>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
@@ -208,7 +201,7 @@
<text class="section-title title-purple">近期服务明细</text>
</view>
<view class="svc-list">
<view class="svc-card" wx:for="{{serviceRecords}}" wx:key="index"
<view class="svc-card" wx:for="{{serviceRecords}}" wx:key="index" wx:if="{{index < 5}}"
bindtap="onSvcCardTap" data-id="{{item.customerId}}" hover-class="svc-card--hover">
<!-- 头像列 -->
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
@@ -229,7 +222,7 @@
<text class="svc-duration">{{item.duration}}</text>
<text class="svc-perf" wx:if="{{item.perfHours}}">定档绩效:{{item.perfHours}}</text>
</view>
<text class="svc-income">{{item.income}}</text>
<text class="svc-income">{{fmt.money(item.income)}}</text>
</view>
</view>
</view>
@@ -246,7 +239,7 @@
</view>
<view class="more-info-row">
<text class="more-info-label">入职日期</text>
<text class="more-info-value">{{detail.hireDate}}</text>
<text class="more-info-value">{{fmt.safe(detail.hireDate)}}</text>
</view>
<view class="history-table">
<view class="history-thead">
@@ -261,10 +254,10 @@
<text>{{item.month}}</text>
<text class="history-est" wx:if="{{item.estimated}}">预估</text>
</view>
<text class="history-td">{{item.customers}}</text>
<text class="history-td">{{item.callbackDone}} | {{item.recallDone}}</text>
<text class="history-td {{index === 0 ? 'text-primary' : ''}}">{{item.hours}}</text>
<text class="history-td {{index === 0 ? 'text-success' : ''}}">{{item.salary}}</text>
<text class="history-td">{{fmt.count(item.customers, '人')}}</text>
<text class="history-td">{{fmt.safe(item.callbackDone)}} | {{fmt.safe(item.recallDone)}}</text>
<text class="history-td {{index === 0 ? 'text-primary' : ''}}">{{fmt.hoursH(item.hours)}}</text>
<text class="history-td {{index === 0 ? 'text-success' : ''}}">{{fmt.money(item.salary)}}</text>
</view>
</view>
</view>

View File

@@ -3,6 +3,7 @@
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"heart-icon": "/components/heart-icon/heart-icon",
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",

View File

@@ -1,3 +1,9 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerDetail } from '../../services/api'
interface ConsumptionRecord {
@@ -28,218 +34,50 @@ interface ConsumptionRecord {
}
const mockRecords: ConsumptionRecord[] = [
{
id: "r1",
type: "table",
date: "2026-02-05",
tableName: "A12号台",
startTime: "21:30",
endTime: "00:50",
duration: "3h 20min",
tableFee: 180,
tableOrigPrice: 240,
coaches: [
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "2.5h", fee: 200 },
{ name: "Amy", level: "junior", levelColor: "green", courseType: "激励课", hours: "0.5h", perfHours: "1h", fee: 50 },
],
foodAmount: 210,
foodOrigPrice: 260,
totalAmount: 640,
totalOrigPrice: 750,
},
{
id: "r2",
type: "table",
date: "2026-02-01",
tableName: "888号台",
startTime: "14:00",
endTime: "16:00",
duration: "2h 00min",
tableFee: 120,
coaches: [
{ name: "泡芙", level: "middle", levelColor: "purple", courseType: "激励课", hours: "1.5h", perfHours: "2h", fee: 100 },
],
totalAmount: 220,
},
{
id: "r3",
type: "shop",
date: "2026-01-28",
coaches: [
{ name: "小燕", level: "senior", levelColor: "pink", courseType: "基础课", hours: "1h", fee: 100 },
],
foodAmount: 180,
totalAmount: 280,
},
{ id: '', type: 'table', date: '', tableName: '', startTime: '', endTime: '', duration: '', tableFee: 0, coaches: [{ name: '', level: '', levelColor: '', courseType: '', hours: '', fee: 0 }], foodAmount: 0, totalAmount: 0, payMethod: '' },
{ id: '', type: 'recharge', date: '', rechargeAmount: 0 },
]
Page({
data: {
pageState: "loading" as "loading" | "empty" | "error" | "normal",
detail: {
id: "cust_001",
name: "王先生",
avatarChar: "王",
phone: "13812345678",
balance: "8,600",
consumption60d: "2,800",
idealInterval: "7天",
daysSinceVisit: "12天",
id: '',
name: '',
avatarChar: '',
phone: '',
balance: null as number | null,
consumption60d: null as number | null,
idealInterval: null as number | null,
daysSinceVisit: null as number | null,
},
phoneVisible: false,
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
aiInsight: {
summary: "高价值 VIP 客户,月均到店 4-5 次,偏好夜场中式台球,近期对斯诺克产生兴趣。社交属性强,常带固定球搭子,有拉新能力。储值余额充足,对促销活动响应积极。",
summary: '',
strategies: [
{ color: "green", text: "最后到店距今 12 天,超出理想间隔 7 天,建议尽快安排助教小燕主动联系召回" },
{ color: "amber", text: "客户提到想练斯诺克走位,可推荐斯诺克专项课程包,结合储值优惠提升客单价" },
{ color: "pink", text: "社交属性强,可邀请参加门店球友赛事活动,带动球搭子到店消费" },
{ color: '', text: '' },
{ color: '', text: '' },
],
},
clues: [
{
category: "客户\n基础",
categoryColor: "primary",
text: "🎂 生日 3月15日 · VIP会员 · 注册2年",
source: "系统",
},
{
category: "消费\n习惯",
categoryColor: "success",
text: "🌙 常来夜场 · 月均4-5次",
source: "系统",
},
{
category: "消费\n习惯",
categoryColor: "success",
text: "💰 高客单价",
source: "系统",
detail: "近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%",
},
{
category: "玩法\n偏好",
categoryColor: "purple",
text: "🎱 偏爱中式 · 斯诺克进阶中",
source: "系统",
},
{
category: "促销\n接受",
categoryColor: "warning",
text: "🍷 爱点酒水套餐 · 对储值活动敏感",
source: "系统",
detail: "最近3次到店均点了酒水套餐上次 ¥5000 储值活动当天即充值,对满赠类活动响应率高",
},
{
category: "社交\n关系",
categoryColor: "pink",
text: "👥 常带朋友 · 固定球搭子2人",
source: "系统",
detail: "近60天 80% 的到店为多人局常与「李哥」「阿杰」同行曾介绍2位新客办卡",
},
{
category: "重要\n反馈",
categoryColor: "error",
text: "⚠️ 上次提到想练斯诺克走位对球桌维护质量比较在意建议优先安排VIP房",
source: "小燕",
},
{ category: '', categoryColor: '', text: '', source: '' },
{ category: '', categoryColor: '', text: '', source: '', detail: '' },
],
coachTasks: [
{
name: "小燕",
level: "senior",
levelColor: "pink",
taskType: "高优先召回",
taskColor: "red",
bgClass: "coach-card-red",
status: "normal",
lastService: "02-20 21:30 · 2.5h",
metrics: [
{ label: "近60天次数", value: "18次", color: "primary" },
{ label: "总时长", value: "17h" },
{ label: "次均时长", value: "0.9h", color: "warning" },
],
},
{
name: "泡芙",
level: "middle",
levelColor: "purple",
taskType: "优先召回",
taskColor: "orange",
bgClass: "coach-card-orange",
status: "pinned",
lastService: "02-15 14:00 · 1.5h",
metrics: [
{ label: "近60天次数", value: "12次", color: "primary" },
{ label: "总时长", value: "11h" },
{ label: "次均时长", value: "0.9h", color: "warning" },
],
},
{
name: "Amy",
level: "junior",
levelColor: "green",
taskType: "关系构建",
taskColor: "pink",
bgClass: "coach-card-pink",
status: "normal",
lastService: "02-10 19:00 · 1.0h",
metrics: [
{ label: "近60天次数", value: "8次", color: "primary" },
{ label: "总时长", value: "6h" },
{ label: "次均时长", value: "0.75h", color: "warning" },
],
},
{
name: "Lucy",
level: "senior",
levelColor: "pink",
taskType: "客户回访",
taskColor: "teal",
bgClass: "coach-card-teal",
status: "abandoned",
lastService: "01-28 20:30 · 2.0h",
metrics: [
{ label: "近60天次数", value: "6次", color: "primary" },
{ label: "总时长", value: "9h" },
{ label: "次均时长", value: "1.5h", color: "warning" },
],
},
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
],
favoriteCoaches: [
{
emoji: "❤️",
name: "小燕",
relationIndex: "9.2",
indexColor: "success",
bgClass: "fav-card-pink",
stats: [
{ label: "基础", value: "12h", color: "primary" },
{ label: "激励", value: "5h", color: "warning" },
{ label: "上课", value: "18次" },
{ label: "充值", value: "¥5,000", color: "success" },
],
},
{
emoji: "💛",
name: "泡芙",
relationIndex: "7.8",
indexColor: "warning",
bgClass: "fav-card-amber",
stats: [
{ label: "基础", value: "8h", color: "primary" },
{ label: "激励", value: "3h", color: "warning" },
{ label: "上课", value: "12次" },
{ label: "充值", value: "¥3,000", color: "success" },
],
},
{ emoji: '', name: '', relationIndex: '', indexColor: '', bgClass: '', stats: [{ label: '', value: '' }] },
],
consumptionRecords: mockRecords,
loadingMore: false,
noteModalVisible: false,
favCoachExpanded: false,
sortedNotes: [
{ id: 'n1', tagLabel: '管理员', createdAt: '2026-03-05 14:30', content: '本月到店积极,对斯诺克课程感兴趣,建议持续跟进推荐相关课程包' },
{ id: 'n2', tagLabel: '小燕', createdAt: '2026-02-20 16:45', content: '客户反馈服务态度很好,提到下次想带朋友一起来' },
{ id: 'n3', tagLabel: '管理员', createdAt: '2026-02-10 10:00', content: '上次储值活动当天即充值 ¥5000对满赠类活动响应积极' },
{ id: '', tagLabel: '', createdAt: '', content: '' },
{ id: '', tagLabel: '', createdAt: '', content: '' },
] as Array<{ id: string; tagLabel: string; createdAt: string; content: string }>,
},
@@ -252,25 +90,50 @@ Page({
this.loadDetail(id)
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/customer-detail/customer-detail')
},
// CHANGE 2026-03-29 | P3 联调映射所有后端返回字段AI 相关暂跳过)
async loadDetail(id?: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
if (id) {
const detail = await fetchCustomerDetail(id)
if (detail) {
const d = await fetchCustomerDetail(id)
if (d) {
this.setData({
detail: {
...this.data.detail,
id: detail.id ?? id,
name: detail.name || this.data.detail.name,
phone: detail.phone || this.data.detail.phone,
id: d.id ?? id,
name: d.name || '',
avatarChar: (d.name || '')[0] || '',
phone: d.phone || '',
balance: d.balance ?? null,
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D大写 D
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
idealInterval: d.idealInterval ?? null,
daysSinceVisit: d.daysSinceVisit ?? null,
},
// 维客线索
clues: d.retentionClues || [],
// 助教任务
coachTasks: d.coachTasks || [],
// 最亲密助教
favoriteCoaches: d.favoriteCoaches || [],
// 消费记录
consumptionRecords: d.consumptionRecords || [],
// 备注
sortedNotes: d.notes || [],
})
}
}
this.setData({ pageState: 'normal' })
} catch {
} catch (e) {
console.error('[customer-detail] loadDetail 失败:', e)
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
@@ -298,7 +161,7 @@ Page({
onViewServiceRecords() {
const customerId = this.data.detail?.id || ''
wx.navigateTo({
url: `/pages/customer-service-records/customer-service-records?customerId=${customerId}`,
url: `/pages/customer-records/customer-records?customerId=${customerId}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
@@ -315,11 +178,64 @@ Page({
this.setData({ noteModalVisible: true })
},
onNoteConfirm(e: any) {
// CHANGE 2026-03-29 | 备注创建调用后端 API保存后直接插入列表顶部
async onNoteConfirm(e: any) {
const { content, score } = e.detail || {}
this.setData({ noteModalVisible: false })
if (!content) return
try {
const { createNote } = require('../../services/api')
const result = await createNote({
targetId: Number(this.data.detail.id),
content,
score: score || undefined,
})
wx.showToast({ title: '备注已保存', icon: 'success' })
// 直接插入到列表顶部,不刷新整页
const now = new Date()
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const newNote = {
id: result?.id || Date.now(),
tagLabel: '备注',
createdAt: timeStr,
content,
}
this.setData({
sortedNotes: [newNote, ...this.data.sortedNotes],
})
} catch {
wx.showToast({ title: '备注保存失败', icon: 'none' })
}
},
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
onToggleFavCoaches() {
this.setData({ favCoachExpanded: !this.data.favCoachExpanded })
},
onDeleteNote(e: WechatMiniprogram.TouchEvent) {
const noteId = e.currentTarget.dataset.id
if (!noteId) return
wx.showModal({
title: '确认删除',
content: '删除后不可恢复',
success: async (res) => {
if (!res.confirm) return
try {
const { deleteNote } = require('../../services/api')
await deleteNote(noteId)
// 从列表中移除
this.setData({
sortedNotes: this.data.sortedNotes.filter((n: any) => n.id !== noteId),
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch {
wx.showToast({ title: '删除失败', icon: 'none' })
}
},
})
},
})

View File

@@ -1,13 +1,7 @@
<!-- pages/customer-detail/customer-detail.wxml -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到客户信息</text>
</view>
@@ -44,19 +38,19 @@
<!-- Banner 统计 -->
<view class="banner-stats">
<view class="stat-item stat-border">
<text class="stat-value stat-green">¥{{detail.balance}}</text>
<text class="stat-value stat-green">{{fmt.money(detail.balance)}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">¥{{detail.consumption60d}}</text>
<text class="stat-value">{{fmt.money(detail.consumption60d)}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{detail.idealInterval}}</text>
<text class="stat-value">{{fmt.days(detail.idealInterval)}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{detail.daysSinceVisit}}</text>
<text class="stat-value stat-amber">{{fmt.days(detail.daysSinceVisit)}}</text>
<text class="stat-label">距今到店</text>
</view>
</view>
@@ -74,7 +68,7 @@
<text class="ai-insight-label">AI 智能洞察</text>
</view>
<view class="ai-insight-summary-v">
<text class="ai-insight-summary">{{aiInsight.summary}}</text>
<text class="ai-insight-summary">{{fmt.safe(aiInsight.summary)}}</text>
</view>
<view class="ai-strategy-box">
<text class="strategy-title">当前推荐策略</text>
@@ -119,6 +113,7 @@
<view class="coach-task-card {{item.bgClass}}" wx:for="{{coachTasks}}" wx:key="index">
<view class="coach-task-top">
<view class="coach-name-row">
<heart-icon score="{{item.heartScore}}" />
<text class="coach-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
@@ -130,11 +125,11 @@
</text>
</view>
</view>
<text class="coach-last-service">上次服务:{{item.lastService}}</text>
<text class="coach-last-service">上次服务:{{fmt.safe(item.lastService)}}</text>
<view class="coach-metrics">
<view class="coach-metric" wx:for="{{item.metrics}}" wx:for-item="m" wx:key="label">
<text class="metric-label">{{m.label}}</text>
<text class="metric-value {{m.color ? 'text-' + m.color : ''}}">{{m.value}}</text>
<text class="metric-value {{m.color ? 'text-' + m.color : ''}}">{{fmt.safe(m.value)}}</text>
</view>
</view>
</view>
@@ -148,24 +143,33 @@
<text class="header-hint">近60天</text>
</view>
<view class="fav-coach-list">
<view class="fav-coach-card {{item.bgClass}}" wx:for="{{favoriteCoaches}}" wx:key="index">
<view class="fav-coach-top">
<view class="fav-coach-name">
<text class="fav-emoji">{{item.emoji}}</text>
<text class="fav-name">{{item.name}}</text>
<!-- CHANGE 2026-03-29 | 前 3 个始终显示,其余折叠 -->
<block wx:for="{{favoriteCoaches}}" wx:key="index">
<view class="fav-coach-card {{item.bgClass}}" wx:if="{{index < 3 || favCoachExpanded}}">
<view class="fav-coach-top">
<view class="fav-coach-name">
<!-- CHANGE 2026-03-29 | 统一格式:爱心 - 名称 - 等级 -->
<heart-icon score="{{item.heartScore}}" />
<text class="fav-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<view class="fav-index">
<text class="fav-index-label">关系指数</text>
<text class="fav-index-value" style="color:{{item.indexColor}}">{{fmt.safe(item.relationIndex)}}</text>
</view>
</view>
<view class="fav-index">
<text class="fav-index-label">关系指数</text>
<text class="fav-index-value text-{{item.indexColor}}">{{item.relationIndex}}</text>
</view>
</view>
<text class="fav-period">近60天</text>
<view class="fav-stats">
<view class="fav-stat" wx:for="{{item.stats}}" wx:for-item="s" wx:key="label">
<text class="fav-stat-label">{{s.label}}</text>
<text class="fav-stat-value {{s.color ? 'text-' + s.color : ''}}">{{s.value}}</text>
<text class="fav-period">近60天</text>
<view class="fav-stats">
<view class="fav-stat" wx:for="{{item.stats}}" wx:for-item="s" wx:key="label">
<text class="fav-stat-label">{{s.label}}</text>
<text class="fav-stat-value {{s.color ? 'text-' + s.color : ''}}">{{fmt.safe(s.value)}}</text>
</view>
</view>
</view>
</block>
<!-- 展开/收起按钮 -->
<view class="expand-btn" wx:if="{{favoriteCoaches.length > 3}}" bindtap="onToggleFavCoaches">
<text class="expand-btn-text">{{favCoachExpanded ? '收起' : '展开更多 (' + (favoriteCoaches.length - 3) + ')'}}</text>
</view>
</view>
</view>
@@ -185,33 +189,33 @@
<view class="record-card-header record-header-blue">
<view class="record-project">
<view class="record-dot record-dot-blue"></view>
<text class="record-project-name record-name-blue">{{item.tableName}}</text>
<text class="record-project-name record-name-blue">{{fmt.safe(item.tableName)}}</text>
</view>
<text class="record-date">{{item.date}}</text>
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-time-row">
<view class="record-time-left">
<text class="record-time-text">{{item.startTime}}</text>
<text class="record-time-text">{{fmt.safe(item.startTime)}}</text>
<text class="record-time-arrow">→</text>
<text class="record-time-text">{{item.endTime}}</text>
<text class="record-duration-tag">{{item.duration}}</text>
<text class="record-time-text">{{fmt.safe(item.endTime)}}</text>
<text class="record-duration-tag">{{fmt.safe(item.duration)}}</text>
</view>
<view class="record-fee-right">
<text class="record-fee-amount">¥{{item.tableFee}}</text>
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">¥{{item.tableOrigPrice}}</text>
<text class="record-fee-amount">{{fmt.money(item.tableFee)}}</text>
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">{{fmt.money(item.tableOrigPrice)}}</text>
</view>
</view>
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{c.name}}</text>
<text class="record-coach-name">{{fmt.safe(c.name)}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
<text class="record-coach-type">{{fmt.safe(c.courseType)}} · {{fmt.hours(c.hours)}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
<text class="record-coach-fee">¥{{c.fee}}</text>
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{fmt.hours(c.perfHours)}}</text>
<text class="record-coach-fee">{{fmt.money(c.fee)}}</text>
</view>
</view>
</view>
@@ -219,15 +223,15 @@
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<view class="record-food-right">
<text class="record-food-amount">¥{{item.foodAmount}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">¥{{item.foodOrigPrice}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.foodOrigPrice}}">{{fmt.money(item.foodOrigPrice)}}</text>
</view>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<view class="record-total-right">
<text class="record-total-amount">¥{{item.totalAmount}}</text>
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">¥{{item.totalOrigPrice}}</text>
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">{{fmt.money(item.totalOrigPrice)}}</text>
</view>
</view>
</view>
@@ -239,30 +243,30 @@
<view class="record-dot record-dot-green"></view>
<text class="record-project-name record-name-green">商城订单</text>
</view>
<text class="record-date">{{item.date}}</text>
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{c.name}}</text>
<text class="record-coach-name">{{fmt.safe(c.name)}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
<text class="record-coach-type">{{fmt.safe(c.courseType)}} · {{fmt.hours(c.hours)}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
<text class="record-coach-fee">¥{{c.fee}}</text>
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{fmt.hours(c.perfHours)}}</text>
<text class="record-coach-fee">{{fmt.money(c.fee)}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</text>
<text class="record-food-amount">¥{{item.foodAmount}}</text>
<text class="record-food-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<text class="record-total-amount">¥{{item.totalAmount}}</text>
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
</view>
</view>
@@ -288,10 +292,15 @@
<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">{{item.tagLabel}}</text>
<text class="note-time">{{item.createdAt}}</text>
<text class="note-author">{{fmt.safe(item.tagLabel)}}</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">
<t-icon name="delete" size="32rpx" color="#c5c5c5" />
</view>
</view>
</view>
<text class="note-content">{{item.content}}</text>
<text class="note-content">{{fmt.safe(item.content)}}</text>
</view>
</view>
<view class="note-empty" wx:else>

View File

@@ -399,22 +399,22 @@ view {
/* 助教任务卡片背景 — 严格对齐 VI-DESIGN-SYSTEM.md 1.1 节 */
.coach-card-red {
background: linear-gradient(135deg, rgba(220, 38, 38, 0.08), rgba(220, 38, 38, 0.06));
background: linear-gradient(135deg, rgb(255 158 158 / 8%), rgb(220 101 101 / 6%));
border: 2rpx solid rgba(220, 38, 38, 0.3);
}
.coach-card-pink {
background: linear-gradient(135deg, rgba(236, 72, 153, 0.08), rgba(236, 72, 153, 0.06));
background: linear-gradient(135deg, rgb(255 187 221 / 8%), rgb(201 106 153 / 6%));
border: 2rpx solid rgba(236, 72, 153, 0.3);
}
.coach-card-orange {
background: linear-gradient(135deg, rgba(249, 115, 22, 0.08), rgba(249, 115, 22, 0.06));
background: linear-gradient(135deg, rgb(247 202 171 / 8%), rgb(209 120 58 / 6%));
border: 2rpx solid rgba(249, 115, 22, 0.3);
}
.coach-card-teal {
background: linear-gradient(135deg, rgba(20, 184, 166, 0.08), rgba(20, 184, 166, 0.06));
background: linear-gradient(135deg, rgb(167 255 245 / 8%), rgb(85 138 132 / 6%));
border: 2rpx solid rgba(20, 184, 166, 0.3);
}
@@ -556,15 +556,20 @@ view {
}
.fav-card-pink {
background: linear-gradient(135deg, rgba(252, 231, 243, 0.8), rgba(255, 228, 230, 0.6));
background: linear-gradient(135deg, rgb(255 158 158 / 8%), rgb(220 101 101 / 6%));
border: 2rpx solid rgba(249, 168, 212, 0.6);
}
.fav-card-amber {
background: linear-gradient(135deg, rgba(254, 243, 199, 0.8), rgba(254, 249, 195, 0.6));
background: linear-gradient(135deg, rgb(255 253 246 / 80%), rgb(255 251 206 / 60%));
border: 2rpx solid rgba(252, 211, 77, 0.6);
}
.fav-card-blue {
background: linear-gradient(135deg, rgb(248 251 255 / 80%), rgb(238 245 250 / 60%));
border: 2rpx solid rgba(147, 197, 253, 0.6);
}
.fav-coach-top {
display: flex;
align-items: center;
@@ -638,6 +643,17 @@ view {
color: var(--text-primary);
}
/* CHANGE 2026-03-29 | 展开/收起按钮 */
.expand-btn {
display: flex;
justify-content: center;
padding: 20rpx 0;
}
.expand-btn-text {
font-size: 24rpx;
color: var(--color-primary);
}
.record-list {
display: flex;
flex-direction: column;
@@ -980,6 +996,21 @@ view {
margin-bottom: 12rpx;
}
.note-top-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.note-delete-btn {
padding: 8rpx;
border-radius: 8rpx;
}
.note-delete-btn--hover {
background: rgba(0, 0, 0, 0.05);
}
.note-author {
font-size: 24rpx;
font-weight: 500;

View File

@@ -0,0 +1,11 @@
{
"navigationBarTitleText": "消费记录",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"dev-fab": "/components/dev-fab/dev-fab"
}
}

View File

@@ -0,0 +1,155 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-29 | CUST-3 客户消费记录页面 | 新建页面banner 复用 customer-detail月份切换复用 customer-service-records消费记录卡片复用 customer-detail |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerConsumptionRecords } from '../../services/api'
Page({
data: {
pageState: 'loading' as 'loading' | 'error' | 'normal',
customerId: '',
detail: {
name: '',
avatarChar: '',
phone: '',
balance: null as number | null,
consumption60d: null as number | null,
idealInterval: null as number | null,
daysSinceVisit: null as number | null,
},
phoneMasked: '',
phoneVisible: false,
// 月份切换
monthLabel: '',
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
minYearMonth: 202501,
maxYearMonth: new Date().getFullYear() * 100 + (new Date().getMonth() + 1),
canPrev: true,
canNext: false,
// 月度汇总
visitCount: 0,
consumeTotal: 0,
rechargeTotal: 0,
// 消费记录
records: [] as any[],
monthLoading: false,
},
onLoad(options) {
const id = options?.customerId || options?.id || ''
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
this.setData({
customerId: id,
currentYear,
currentMonth,
maxYearMonth: currentYear * 100 + currentMonth,
})
this.loadMonthData(id, currentYear, currentMonth)
},
onShow() {
checkPageAccess('pages/customer-records/customer-records')
},
async loadMonthData(customerId: string, year: number, month: number) {
const monthLabel = `${year}${month}`
this.setData({ monthLabel, records: [], monthLoading: true, pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const d = await fetchCustomerConsumptionRecords({ customerId, year, month })
// Banner 数据(首次加载设置)
if (d.name) {
const phone = d.phoneFull || d.phone || ''
this.setData({
detail: {
name: d.name || '',
avatarChar: (d.name || '')[0] || '',
phone: d.phoneFull || '',
// CHANGE 2026-03-29 | Pydantic 把 consumption_60d → consumption60D大写 D
balance: d.balance ?? null,
consumption60d: d.consumption60D ?? d.consumption60d ?? null,
idealInterval: d.idealInterval ?? null,
daysSinceVisit: d.daysSinceVisit ?? null,
},
phoneMasked: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
})
}
// 月度汇总
const visitCount = d.visitCount ?? 0
const consumeTotal = d.consumeTotal ?? 0
const rechargeTotal = d.rechargeTotal ?? 0
// 消费记录
const records = d.records || []
// 月份边界
const yearMonth = year * 100 + month
const canPrev = yearMonth > this.data.minYearMonth
const canNext = yearMonth < this.data.maxYearMonth
this.setData({
visitCount,
consumeTotal,
rechargeTotal,
records,
canPrev,
canNext,
monthLoading: false,
pageState: 'normal',
})
} catch (err) {
console.error('[customer-records] loadMonthData failed:', err)
this.setData({ monthLoading: false, pageState: 'error' })
} finally {
wx.hideLoading()
}
},
onPrevMonth() {
if (!this.data.canPrev || this.data.monthLoading) return
let { currentYear, currentMonth, customerId } = this.data
currentMonth--
if (currentMonth < 1) { currentMonth = 12; currentYear-- }
this.setData({ currentYear, currentMonth })
this.loadMonthData(customerId, currentYear, currentMonth)
},
onNextMonth() {
if (!this.data.canNext || this.data.monthLoading) return
let { currentYear, currentMonth, customerId } = this.data
currentMonth++
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
this.setData({ currentYear, currentMonth })
this.loadMonthData(customerId, currentYear, currentMonth)
},
onRetry() {
const { customerId, currentYear, currentMonth } = this.data
this.loadMonthData(customerId, currentYear, currentMonth)
},
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
onCopyPhone() {
wx.setClipboardData({
data: this.data.detail.phone,
success: () => wx.showToast({ title: '手机号码已复制', icon: 'none' }),
})
},
onPullDownRefresh() {
const { customerId, currentYear, currentMonth } = this.data
this.loadMonthData(customerId, currentYear, currentMonth)
setTimeout(() => wx.stopPullDownRefresh(), 600)
},
})

View File

@@ -0,0 +1,176 @@
<!-- pages/customer-records/customer-records.wxml -->
<!-- CHANGE 2026-03-29 | CUST-3: 客户消费记录页面 -->
<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" bindtap="onRetry" hover-class="retry-btn--hover">点击重试</view>
</view>
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner 区域(复用 customer-detail 暗金色风格)-->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-dark-gold-aurora.svg" mode="widthFix" />
<view class="banner-overlay">
<view class="customer-header">
<view class="avatar-box">
<text class="avatar-text">{{detail.avatarChar}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{detail.name}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? detail.phone : phoneMasked}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
</view>
</view>
</view>
<view class="banner-stats">
<view class="stat-item stat-border">
<text class="stat-value stat-green">{{fmt.money(detail.balance)}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{fmt.money(detail.consumption60d)}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{fmt.days(detail.idealInterval)}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{fmt.days(detail.daysSinceVisit)}}</text>
<text class="stat-label">距今到店</text>
</view>
</view>
</view>
</view>
<!-- 月份切换(复用 customer-service-records 交互)-->
<view class="month-switcher">
<view class="month-btn {{canPrev ? '' : 'disabled'}}" bindtap="onPrevMonth">
<t-icon name="chevron-left" size="32rpx" color="{{canPrev ? '#777777' : '#dcdcdc'}}" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canNext ? '' : 'disabled'}}" bindtap="onNextMonth">
<t-icon name="chevron-right" size="32rpx" color="{{canNext ? '#777777' : '#dcdcdc'}}" />
</view>
</view>
<!-- 月度汇总 -->
<view class="month-summary">
<view class="summary-item">
<text class="summary-label">到店次数</text>
<text class="summary-value">{{visitCount}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">消费总额</text>
<text class="summary-value value-primary">{{fmt.money(consumeTotal)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">充值总额</text>
<text class="summary-value value-green">{{fmt.money(rechargeTotal)}}</text>
</view>
</view>
<!-- 消费记录列表(复用 customer-detail 卡片样式)-->
<view class="records-container">
<view class="month-loading" wx:if="{{monthLoading}}">
<t-loading theme="circular" size="40rpx" />
<text class="month-loading-text">加载中...</text>
</view>
<view class="no-month-data" wx:elif="{{records.length === 0}}">
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
<text class="no-month-text">本月暂无消费记录</text>
</view>
<block wx:else>
<block wx:for="{{records}}" wx:key="id">
<!-- 台桌结账 -->
<view class="record-card" wx:if="{{item.type === 'table'}}">
<view class="record-card-header record-header-blue">
<view class="record-project">
<view class="record-dot record-dot-blue"></view>
<text class="record-project-name record-name-blue">{{fmt.safe(item.tableName)}}</text>
</view>
<text class="record-date">{{fmt.safe(item.date)}}</text>
</view>
<view class="record-time-row">
<view class="record-time-left">
<text class="record-time-text">{{fmt.safe(item.startTime)}}</text>
<text class="record-time-arrow">→</text>
<text class="record-time-text">{{fmt.safe(item.endTime)}}</text>
<text class="record-duration-tag">{{fmt.safe(item.duration)}}</text>
</view>
<view class="record-fee-right">
<text class="record-fee-amount">{{fmt.money(item.tableFee)}}</text>
<text class="record-fee-orig" wx:if="{{item.tableOrigPrice}}">{{fmt.money(item.tableOrigPrice)}}</text>
</view>
</view>
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{fmt.safe(c.name)}}</text>
<coach-level-tag level="{{c.level}}" shadowColor="rgba(0,0,0,0)" />
</view>
<text class="record-coach-type">{{fmt.safe(c.courseType)}} · {{fmt.hours(c.hours)}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{fmt.hours(c.perfHours)}}</text>
<text class="record-coach-fee">{{fmt.money(c.fee)}}</text>
</view>
</view>
</view>
</view>
<view class="record-food-row" wx:if="{{item.foodAmount > 0}}">
<text class="record-food-label">🍷 食品酒水</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>
</view>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<view class="record-total-right">
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
<text class="record-fee-orig" wx:if="{{item.totalOrigPrice}}">{{fmt.money(item.totalOrigPrice)}}</text>
</view>
</view>
</view>
<!-- 商城订单 -->
<view class="record-card" wx:elif="{{item.type === 'shop'}}">
<view class="record-card-header record-header-green">
<view class="record-project">
<view class="record-dot record-dot-green"></view>
<text class="record-project-name record-name-green">商城订单</text>
</view>
<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-amount">{{fmt.money(item.foodAmount)}}</text>
</view>
<view class="record-total-row" wx:if="{{item.totalAmount}}">
<text class="record-total-label">总金额</text>
<text class="record-total-amount">{{fmt.money(item.totalAmount)}}</text>
</view>
</view>
</block>
<view class="list-footer">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
</block>
</view>
</block>
<dev-fab />

View File

@@ -0,0 +1,314 @@
/* pages/customer-records/customer-records.wxss */
/* CHANGE 2026-03-29 | CUST-3: 客户消费记录页面样式 */
/* Banner 复用 customer-detail月份切换复用 customer-service-records卡片复用 customer-detail */
page {
background-color: var(--bg-primary);
line-height: 1.5;
}
/* ========== 页面状态 ========== */
.page-loading, .page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.error-text { font-size: 26rpx; color: var(--color-error, #e34d59); }
.retry-btn {
padding: 16rpx 48rpx;
background: var(--color-primary, #0052d9);
color: #ffffff;
border-radius: 16rpx;
font-size: 26rpx;
}
.retry-btn--hover { opacity: 0.7; }
/* ========== Banner复用 customer-detail 暗金色风格)========== */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
}
.banner-bg-img {
position: absolute;
top: -20rpx;
left: 0;
width: 100%;
height: auto;
z-index: 0;
}
.banner-overlay {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
padding: 36rpx 40rpx;
}
.customer-header {
display: flex;
align-items: center;
gap: 32rpx;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-text {
font-size: 48rpx;
font-weight: 700;
color: var(--color-white, #fff);
}
.info-right { flex: 1; }
.name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.customer-name {
font-size: 36rpx;
font-weight: 600;
color: var(--color-white, #fff);
}
.sub-info {
display: flex;
align-items: center;
gap: 24rpx;
margin-top: 8rpx;
}
.phone {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
.phone-toggle-btn {
padding: 4rpx 14rpx;
background: rgba(255, 255, 255, 0.20);
border-radius: 8rpx;
display: flex;
align-items: center;
}
.phone-toggle-text {
font-size: 22rpx;
line-height: 32rpx;
color: rgba(255, 255, 255, 0.9);
}
.phone-toggle-btn--hover { opacity: 0.7; }
.banner-stats {
margin: 24rpx 0 0 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 24rpx;
backdrop-filter: blur(8px);
display: flex;
}
.stat-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
}
.stat-border { border-right: 2rpx solid rgba(255, 255, 255, 0.1); }
.stat-value {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--color-white, #fff);
}
.stat-green { color: #6ee7b7; }
.stat-amber { color: #fcd34d; }
.stat-label {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.6);
margin-top: 2rpx;
}
/* ========== 月份切换(复用 customer-service-records========== */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
background: #ffffff;
padding: 24rpx 32rpx;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn { padding: 12rpx; border-radius: 50%; }
.month-btn.disabled { opacity: 0.3; }
.month-label { font-size: 26rpx; font-weight: 600; color: #242424; }
/* ========== 月度汇总 ========== */
.month-summary {
display: flex;
align-items: flex-start;
background: #ffffff;
padding: 24rpx 0;
border-bottom: 2rpx solid #eeeeee;
}
.summary-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.summary-label { font-size: 20rpx; color: #a6a6a6; line-height: 29rpx; }
.summary-value {
font-size: 34rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
line-height: 44rpx;
}
.value-primary { color: #0052d9; }
.value-green { color: #2ba471; }
.summary-divider { width: 2rpx; height: 64rpx; background: #eeeeee; margin-top: 4rpx; }
/* ========== 消费记录列表 ========== */
.records-container {
padding: 24rpx 30rpx;
padding-bottom: 100rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.month-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 16rpx;
}
.month-loading-text { font-size: 26rpx; color: #a6a6a6; }
.no-month-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 20rpx;
}
.no-month-text { font-size: 26rpx; color: #a6a6a6; }
.list-footer { text-align: center; padding: 20rpx 0 8rpx; }
.footer-text { font-size: 20rpx; color: #c5c5c5; }
/* ========== 消费记录卡片(复用 customer-detail========== */
.record-card {
border-radius: 24rpx;
border: 2rpx solid var(--color-gray-4, #dcdcdc);
overflow: hidden;
background: #ffffff;
}
.record-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 2rpx solid rgba(219, 234, 254, 0.5);
}
.record-header-blue { background: linear-gradient(90deg, var(--color-primary-light, #e8f0fe), #eef2ff); }
.record-header-green { background: linear-gradient(90deg, var(--tag-consume-bg, #e6f9f0), #f0fdf4); }
.record-project { display: flex; align-items: center; gap: 12rpx; }
.record-dot { width: 12rpx; height: 12rpx; border-radius: 50%; }
.record-dot-blue { background: var(--color-primary, #0052d9); }
.record-dot-green { background: var(--color-success, #2ba471); }
.record-project-name { font-size: 24rpx; font-weight: 600; }
.record-name-blue { color: var(--color-primary, #0052d9); }
.record-name-green { color: var(--color-success, #2ba471); }
.record-date { font-size: 24rpx; color: var(--text-secondary, #666); }
.record-time-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 2rpx solid var(--border-light, #f0f0f0);
}
.record-time-left { display: flex; align-items: center; gap: 12rpx; }
.record-time-text {
font-size: 28rpx;
font-weight: 500;
color: var(--text-primary, #242424);
font-variant-numeric: tabular-nums;
}
.record-time-arrow { font-size: 24rpx; color: var(--text-secondary, #666); }
.record-duration-tag {
font-size: 22rpx;
padding: 4rpx 16rpx;
margin-left: 20rpx;
background: var(--color-primary-shadow-minimal, rgba(0,82,217,0.08));
color: var(--color-primary, #0052d9);
border-radius: 100rpx;
font-weight: 500;
}
.record-fee-right { display: flex; align-items: baseline; gap: 8rpx; flex-shrink: 0; }
.record-fee-amount {
font-size: 28rpx;
font-weight: 700;
color: var(--text-primary, #242424);
font-variant-numeric: tabular-nums;
}
.record-fee-orig {
font-size: 22rpx;
color: var(--text-tertiary, #999);
text-decoration: line-through;
}
.record-coaches { padding: 20rpx 24rpx; }
.record-coach-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
.record-coach-card { background: #fafafa; border-radius: 16rpx; padding: 20rpx; }
.record-coach-name-row { display: flex; align-items: center; gap: 8rpx; margin-bottom: 4rpx; }
.record-coach-name { font-size: 24rpx; font-weight: 700; color: var(--text-primary, #242424); }
.record-coach-type { font-size: 22rpx; color: var(--text-secondary, #666); }
.record-coach-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 4rpx;
}
.record-coach-perf { font-size: 22rpx; color: var(--color-warning, #ed7b2f); flex: 1; }
.record-coach-fee {
font-size: 24rpx;
font-weight: 700;
color: var(--text-primary, #242424);
font-variant-numeric: tabular-nums;
}
.record-food-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx;
border-top: 2rpx solid var(--border-light, #f0f0f0);
}
.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;
font-weight: 500;
color: var(--color-warning, #ed7b2f);
font-variant-numeric: tabular-nums;
}
.record-total-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx 20rpx;
border-top: 2rpx solid var(--color-gray-4, #dcdcdc);
}
.record-total-label { font-size: 24rpx; font-weight: 600; color: var(--text-secondary, #666); }
.record-total-right { display: flex; align-items: baseline; gap: 8rpx; }
.record-total-amount {
font-size: 32rpx;
font-weight: 700;
color: var(--color-error, #e34d59);
font-variant-numeric: tabular-nums;
}

View File

@@ -1,15 +1,21 @@
// CHANGE 2026-03-20 | RNS1.4 T10.2: 月份切换改为按月请求 API移除 mock 全量加载+本地过滤
import { fetchCustomerRecords, fetchCustomerDetail } from '../../services/api'
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | 任务A 前端改造 | 修复数据转换duration/income/timeRange/table/recordType去掉 loadCustomerInfo 改从 records 响应取客户信息,新增 monthIncome 展示 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// CHANGE 2026-03-27 | 任务A A5: 去掉 fetchCustomerDetail客户信息从 fetchCustomerRecords 响应中获取
import { fetchCustomerRecords } from '../../services/api'
import { formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
// CHANGE 2026-03-27 | 任务A: 对齐后端 ServiceRecordItemCamelModel移除废弃字段
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
interface ServiceRecord {
id: string
date: string
project: string
duration: number
amount: number
coachName: string
/** 台桌号,如 "A12号台" */
/** 台桌号/名称 */
table: string
/** 课程类型标签,如 "基础课" */
type: string
@@ -49,8 +55,8 @@ Page({
phoneVisible: false,
/** 累计服务次数 */
totalServiceCount: 0,
/** 关系指数 */
relationIndex: '0.85',
/** 关系指数(从后端获取,格式化后展示) */
relationIndex: '--',
/** 当前月份标签 */
monthLabel: '',
/** 当前年 */
@@ -68,7 +74,8 @@ Page({
/** 月度统计 */
monthCount: '0次',
monthHours: '0h',
monthRelation: '0.85',
monthIncome: '¥0',
monthRelation: '--',
/** 当前月的服务记录 */
records: [] as ServiceRecord[],
/** 是否还有更多 */
@@ -91,29 +98,13 @@ Page({
currentMonth,
maxYearMonth: currentYear * 100 + currentMonth,
})
this.loadCustomerInfo(id)
// CHANGE 2026-03-27 | 任务A A5: 客户信息从 fetchCustomerRecords 响应中获取,不再单独调 fetchCustomerDetail
this.loadMonthRecords(id, currentYear, currentMonth)
},
/** 加载客户基本信息(头部展示) */
async loadCustomerInfo(id: string) {
try {
const detail = await fetchCustomerDetail(id)
if (detail) {
const name = detail.name || '客户'
this.setData({
customerName: name,
customerInitial: name[0] || '?',
customerPhone: detail.phone ? detail.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
customerPhoneFull: detail.phone || '',
// totalServiceCount 由真实 API 返回mock 类型无此字段,安全取值
totalServiceCount: (detail as any).totalServiceCount ?? 0,
})
}
} catch (err) {
console.error('[customer-service-records] loadCustomerInfo failed:', err)
// 客户信息加载失败不阻塞记录展示
}
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/customer-service-records/customer-service-records')
},
/** 按月加载服务记录(核心方法) */
@@ -127,43 +118,56 @@ Page({
monthLoading: true,
pageState: 'loading',
})
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchCustomerRecords({ customerId, year, month })
const rawRecords: any[] = res.records || []
// 转换为展示格式(对齐 service-record-card 组件
// CHANGE 2026-03-27 | 任务A A3: 从 records 响应中提取客户信息(省掉 fetchCustomerDetail 请求
const customerName = res.customerName || this.data.customerName || '客户'
const customerPhone = res.customerPhone || ''
const customerPhoneFull = res.customerPhoneFull || ''
// CHANGE 2026-03-27 | 任务A A1: 修复数据转换,对齐后端 CamelModel 字段
const records: ServiceRecord[] = rawRecords.map((r: any) => {
// 日期格式化:优先用后端 timeRange无值时从 date 提取日期部分
const d = new Date(r.date)
const m = d.getMonth() + 1
const day = d.getDate()
const dateLabel = `${m}${day}`
const timeRange = this.generateTimeRange(r.duration || 0)
const isRecharge = (r.project || '').includes('充值')
// recordType 直接用后端返回值
const recordType = r.recordType || 'course'
const isRecharge = recordType === 'recharge'
return {
id: r.id || '',
date: r.date || '',
project: r.project || '',
duration: r.duration || 0,
amount: r.amount || 0,
coachName: r.coachName || '',
table: r.table || this.getTableNo(r.id || ''),
type: this.getTypeLabel(r.project || ''),
typeClass: this.getTypeClass(r.project || '') as 'basic' | 'vip' | 'tip' | 'recharge',
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
durationHours: isRecharge ? 0 : parseFloat(((r.duration || 0) / 60).toFixed(1)),
durationRaw: 0,
income: r.amount || 0,
table: r.table || '',
// type: 后端返回中文标签(如"陪打"/"超休"/"充值"),直接用
type: r.type || '课程',
// typeClass: 后端 typeClass 是 "tag-recharge"/"tag-course",需映射为组件期望的 CSS class
typeClass: this.mapTypeClass(r.recordType, r.type),
recordType: recordType as 'course' | 'recharge',
// duration 已是小时数,不再 /60
durationHours: isRecharge ? 0 : (r.duration || 0),
durationRaw: r.durationRaw || 0,
// income 直接用后端已计算的到手金额
income: r.income || 0,
isEstimate: r.isEstimate || false,
drinks: r.drinks || '',
displayDate: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
// displayDate 优先用后端 timeRange
displayDate: isRecharge ? dateLabel : (r.timeRange ? `${dateLabel} ${r.timeRange}` : dateLabel),
}
})
// 月度统计
const totalMinutes = rawRecords.reduce((sum: number, r: any) => sum + (r.duration || 0), 0)
const monthCount = records.length + '次'
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
// CHANGE 2026-03-27 | 任务A A2: 月度统计直接用后端汇总值
const monthCount = formatCount(res.monthCount ?? records.length, '次')
// monthHours 后端已是小时数
const monthHours = formatHours(res.monthHours ?? 0)
// 月度到手收入
const monthIncome = res.monthIncome != null ? `¥${Math.round(res.monthIncome)}` : '¥0'
// 关系指数
const monthRelation = res.relationIndex != null ? String(res.relationIndex) : '--'
// 边界判断
const yearMonth = year * 100 + month
@@ -171,14 +175,24 @@ Page({
const canNext = yearMonth < this.data.maxYearMonth
this.setData({
// 客户信息(首次加载时设置,后续月份切换保持不变)
customerName,
customerInitial: customerName[0] || '?',
customerPhone: customerPhone ? customerPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : this.data.customerPhone,
customerPhoneFull: customerPhoneFull || this.data.customerPhoneFull,
totalServiceCount: res.totalServiceCount ?? this.data.totalServiceCount,
relationIndex: monthRelation,
// 记录和统计
records,
monthCount,
monthHours,
monthIncome,
monthRelation,
canPrev,
canNext,
hasMore: res.hasMore || false,
monthLoading: false,
pageState: 'normal',
pageState: records.length > 0 ? 'normal' : 'normal',
})
} catch (err) {
console.error('[customer-service-records] loadMonthRecords failed:', err)
@@ -186,42 +200,23 @@ Page({
monthLoading: false,
pageState: 'error',
})
} finally {
wx.hideLoading()
}
},
/** 生成模拟时间段(后端未返回具体时段时的 fallback */
generateTimeRange(durationMin: number): string {
const startHour = 14 + Math.floor(Math.random() * 6)
const endMin = startHour * 60 + durationMin
const endHour = Math.floor(endMin / 60)
const endMinute = endMin % 60
return `${startHour}:00 - ${endHour}:${String(endMinute).padStart(2, '0')}`
},
/** 课程类型标签 */
getTypeLabel(project: string): string {
if (project.includes('小组')) return '小组课'
if (project.includes('1v1')) return '基础课'
if (project.includes('充值')) return '充值'
if (project.includes('斯诺克')) return '斯诺克'
return '基础课'
},
/** 课程类型样式(对齐 service-record-card typeClass prop*/
getTypeClass(project: string): string {
if (project.includes('充值')) return 'recharge'
if (project.includes('小组')) return 'vip'
if (project.includes('斯诺克')) return 'vip'
// CHANGE 2026-03-27 | 任务A: 后端 type/recordType → 前端 CSS class 映射
/** 根据 recordType 和 type中文标签映射为组件期望的 CSS class 后缀 */
mapTypeClass(recordType?: string, typeLabel?: string): 'basic' | 'vip' | 'tip' | 'recharge' {
if (recordType === 'recharge') return 'recharge'
// 激励课/超休/打赏课 → vip 样式
const label = typeLabel || ''
if (label.includes('超休') || label.includes('激励') || label.includes('打赏') || label.includes('VIP')) {
return 'vip'
}
return 'basic'
},
/** 模拟台号(后端未返回台号时的 fallback */
getTableNo(id: string): string {
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
return tables[idx]
},
/** 切换到上一月 */
onPrevMonth() {
if (!this.data.canPrev || this.data.monthLoading) return
@@ -251,7 +246,6 @@ Page({
/** 下拉刷新 */
onPullDownRefresh() {
const { customerId, currentYear, currentMonth } = this.data
this.loadCustomerInfo(customerId)
this.loadMonthRecords(customerId, currentYear, currentMonth)
setTimeout(() => wx.stopPullDownRefresh(), 600)
},

View File

@@ -1,13 +1,8 @@
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- CHANGE 2026-03-21 | P13 B: 引入 WXS 格式化工具 -->
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-empty description="暂无服务记录" />
</view>
@@ -33,13 +28,13 @@
<view class="name-row">
<text class="customer-name">{{customerName}}</text>
<view class="name-badges">
<text class="name-badge">服务 <text class="badge-highlight">{{totalServiceCount}}</text> 次</text>
<text class="name-badge">服务 <text class="badge-highlight">{{fmt.safe(totalServiceCount)}}</text> 次</text>
</view>
</view>
<view class="sub-stats">
<view class="sub-info">
<text class="phone">{{phoneVisible ? customerPhoneFull : customerPhone}}</text>
<text class="phone">{{phoneVisible ? fmt.safe(customerPhoneFull) : fmt.safe(customerPhone)}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
@@ -65,17 +60,22 @@
<view class="month-summary">
<view class="summary-item">
<text class="summary-label">本月服务</text>
<text class="summary-value">{{monthCount}}</text>
<text class="summary-value">{{fmt.safe(monthCount)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">服务时长</text>
<text class="summary-value value-primary">{{monthHours}}</text>
<text class="summary-value value-primary">{{fmt.safe(monthHours)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">到手收入</text>
<text class="summary-value value-green">{{fmt.safe(monthIncome)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">关系指数</text>
<text class="summary-value value-warning">{{monthRelation}}</text>
<text class="summary-value value-warning">{{fmt.safe(monthRelation)}}</text>
</view>
</view>

View File

@@ -236,6 +236,7 @@ page {
line-height: 44rpx;
}
.value-primary { color: #0052d9; }
.value-green { color: #2ba471; }
.value-warning { color: #ed7b2f; }
.summary-divider {
width: 2rpx;

View File

@@ -1,3 +1,8 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
/**
* 开发调试面板页面
*
@@ -7,6 +12,7 @@
* - 一键切换用户状态(后端真实修改 users.status + 重签 token
* - 页面跳转列表(点击跳转到任意已注册页面)
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { request } from "../../utils/request"
// 页面列表分三段:正在迁移、已完成、未完成
@@ -39,8 +45,8 @@ const TODO_PAGES: typeof MIGRATING_PAGES = []
const ROLE_LIST = [
{ code: "coach", name: "助教" },
{ code: "staff", name: "员工" },
{ code: "site_admin", name: "店铺管理员" },
{ code: "tenant_admin", name: "租户管理员" },
{ code: "head_coach", name: "教练" },
{ code: "manager", name: "管理员" },
]
const STATUS_LIST = ["new", "pending", "approved", "rejected", "disabled"]
@@ -64,6 +70,8 @@ Page({
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/dev-tools/dev-tools')
this.loadContext()
},

View File

@@ -1,5 +1,12 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | 登录成功后保存 role、syncVisibleTabs、按角色跳转 getRoleHome |
| 2026-03-28 | 登录落地页修复 | 修复权限改造后 login.ts 未同步:改为请求 /me 获取 permissions用 syncPermissions + getPermissionHome 决定落地页 |
*/
import { request } from "../../utils/request"
import { API_BASE } from "../../utils/config"
import { syncPermissions, getPermissionHome } from "../../utils/auth-guard"
/** develop 环境使用 dev-login 跳过微信 code2Session */
const isDevMode = API_BASE.startsWith("http://127.0.0.1")
@@ -62,24 +69,40 @@ Page({
}
const app = getApp<IAppOption>()
app.globalData.token = data.access_token
app.globalData.refreshToken = data.refresh_token
wx.setStorageSync("token", data.access_token)
wx.setStorageSync("refreshToken", data.refresh_token)
app.globalData.token = data.accessToken
app.globalData.refreshToken = data.refreshToken
wx.setStorageSync("token", data.accessToken)
wx.setStorageSync("refreshToken", data.refreshToken)
// 持久化用户身份信息
// CHANGE 2026-03-23 | 角色路由:保存 role 并按角色跳转
// CHANGE 2026-03-23 | camelCase 修复:后端 CamelModel 序列化输出 camelCase
app.globalData.authUser = {
userId: data.user_id,
status: data.user_status,
userId: data.userId,
status: data.userStatus,
role: data.role || undefined,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.user_status)
wx.setStorageSync("userId", data.userId)
wx.setStorageSync("userStatus", data.userStatus)
if (data.role) wx.setStorageSync("userRole", data.role)
// 根据 user_status 路由
switch (data.user_status) {
case "approved":
wx.reLaunch({ url: "/pages/task-list/task-list" })
// 根据 userStatus 路由
switch (data.userStatus) {
case "approved": {
// CHANGE 2026-03-28 | 权限码驱动跳转:登录接口不返回 permissions
// 需额外请求 /api/xcx/me 获取权限码,再决定落地页
let homePage = '/pages/my-profile/my-profile'
try {
const me = await request({ url: "/api/xcx/me", method: "GET", needAuth: true })
const permissions: string[] = me.permissions || []
syncPermissions(permissions)
homePage = getPermissionHome(permissions)
} catch {
// /me 失败时降级到个人页checkAuthStatus 后续会补偿
}
wx.reLaunch({ url: homePage })
break
}
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
break

View File

@@ -1,22 +1,63 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-24 | 头像昵称获取 | loadUserInfo 从 fetchMe 的 avatar_url 构建完整头像 URL |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchMe } from '../../services/api'
import { getMenuRoute, navigateTo } from '../../utils/router'
// CHANGE 2026-03-24 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
Page({
data: {
userInfo: null as any,
avatarUrl: '',
},
onShow() {
async onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
const allowed = await checkPageAccess('pages/my-profile/my-profile')
if (!allowed) return
// 同步 custom-tab-bar 选中态
const tabBar = this.getTabBar?.()
if (tabBar) tabBar.setData({ active: 'my' })
this.loadUserInfo()
},
/** G1: 从全局用户信息或 fetchMe 获取头像等字段 */
async loadUserInfo() {
// 优先从 globalData 读取已有用户信息
const app = getApp<IAppOption>()
const authUser = app.globalData.authUser
if (authUser?.nickname) {
this.setData({
userInfo: {
name: authUser.nickname,
role: '',
storeName: '',
},
// CHANGE 2026-03-24 | 头像:从 globalData 读取 avatarUrl构建完整 URL
avatarUrl: authUser.avatarUrl
? `${API_BASE}/api/xcx/avatar/${authUser.userId}`
: '',
})
}
// 兜底:调用 fetchMe 获取完整用户信息
try {
const info = await fetchMe()
this.setData({ userInfo: info })
if (info) {
// CHANGE 2026-03-24 | 头像:后端返回 avatarUrl相对路径有值则通过头像接口获取
const userId = (info as any).userId || authUser?.userId
const hasAvatar = !!(info as any).avatarUrl
this.setData({
userInfo: info,
avatarUrl: hasAvatar && userId
? `${API_BASE}/api/xcx/avatar/${userId}`
: this.data.avatarUrl,
})
}
} catch {
wx.showToast({ title: '加载用户信息失败', icon: 'none' })
}

View File

@@ -1,17 +1,19 @@
<!-- 我的页面 -->
<wxs src="../../utils/format.wxs" module="fmt" />
<view class="page-my-profile">
<!-- 用户信息区域 -->
<view class="user-card">
<!-- G1: 从全局用户信息读取头像,无头像时显示默认占位图 -->
<view class="avatar-wrap">
<image class="avatar" src="{{userInfo.avatar}}" mode="aspectFill" />
<image class="avatar" src="{{avatarUrl || '/assets/images/avatar-coach.png'}}" mode="aspectFill" />
</view>
<view class="user-info">
<view class="name-row">
<text class="name">{{userInfo.name}}</text>
<text class="role-tag">{{userInfo.role}}</text>
<text class="name">{{fmt.safe(userInfo.name)}}</text>
<text class="role-tag">{{fmt.safe(userInfo.role)}}</text>
</view>
<text class="store-name">{{userInfo.storeName}}</text>
<text class="store-name">{{fmt.safe(userInfo.storeName)}}</text>
</view>
</view>

View File

@@ -1,12 +1,28 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | approved 跳转改为 getRoleHome(data.role),保存 role 到 globalData 和 Storage |
| 2026-03-23 | 审核流程增强 | 区分 rejected/disabled 状态展示rejected 增加"重新申请"按钮跳转申请页预填 |
*/
// pages/no-permission/no-permission.ts
// 无权限页面 — 账号已禁用或无访问权限时展示
// onShow 时查询最新状态,状态变化时自动跳转
// 无权限页面 — 账号已禁用或申请被拒绝时展示
import { request } from "../../utils/request"
import { getRoleHome, syncVisibleTabs } from "../../utils/auth-guard"
Page({
data: {
statusBarHeight: 0,
// CHANGE 2026-03-23 | 审核流程增强:区分 rejected 和 disabled
userStatus: "disabled" as "disabled" | "rejected",
latestApplication: null as null | {
siteCode: string
appliedRoleText: string
phone: string
employeeNumber: string | null
reviewNote: string | null
createdAt: string
},
},
onLoad() {
@@ -18,7 +34,7 @@ Page({
this._checkStatus()
},
/** 查询最新用户状态,非 disabled 时自动跳转 */
/** 查询最新用户状态,非 disabled/rejected 时自动跳转 */
async _checkStatus() {
const token = wx.getStorageSync("token")
if (!token) {
@@ -33,19 +49,26 @@ Page({
})
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
userId: data.userId,
status: data.status,
nickname: data.nickname,
role: data.role || undefined,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userId", data.userId)
wx.setStorageSync("userStatus", data.status)
if (data.role) wx.setStorageSync("userRole", data.role)
switch (data.status) {
case "disabled":
this.setData({ userStatus: "disabled", latestApplication: data.latestApplication || null })
break
case "rejected":
break // 留在当前页
this.setData({ userStatus: "rejected", latestApplication: data.latestApplication || null })
break
case "approved":
wx.reLaunch({ url: "/pages/mvp/mvp" })
syncVisibleTabs(data.role)
// CHANGE 2026-03-24 | 防御approved 但 role 为空时跳个人页,不跳登录页
wx.reLaunch({ url: data.role ? getRoleHome(data.role) : '/pages/my-profile/my-profile' })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
@@ -59,7 +82,7 @@ Page({
}
},
/** 更换登录账号:清除凭证后跳转登录页 */
/** 更换登录账号 */
onSwitchAccount() {
const app = getApp<IAppOption>()
app.globalData.token = undefined
@@ -71,4 +94,20 @@ Page({
wx.removeStorageSync("userStatus")
wx.reLaunch({ url: "/pages/login/login" })
},
// CHANGE 2026-03-23 | 审核流程增强rejected 用户重新申请(跳转申请页预填上次信息)
onReapply() {
const la = this.data.latestApplication
if (la) {
const params = [
`siteCode=${encodeURIComponent(la.siteCode || "")}`,
`role=${encodeURIComponent(la.appliedRoleText || "")}`,
`phone=${encodeURIComponent(la.phone || "")}`,
`employeeNumber=${encodeURIComponent(la.employeeNumber || "")}`,
].join("&")
wx.reLaunch({ url: `/pages/apply/apply?${params}` })
} else {
wx.reLaunch({ url: "/pages/apply/apply" })
}
},
})

View File

@@ -1,6 +1,6 @@
<!-- pages/no-permission/no-permission.wxml — 按 H5 原型结构迁移 -->
<!-- pages/no-permission/no-permission.wxml — 无权限/申请被拒页 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 十字纹背景图案H5 bg-patternerror 色) -->
<!-- 十字纹背景图案 -->
<view class="bg-pattern"></view>
<!-- 顶部渐变装饰(红色主题) -->
<view class="top-gradient"></view>
@@ -9,25 +9,32 @@
<view class="content">
<!-- 图标区域 -->
<view class="icon-area">
<!-- 背景光晕 -->
<view class="icon-glow"></view>
<!-- 主图标 -->
<view class="icon-box">
<image class="icon-main-img" src="/assets/icons/icon-forbidden.svg" mode="aspectFit" />
</view>
<!-- 装饰点 -->
<view class="icon-dot dot-1"></view>
<view class="icon-dot dot-2"></view>
</view>
<!-- 标题区域 -->
<!-- 标题区域:区分 rejected 和 disabled -->
<view class="title-area">
<text class="main-title">无访问权限</text>
<text class="sub-title">很抱歉,您的访问申请未通过审核,或当前账号无访问权限</text>
<text class="main-title">{{userStatus === 'rejected' ? '申请未通过' : '无访问权限'}}</text>
<text class="sub-title" wx:if="{{userStatus === 'rejected'}}">很抱歉,您的申请未通过审核,您可以修改信息后重新申请</text>
<text class="sub-title" wx:else>您的账号已被禁用,如有疑问请联系管理员</text>
</view>
<!-- 原因说明卡片 -->
<view class="reason-card">
<!-- 拒绝原因卡片(仅 rejected 且有拒绝原因时显示) -->
<view class="reject-reason-card" wx:if="{{userStatus === 'rejected' && latestApplication && latestApplication.reviewNote}}">
<view class="reject-reason-header">
<t-icon name="error-circle-filled" size="32rpx" color="#e34d59" />
<text class="reject-reason-title">拒绝原因</text>
</view>
<text class="reject-reason-text">{{latestApplication.reviewNote}}</text>
</view>
<!-- 原因说明卡片(仅 disabled 显示) -->
<view class="reason-card" wx:if="{{userStatus === 'disabled'}}">
<view class="reason-header">
<view class="reason-icon-box">
<t-icon name="error-circle-filled" size="35rpx" color="#e34d59" />
@@ -35,13 +42,12 @@
<view class="reason-header-text">
<text class="reason-title">可能的原因</text>
<view class="reason-list">
<text class="reason-item">• 申请信息不完整或不符合要求</text>
<text class="reason-item">• 申请多次未通过,账号已被自动禁用</text>
<text class="reason-item">• 非本店授权员工账号</text>
<text class="reason-item">• 账号权限已被管理员收回</text>
</view>
</view>
</view>
<!-- 联系管理员 -->
<view class="reason-footer">
<text class="reason-footer-label">请联系管理员</text>
<text class="reason-footer-value">厉超</text>
@@ -51,15 +57,29 @@
<!-- 帮助提示 -->
<view class="contact-hint">
<t-icon name="help-circle-filled" size="28rpx" color="#a6a6a6" />
<text class="contact-text">如有疑问,请联系管理员重新申请</text>
<text class="contact-text">如有疑问,请联系管理员</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-area">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
<!-- rejected双按钮更换登录账号 + 重新申请) -->
<view class="btn-row" wx:if="{{userStatus === 'rejected'}}">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
</view>
<view class="reapply-btn" bindtap="onReapply">
<t-icon name="refresh" size="28rpx" color="#fff" />
<text class="reapply-btn-text">重新申请</text>
</view>
</view>
<!-- disabled仅更换登录账号 -->
<view wx:else>
<view class="switch-btn switch-btn--full" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
</view>
</view>
</view>
</view>

View File

@@ -220,13 +220,20 @@
z-index: 1;
}
/* 双按钮横排布局 */
.btn-row {
display: flex;
flex-direction: row;
gap: 20rpx;
}
/* padding 28 × 0.875 = 24.5 → 24, gap 16 × 0.875 = 14, radius 24 × 0.875 = 21 → 22 */
.switch-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
gap: 10rpx;
flex: 1;
padding: 24rpx 0;
background: #ffffff;
border: 2rpx solid #eeeeee;
@@ -234,8 +241,62 @@
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.03);
}
/* disabled 状态下全宽按钮 */
.switch-btn--full {
width: 100%;
}
.switch-btn-text {
font-size: 24rpx;
font-size: 22rpx;
font-weight: 500;
color: #5e5e5e;
}
/* 重新申请按钮(红色主题) */
.reapply-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
flex: 1;
padding: 24rpx 0;
background: linear-gradient(135deg, #fb7185, #ef4444);
border-radius: 22rpx;
box-shadow: 0 4rpx 14rpx rgba(227, 77, 89, 0.25);
}
.reapply-btn-text {
font-size: 22rpx;
font-weight: 500;
color: #ffffff;
}
/* ---- 拒绝原因卡片rejected 状态) ---- */
.reject-reason-card {
width: 100%;
max-width: 482rpx;
background: #fff5f5;
border: 2rpx solid rgba(227, 77, 89, 0.15);
border-radius: 28rpx;
padding: 28rpx;
margin-bottom: 28rpx;
}
.reject-reason-header {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 14rpx;
}
.reject-reason-title {
font-size: 24rpx;
font-weight: 500;
color: #e34d59;
}
.reject-reason-text {
font-size: 22rpx;
color: #5e5e5e;
line-height: 1.625;
}

View File

@@ -1,3 +1,9 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchNotes, deleteNote } from '../../services/api'
import type { Note } from '../../utils/mock-data'
import { formatRelativeTime } from '../../utils/time'
@@ -30,9 +36,15 @@ Page({
this.loadData()
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/notes/notes')
},
/** 首次加载 / 刷新:重置分页,从第 1 页开始 */
async loadData() {
this.setData({ pageState: 'loading', page: 1, hasMore: true })
wx.showLoading({ title: '加载中...', mask: true })
try {
const res = await fetchNotes({ page: 1, pageSize: PAGE_SIZE })
@@ -48,6 +60,8 @@ Page({
})
} catch {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},

View File

@@ -1,15 +1,8 @@
<!-- pages/notes/notes.wxml — 备注记录 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<view class="page-error" wx:if="{{pageState === 'error'}}">
<view class="error-content">
<text class="error-icon">😵</text>
<text class="error-text">加载失败,请重试</text>
@@ -35,14 +28,14 @@
wx:for="{{notes}}"
wx:key="id"
>
<text class="note-content">{{item.content}}</text>
<text class="note-content">{{fmt.safe(item.content)}}</text>
<view class="note-bottom">
<text class="note-tag {{item.tagType === 'coach' ? 'tag-coach' : 'tag-customer'}}">{{item.tagLabel}}</text>
<text class="note-tag {{item.tagType === 'coach' ? 'tag-coach' : 'tag-customer'}}">{{fmt.safe(item.tagLabel)}}</text>
<view class="note-bottom-right">
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="16px" color="#a6a6a6" />
</view>
<text class="note-time">{{item.timeLabel}}</text>
<text class="note-time">{{fmt.safe(item.timeLabel)}}</text>
</view>
</view>
</view>

View File

@@ -4,9 +4,9 @@
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"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

@@ -1,53 +1,73 @@
import type { PerformanceRecord } from '../../utils/mock-data'
import { fetchPerformanceRecords } from '../../services/api'
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-27 | 联调改造 | 重写Banner 对齐 performance 页面,数据对接后端 PERF-2卡片样式复用 performance 服务记录明细 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchPerformanceRecords, fetchMe } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { API_BASE } from '../../utils/config'
/** 按日期分组后的展示结构 */
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
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'
}
/** 角色英文 code → 中文显示名 */
const ROLE_LABELS: Record<string, string> = {
coach: '助教', staff: '员工', head_coach: '主教练', manager: '店长',
}
/** 按日期分组后的展示结构(对齐 performance 页面) */
interface DateGroup {
date: string
totalHours: number
totalIncome: number
totalHoursLabel: string
totalIncomeLabel: string
totalHours: string
totalIncome: string
records: RecordItem[]
}
interface RecordItem {
id: string
customerName: string
memberId: number
avatarChar: string
avatarColor: string
timeRange: string
hours: number // 折算后课时小时number
hoursRaw?: number // 折算前课时小时number可选
hours: string
courseType: string
courseTypeClass: string
courseTagClass: string
location: string
income: number // 收入(元,整数)
income: string
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** Banner */
coachName: '小燕',
coachLevel: '星级',
storeName: '球会名称店',
/** Banner — 对齐 performance 页面 */
avatarUrl: '',
coachName: '',
coachRole: '',
storeName: '',
/** 月份切换 */
currentYear: 2026,
currentMonth: 2,
monthLabel: '2026年2月',
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
canGoPrev: true,
canGoNext: false,
/** 统计概览 */
totalCount: 0,
totalHours: 0,
totalIncome: 0,
/** 当月预估判断 */
isCurrentMonth: false,
/** 统计概览(从后端 summary 读取) */
totalCountLabel: '--',
totalHoursLabel: '--',
totalHoursRawLabel: '',
@@ -56,9 +76,6 @@ Page({
/** 按日期分组的记录 */
dateGroups: [] as DateGroup[],
/** 所有记录(用于筛选) */
allRecords: [] as any[],
/** 分页 */
page: 1,
pageSize: 20,
@@ -66,10 +83,22 @@ Page({
},
onLoad() {
const now = new Date()
this.setData({
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
})
this.loadBanner()
this.loadData()
},
onShow() {
checkPageAccess('pages/performance-records/performance-records')
},
onPullDownRefresh() {
this.setData({ page: 1, dateGroups: [] })
this.loadData(() => wx.stopPullDownRefresh())
},
@@ -79,86 +108,117 @@ Page({
this.loadData()
},
/** 加载 Banner 用户信息(与 performance 页面统一) */
async loadBanner() {
try {
const me = await fetchMe()
if (!me) return
const userId = (me as any)?.userId
const hasAvatar = !!(me as any)?.avatarUrl
const avatarUrl = hasAvatar && userId ? `${API_BASE}/api/xcx/avatar/${userId}` : ''
const rawRole = (me as any)?.role || ''
const coachLevel = (me as any)?.coachLevel || ''
const roleLabel = ROLE_LABELS[rawRole] || rawRole
const coachRole = (rawRole === 'coach' && coachLevel) ? `${coachLevel}助教` : roleLabel
this.setData({
avatarUrl,
coachName: (me as any)?.nickname || '',
coachRole,
storeName: (me as any)?.storeName || '',
})
} catch (_e) {
// 用户信息加载失败不阻塞页面
}
},
/** 加载绩效明细数据 */
async loadData(cb?: () => void) {
this.setData({ pageState: 'loading' })
if (this.data.page === 1) {
this.setData({ pageState: 'loading' })
}
wx.showLoading({ title: '加载中...', mask: true })
// 预估规则:当月且当前日期 ≤ 5号全小程序统一
const now = new Date()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
try {
const { currentYear, currentMonth, page, pageSize } = this.data
const res = await fetchPerformanceRecords({
year: currentYear,
month: currentMonth,
page,
pageSize,
page: this.data.page,
pageSize: this.data.pageSize,
})
const records = res.records || []
// 按日期分组(后端返回的记录已按日期排序)
const groupMap = new Map<string, DateGroup>()
for (const r of records) {
const dateKey = r.date || '未知日期'
if (!groupMap.has(dateKey)) {
groupMap.set(dateKey, {
date: dateKey,
totalHours: 0,
totalIncome: 0,
totalHoursLabel: '',
totalIncomeLabel: '',
records: [],
})
}
const group = groupMap.get(dateKey)!
const hours = (r as any).hours ?? 0
const income = r.amount ?? 0
group.totalHours += hours
group.totalIncome += income
group.records.push({
id: r.id,
customerName: r.customerName,
avatarChar: r.customerName?.charAt(0) || '?',
avatarColor: nameToAvatarColor(r.customerName || ''),
timeRange: (r as any).timeRange || '',
hours,
hoursRaw: (r as any).hoursRaw,
courseType: r.type || '',
courseTypeClass: `tag-${(r.category || 'basic').toLowerCase()}`,
location: (r as any).location || '',
income,
})
}
const dateGroups = Array.from(groupMap.values()).map(g => ({
// 后端返回的 dateGroups 已按日期分组,前端只需补充头像颜色和课程标签映射
const newGroups = (res.dateGroups || []).map((g: any) => ({
...g,
totalHoursLabel: formatHours(g.totalHours),
totalIncomeLabel: formatMoney(g.totalIncome),
records: (g.records || []).map((rec: any) => ({
...rec,
avatarColor: nameToAvatarColor(String(rec.memberId ?? '')),
avatarChar: rec.avatarChar || (rec.customerName || '?').charAt(0),
courseTagClass: courseTagClass(rec.courseType || ''),
})),
}))
// 汇总统计
const totalCount = records.length
const totalHours = dateGroups.reduce((s, g) => s + g.totalHours, 0)
const totalIncome = dateGroups.reduce((s, g) => s + g.totalIncome, 0)
// 懒加载:追加到已有 dateGroups需合并同日期组
let dateGroups: DateGroup[]
if (this.data.page === 1) {
dateGroups = newGroups
} else {
dateGroups = this._mergeGroups(this.data.dateGroups, newGroups)
}
this.setData({
// 统计概览(从后端 summary 读取,仅第一页设置)
const updates: Record<string, any> = {
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
allRecords: records,
isCurrentMonth,
dateGroups,
totalCount,
totalHours,
totalIncome,
totalCountLabel: formatCount(totalCount, '笔'),
totalHoursLabel: formatHours(totalHours),
totalHoursRawLabel: '',
totalIncomeLabel: formatMoney(totalIncome),
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) {
this.setData({ pageState: 'error' })
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()
},
@@ -167,6 +227,20 @@ Page({
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/task-list/task-list' }) })
},
/** 点击服务记录 → 跳转任务详情(与 performance 页面统一) */
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/task-detail/task-detail?memberId=${memberId || ''}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 切换月份 */
switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
@@ -174,30 +248,28 @@ Page({
if (direction === 'prev') {
currentMonth--
if (currentMonth < 1) {
currentMonth = 12
currentYear--
}
if (currentMonth < 1) { currentMonth = 12; currentYear-- }
} else {
currentMonth++
if (currentMonth > 12) {
currentMonth = 1
currentYear++
}
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
// 月份切换重置分页到第 1 页
this.setData({
currentYear,
currentMonth,
monthLabel: `${currentYear}${currentMonth}`,
canGoNext,
canGoPrev: true,
isCurrentMonth,
page: 1,
dateGroups: [],
})
this.loadData()

View File

@@ -1,13 +1,5 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- toast 加载浮层fixed不销毁内容不白屏 -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 错误态(全屏,仅在 error 时展示) -->
<!-- 错误态 -->
<view class="page-error" wx:if="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
@@ -16,23 +8,25 @@
</view>
</view>
<!-- 主体内容(始终挂载,不随 loading 销毁) -->
<!-- 主体内容 -->
<block wx:if="{{pageState !== 'error'}}">
<!-- Banner 区域(复用助教详情样式 -->
<!-- Banner 区域(对齐 performance 页面 -->
<view class="banner-section">
<image class="banner-bg-img" src="/assets/images/banner-bg-coral-aurora.svg" mode="widthFix" />
<view class="banner-overlay">
<view class="coach-header">
<view class="avatar-box">
<image class="avatar-img" src="/assets/images/avatar-coach.png" mode="aspectFill" />
</view>
<view class="info-middle">
<view class="name-row">
<text class="coach-name">{{coachName}}</text>
<coach-level-tag level="{{coachLevel}}" />
<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="skill-row">
<text class="store-name-text">{{storeName}}</text>
<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>
@@ -60,14 +54,14 @@
<view class="stat-item">
<text class="stat-label">总业绩时长</text>
<text class="stat-value stat-primary">{{totalHoursLabel}}</text>
<text class="stat-hint">预估</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">收入</text>
<text class="stat-label">{{isCurrentMonth ? '预估收入' : '收入'}}</text>
<text class="stat-value stat-success">{{totalIncomeLabel}}</text>
<text class="stat-hint">预估</text>
<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>
</view>
</view>
@@ -77,47 +71,46 @@
<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">{{item.date}}&nbsp;&nbsp;—</text>
<text decode class="dd-stats" wx:if="{{item.totalHoursLabel}}">{{item.totalHoursLabel}}&nbsp;&nbsp;·&nbsp;&nbsp;预估 {{item.totalIncomeLabel}}&nbsp;&nbsp;&nbsp;&nbsp;</text>
<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>
<!-- 该日期下的记录 -->
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="id"
hover-class="record-item--hover">
<!-- 该日期下的记录(与 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.customerName}}</text>
<text class="record-time">{{rec.timeRange}}</text>
</view>
<view class="record-hours-wrap">
<text class="record-hours">{{fmt.hours(rec.hours)}}</text>
<text class="record-hours-deduct" wx:if="{{rec.hoursRaw}}">(折后 {{fmt.hours(rec.hoursRaw)}}</text>
<text class="record-name">{{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 {{rec.courseTypeClass}}">{{rec.courseType}}</text>
<text class="record-location">{{rec.location}}</text>
<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">我的预估收入 <text class="record-income-val">{{fmt.money(rec.income)}}</text></text>
<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">
<view class="list-end-hint" wx:if="{{!hasMore && dateGroups.length > 0}}">
<text>— 已加载全部记录 —</text>
</view>
</view>

View File

@@ -1,4 +1,5 @@
/* pages/performance-records/performance-records.wxss */
/* CHANGE 2026-03-27 | 联调改造Banner 对齐 performance卡片样式复用 performance 服务记录明细 */
page {
background-color: #f3f3f3;
@@ -12,38 +13,6 @@ view {
/* ============================================
* 加载态 / 空态 / 错误态
* ============================================ */
/* toast 风格加载(浮在页面中央,不全屏变白) */
.toast-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
pointer-events: none;
}
.toast-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
background: rgba(36, 36, 36, 0.75);
border-radius: 24rpx;
padding: 36rpx 48rpx;
pointer-events: auto;
}
.toast-loading-text {
font-size: 24rpx;
color: #ffffff;
line-height: 32rpx;
}
.page-empty {
display: flex;
flex-direction: column;
@@ -85,13 +54,12 @@ view {
}
/* ============================================
* Banner复用助教详情
* Banner对齐 performance 页面
* ============================================ */
.banner-section {
position: relative;
width: 100%;
overflow: hidden;
height: 100%;
}
.banner-bg-img {
@@ -103,30 +71,34 @@ view {
z-index: 0;
}
.banner-overlay {
.banner-content {
position: relative;
z-index: 2;
height: 100%;
padding: 40rpx;
display: flex;
flex-direction: column;
justify-content: center;
padding: 36rpx 40rpx;
}
.coach-header {
.user-info-section {
position: relative;
z-index: 2;
}
.user-info-row {
display: flex;
align-items: center;
gap: 32rpx;
gap: 29rpx;
}
.avatar-box {
.avatar-wrap {
width: 98rpx;
height: 98rpx;
border-radius: 32rpx;
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;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-img {
@@ -134,58 +106,40 @@ view {
height: 100%;
}
.info-middle {
.user-detail {
flex: 1;
min-width: 0;
}
.name-row {
.user-name-row {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 8rpx;
gap: 15rpx;
margin-bottom: 7rpx;
line-height: 51rpx;
}
.coach-name {
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.skill-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.skill-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 8rpx;
.user-role-tag {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
}
/* 助教等级 tag — 遵循 VI 规范§5 助教等级配色) */
.coach-level-tag {
padding: 4rpx 16rpx;
border-radius: 24rpx;
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
line-height: 29rpx;
padding: 4rpx 15rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 9999rpx;
color: #ffffff;
}
.coach-level-star { color: #fbbf24; background: #fffef0; }
.coach-level-senior { color: #e91e63; background: #ffe6e8; }
.coach-level-middle { color: #ed7b2f; background: #fff3e6; }
.coach-level-junior { color: #0052d9; background: #ecf2fe; }
.user-store-row {
line-height: 36rpx;
}
/* 球会名称 — 纯文字,无背景 */
.store-name-text {
.user-store {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 29rpx;
}
/* ============================================
@@ -254,20 +208,8 @@ view {
font-variant-numeric: tabular-nums;
}
.stat-primary {
color: #0052d9;
}
.stat-success {
color: #00a870;
}
.stat-sub-hint {
font-size: 20rpx;
color: #c5c5c5;
margin-top: 2rpx;
line-height: 26rpx;
}
.stat-primary { color: #0052d9; }
.stat-success { color: #00a870; }
.stat-hours-raw {
font-size: 20rpx;
@@ -290,7 +232,7 @@ view {
}
/* ============================================
* 记录列表
* 记录列表(复用 performance 页面服务记录明细样式)
* ============================================ */
.records-container {
padding: 24rpx;
@@ -302,13 +244,14 @@ view {
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 32rpx 8rpx;
padding: 20rpx 0 8rpx;
}
.dd-date {
@@ -337,14 +280,14 @@ view {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 32rpx;
padding: 16rpx 0;
}
.record-item--hover {
background: #f7f7f7;
opacity: 0.7;
}
/* 头像 */
/* 头像(渐变色由 app.wxss 全局 .avatar-{key} 提供) */
.record-avatar {
width: 76rpx;
height: 76rpx;
@@ -358,24 +301,6 @@ view {
flex-shrink: 0;
}
.avatar-from-blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar-from-pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar-from-teal { background: linear-gradient(135deg, #2dd4bf, #10b981); }
.avatar-from-green { background: linear-gradient(135deg, #4ade80, #14b8a6); }
.avatar-from-orange { background: linear-gradient(135deg, #fb923c, #f59e0b); }
.avatar-from-purple { background: linear-gradient(135deg, #c084fc, #8b5cf6); }
.avatar-from-violet { background: linear-gradient(135deg, #a78bfa, #7c3aed); }
.avatar-from-amber { background: linear-gradient(135deg, #fbbf24, #eab308); }
.avatar-from-sky { background: linear-gradient(135deg, #38bdf8, #0ea5e9); }
.avatar-from-lime { background: linear-gradient(135deg, #a3e635, #65a30d); }
.avatar-from-rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
.avatar-from-fuchsia{ background: linear-gradient(135deg, #e879f9, #a21caf); }
.avatar-from-slate { background: linear-gradient(135deg, #94a3b8, #475569); }
.avatar-from-indigo { background: linear-gradient(135deg, #818cf8, #4338ca); }
.avatar-from-cyan { background: linear-gradient(135deg, #22d3ee, #0891b2); }
.avatar-from-yellow { background: linear-gradient(135deg, #facc15, #ca8a04); }
/* 内容区 */
.record-content {
flex: 1;
min-width: 0;
@@ -408,27 +333,15 @@ view {
line-height: 29rpx;
}
.record-hours-wrap {
display: flex;
align-items: baseline;
gap: 6rpx;
flex-shrink: 0;
}
.record-hours {
font-size: 26rpx;
font-weight: 700;
color: #059669;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
line-height: 36rpx;
}
.record-hours-deduct {
font-size: 20rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.record-bottom {
display: flex;
align-items: center;
@@ -450,20 +363,9 @@ view {
line-height: 29rpx;
}
.tag-basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
background: #fffbeb;
color: #a16207;
}
.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;

View File

@@ -5,6 +5,7 @@
"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",
"metric-card": "/components/metric-card/metric-card",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"

View File

@@ -1,5 +1,25 @@
// TODO: 联调时替换为真实 API 调用
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-24 | 角色标签显示修复 | coachRole 从英文 code 映射中文;助教显示"X级助教",与 task-list 页一致 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { initPageAiColor } from '../../utils/ai-color-manager'
import { fetchMe, fetchPerformanceOverview } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
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 IncomeItem {
@@ -12,12 +32,12 @@ interface IncomeItem {
/** 服务记录(按日期分组后的展示结构) */
interface ServiceRecord {
customerName: string
memberId: number
avatarChar: string
avatarColor: string
timeRange: string
hours: string
courseType: string
courseTypeClass: string
location: string
income: string
}
@@ -30,31 +50,38 @@ interface DateGroup {
}
Page({
/** 从任务页"查看所有客户"入口进入时,数据加载完后滚动到底部 */
_scrollToBottom: false,
data: {
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** Banner 数据 */
coachName: '小燕',
coachRole: '助教',
storeName: '广州朗朗桌球',
monthlyIncome: '¥6,206',
lastMonthIncome: '¥16,880',
avatarUrl: '',
coachName: '',
coachRole: '',
storeName: '',
monthlyIncome: '',
lastMonthIncome: '',
/** 当月预估判断 */
isCurrentMonth: true,
/** 收入档位 */
currentTier: {
basicRate: 80,
incentiveRate: 95,
basicRate: 0,
incentiveRate: 0,
},
nextTier: {
basicRate: 90,
incentiveRate: 114,
basicRate: 0,
incentiveRate: 0,
},
upgradeHoursNeeded: 15,
upgradeBonus: 800,
upgradeHoursNeeded: 0,
upgradeBonus: 0,
/** 本月业绩明细 */
incomeItems: [] as IncomeItem[],
monthlyTotal: '¥6,950.5',
monthlyTotal: '',
/** 服务记录 */
thisMonthRecords: [] as DateGroup[],
@@ -63,112 +90,127 @@ Page({
visibleRecordGroups: 2,
/** 新客列表 */
newCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; lastService: string; count: number }>,
newCustomers: [] as Array<{ name: string; memberId: number; avatarChar: string; avatarColor: string; lastService: string; count: number }>,
newCustomerExpanded: false,
/** 常客列表 */
regularCustomers: [] as Array<{ name: string; avatarChar: string; avatarColor: string; hours: number; income: string; count: number }>,
regularCustomers: [] as Array<{ name: string; memberId: number; avatarChar: string; avatarColor: string; hours: number; income: string; count: number }>,
regularCustomerExpanded: false,
},
onLoad() {
onLoad(options?: { scrollToBottom?: string }) {
// 初始化 AI 图标配色(蓝色 - 数据分析感)
const { aiColor } = initPageAiColor('performance')
this.setData({ aiColor })
// 记录是否需要滚动到底部(从任务页"查看所有客户"入口进入)
this._scrollToBottom = options?.scrollToBottom === '1'
this.loadData()
},
loadData() {
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/performance/performance')
},
async loadData() {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
setTimeout(() => {
try {
// TODO: 替换为真实 API
const incomeItems: IncomeItem[] = [
{ icon: '🎱', label: '基础课', desc: '80元/h × 75h', value: '¥6,000' },
{ icon: '⭐', label: '激励课', desc: '95.05元/h × 10h', value: '¥950.5' },
{ icon: '💰', label: '充值激励', desc: '客户充值返佣', value: '¥500' },
{ icon: '🏆', label: 'TOP3 销冠奖', desc: '全店业绩前三名奖励', value: '继续努力' },
]
// G2当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// TODO: 联调时从接口参数或页面参数获取 year/month
const year = nowYear
const month = nowMonth
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"
const isCurrentMonth = year === nowYear && month === nowMonth && now.getDate() <= 5
const gradients = [
'blue', 'pink', 'teal', 'green',
'orange', 'purple', 'violet', 'amber',
]
try {
// 并行请求用户信息和绩效概览
const [me, overview] = await Promise.all([
fetchMe().catch(() => null),
fetchPerformanceOverview({ year, month }),
])
// 模拟服务记录按日期分组
const thisMonthRecords: DateGroup[] = [
{
date: '2月7日',
totalHours: '4.0h',
totalIncome: '¥350',
records: [
{ customerName: '王先生', avatarChar: '王', avatarColor: gradients[0], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '3号台', income: '¥160' },
{ customerName: '李女士', avatarChar: '李', avatarColor: gradients[1], timeRange: '16:00-18:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP1号房', income: '¥190' },
],
},
{
date: '2月6日',
totalHours: '2.0h',
totalIncome: '¥160',
records: [
{ customerName: '张先生', avatarChar: '张', avatarColor: gradients[2], timeRange: '19:00-21:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '5号台', income: '¥160' },
],
},
{
date: '2月5日',
totalHours: '4.0h',
totalIncome: '¥320',
records: [
{ customerName: '陈女士', avatarChar: '陈', avatarColor: gradients[2], timeRange: '20:00-22:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '2号台', income: '¥160' },
{ customerName: '赵先生', avatarChar: '赵', avatarColor: gradients[5], timeRange: '14:00-16:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '7号台', income: '¥160' },
],
},
{
date: '2月4日',
totalHours: '4.0h',
totalIncome: '¥350',
records: [
{ customerName: '孙先生', avatarChar: '孙', avatarColor: gradients[6], timeRange: '19:00-21:00', hours: '2.0h', courseType: '包厢课', courseTypeClass: 'tag-vip', location: 'VIP2号房', income: '¥190' },
{ customerName: '吴女士', avatarChar: '吴', avatarColor: gradients[2], timeRange: '15:00-17:00', hours: '2.0h', courseType: '基础课', courseTypeClass: 'tag-basic', location: '1号台', income: '¥160' },
],
},
]
// 用户信息fetchMe 失败不阻塞,用 overview 返回的兜底)
// CHANGE 2026-03-27 | 头像:读 avatarUrlCamelModel 转换后的字段名),拼接完整 URL
// 统一与 task-list 页面的用户信息获取逻辑
const userId = (me as any)?.userId
const hasAvatar = !!(me as any)?.avatarUrl
const avatarUrl = hasAvatar && userId ? `${API_BASE}/api/xcx/avatar/${userId}` : ''
const coachName = overview.coachName || (me as any)?.nickname || (me as any)?.name || ''
// CHANGE 2026-03-27 | 角色标签:统一用 fetchMe 的 role英文 code不用 overview.coachRole
// overview.coachRole 可能返回 "assistant" 等非标准值ROLE_LABELS 无法映射
const ROLE_LABELS: Record<string, string> = { coach: '助教', staff: '员工', head_coach: '主教练', manager: '店长' }
const rawRole = (me as any)?.role || overview.coachRole || ''
const coachLevel = (me as any)?.coachLevel || (me as any)?.coach_level || ''
const roleLabel = ROLE_LABELS[rawRole] || rawRole
const coachRole = (rawRole === 'coach' && coachLevel) ? `${coachLevel}助教` : roleLabel
const storeName = overview.storeName || (me as any)?.storeName || ''
const newCustomers = [
{ name: '王先生', avatarChar: '王', avatarColor: gradients[0], lastService: '2月7日', count: 2 },
{ name: '李女士', avatarChar: '李', avatarColor: gradients[1], lastService: '2月7日', count: 1 },
{ name: '刘先生', avatarChar: '刘', avatarColor: gradients[4], lastService: '2月6日', count: 1 },
{ name: '周女士', avatarChar: '周', avatarColor: gradients[3], lastService: '2月5日', count: 1 },
{ name: '吴先生', avatarChar: '吴', avatarColor: gradients[7], lastService: '2月4日', count: 1 },
{ name: '郑女士', avatarChar: '郑', avatarColor: gradients[5], lastService: '2月3日', count: 1 },
{ name: '钱先生', avatarChar: '钱', avatarColor: gradients[6], lastService: '2月2日', count: 1 },
{ name: '冯女士', avatarChar: '冯', avatarColor: gradients[2], lastService: '2月1日', count: 1 },
]
const regularCustomers = [
{ name: '张先生', avatarChar: '张', avatarColor: gradients[2], hours: 12, income: '¥960', count: 6 },
{ name: '陈女士', avatarChar: '陈', avatarColor: gradients[2], hours: 10, income: '¥800', count: 5 },
{ name: '赵先生', avatarChar: '赵', avatarColor: gradients[5], hours: 8, income: '¥640', count: 4 },
{ name: '孙先生', avatarChar: '孙', avatarColor: gradients[6], hours: 6, income: '¥570', count: 3 },
{ name: '杨女士', avatarChar: '杨', avatarColor: 'pink', hours: 6, income: '¥480', count: 3 },
{ name: '黄先生', avatarChar: '黄', avatarColor: 'amber', hours: 4, income: '¥380', count: 2 },
{ name: '林女士', avatarChar: '林', avatarColor: 'green', hours: 4, income: '¥320', count: 2 },
{ name: '徐先生', avatarChar: '徐', avatarColor: 'orange', hours: 2, income: '¥190', count: 1 },
]
// CHANGE 2026-03-24 | 头像颜色改为前端根据 member_id 计算
const thisMonthRecords = (overview.thisMonthRecords || []).map((group: any) => ({
...group,
records: (group.records || []).map((rec: any) => ({
...rec,
avatarColor: nameToAvatarColor(String(rec.memberId ?? rec.member_id ?? '')),
courseTagClass: courseTagClass(rec.courseType ?? rec.course_type ?? ''),
})),
}))
const newCustomers = (overview.newCustomers || []).map((c: any) => ({
...c,
avatarColor: nameToAvatarColor(String(c.memberId ?? c.member_id ?? '')),
}))
const regularCustomers = (overview.regularCustomers || []).map((c: any) => ({
...c,
avatarColor: nameToAvatarColor(String(c.memberId ?? c.member_id ?? '')),
}))
this.setData({
pageState: 'normal',
incomeItems,
isCurrentMonth,
avatarUrl,
coachName,
coachRole,
storeName,
monthlyIncome: overview.monthlyIncome,
lastMonthIncome: overview.lastMonthIncome,
currentTier: {
basicRate: overview.currentTier.basicRate,
incentiveRate: overview.currentTier.incentiveRate,
},
nextTier: {
basicRate: overview.nextTier.basicRate,
incentiveRate: overview.nextTier.incentiveRate,
},
upgradeHoursNeeded: overview.upgradeHoursNeeded,
upgradeBonus: overview.upgradeBonus,
incomeItems: overview.incomeItems,
monthlyTotal: overview.monthlyTotal,
thisMonthRecords,
newCustomers,
regularCustomers,
})
} catch (_e) {
this.setData({ pageState: 'error' })
// 从任务页"查看所有客户"入口进入时,自动展开常客列表并滚动到底部
if (this._scrollToBottom) {
this._scrollToBottom = false
this.setData({
regularCustomerExpanded: true,
newCustomerExpanded: true,
})
setTimeout(() => {
wx.pageScrollTo({ scrollTop: 99999, duration: 300 })
}, 150)
}
}, 500)
} catch (_e) {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 加载失败重试 */
@@ -202,19 +244,28 @@ Page({
},
/** 点击客户卡片 → 跳转任务详情 */
// CHANGE 2026-03-25 | 传 memberId 替代 customerName详情页按 member 查询任务
// CHANGE 2026-03-27 | 任务C1: 散客/未知客户memberId <= 0拦截不跳转
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
const { name } = e.currentTarget.dataset
const { name, memberId } = e.currentTarget.dataset
const mid = Number(memberId)
if (!mid || mid <= 0) {
wx.showToast({ title: '未知客户不提供查看详情', icon: 'none' })
return
}
wx.navigateTo({
url: `/pages/task-detail/task-detail?customerName=${name}`,
url: `/pages/task-detail/task-detail?memberId=${memberId || ''}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
/** 点击服务记录 → 跳转任务详情 */
// CHANGE 2026-03-25 | 优先用 taskIdfallback 用 memberId
onRecordTap(e: WechatMiniprogram.TouchEvent) {
const { customerName, taskId } = e.currentTarget.dataset
const { customerName, taskId, memberId } = e.currentTarget.dataset
const params = taskId ? `id=${taskId}` : `memberId=${memberId || ''}`
wx.navigateTo({
url: `/pages/task-detail/task-detail?customerName=${customerName}${taskId ? `&taskId=${taskId}` : ''}`,
url: `/pages/task-detail/task-detail?${params}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},

View File

@@ -1,15 +1,8 @@
<!-- pages/performance/performance.wxml — 业绩总览 -->
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 空数据态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="40px" color="#dcdcdc" />
<text class="empty-text">暂无业绩数据</text>
</view>
@@ -31,29 +24,33 @@
<view class="banner-content">
<!-- 个人信息 -->
<view class="coach-info">
<view class="coach-avatar">
<image src="/assets/images/avatar-coach.png" class="avatar-img" mode="aspectFill" />
</view>
<view class="coach-meta">
<view class="coach-name-row">
<text class="coach-name">{{coachName}}</text>
<text class="coach-role-tag">{{coachRole}}</text>
<!-- CHANGE 2026-03-27 | 个人信息区域WXML 结构对齐 task-list基准统一类名和布局 -->
<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>
<text class="coach-store">{{storeName}}</text>
</view>
</view>
<!-- 核心收入数据 -->
<view class="income-overview">
<view class="income-card">
<text class="income-label">本月预计收入</text>
<text class="income-value">{{monthlyIncome}}</text>
<text class="income-label">{{isCurrentMonth ? '我的预估收入' : '我的收入'}}</text>
<text class="income-value">{{fmt.safe(monthlyIncome)}}</text>
</view>
<view class="income-card">
<text class="income-label">上月收入</text>
<text class="income-value income-highlight">{{lastMonthIncome}}</text>
<text class="income-value income-highlight">{{fmt.safe(lastMonthIncome)}}</text>
</view>
</view>
</view>
@@ -77,7 +74,7 @@
<view class="tier-rates">
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-green">{{currentTier.basicRate}}</text>
<text class="rate-value rate-green">{{fmt.safe(currentTier.basicRate)}}</text>
<text class="rate-unit rate-green-light">元/h</text>
</view>
<text class="rate-desc rate-green-light">基础课到手</text>
@@ -85,7 +82,7 @@
<view class="rate-divider"></view>
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-green">{{currentTier.incentiveRate}}</text>
<text class="rate-value rate-green">{{fmt.safe(currentTier.incentiveRate)}}</text>
<text class="rate-unit rate-green-light">元/h</text>
</view>
<text class="rate-desc rate-green-light">激励课到手</text>
@@ -105,7 +102,7 @@
<view class="tier-rates">
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-yellow">{{nextTier.basicRate}}</text>
<text class="rate-value rate-yellow">{{fmt.safe(nextTier.basicRate)}}</text>
<text class="rate-unit rate-yellow-light">元/h</text>
</view>
<text class="rate-desc rate-yellow-light">基础课到手</text>
@@ -113,7 +110,7 @@
<view class="rate-divider rate-divider-yellow"></view>
<view class="rate-item">
<view class="rate-value-row">
<text class="rate-value rate-yellow">{{nextTier.incentiveRate}}</text>
<text class="rate-value rate-yellow">{{fmt.safe(nextTier.incentiveRate)}}</text>
<text class="rate-unit rate-yellow-light">元/h</text>
</view>
<text class="rate-desc rate-yellow-light">激励课到手</text>
@@ -130,14 +127,14 @@
<text class="upgrade-label">距离下一阶段</text>
<view class="upgrade-hours">
<text>需完成 </text>
<text class="upgrade-hours-num">{{upgradeHoursNeeded}}</text>
<text class="upgrade-hours-num">{{fmt.safe(upgradeHoursNeeded)}}</text>
<text> 小时</text>
</view>
</view>
</view>
<view class="upgrade-bonus">
<text class="bonus-label">到达即得</text>
<text class="bonus-value">{{upgradeBonus}}</text>
<text class="bonus-value">{{fmt.money(upgradeBonus)}}</text>
</view>
</view>
</view>
@@ -146,7 +143,8 @@
<view class="section-card">
<view class="section-title">
<view class="title-dot dot-success"></view>
<text>本月业绩 预估</text>
<text>本月业绩</text>
<text wx:if="{{isCurrentMonth}}" class="estimate-tag">预估</text>
</view>
<view class="income-list">
@@ -156,18 +154,18 @@
<text>{{item.icon}}</text>
</view>
<view class="income-info">
<text class="income-item-label">{{item.label}}</text>
<text class="income-item-desc">{{item.desc}}</text>
<text class="income-item-label">{{fmt.safe(item.label)}}</text>
<text class="income-item-desc">{{fmt.safe(item.desc)}}</text>
</view>
</view>
<text class="income-item-value">{{item.value}}</text>
<text class="income-item-value">{{fmt.safe(item.value)}}</text>
</view>
</view>
<!-- 合计 -->
<view class="income-total">
<text class="total-label">本月合计 预估</text>
<text class="total-value">{{monthlyTotal}}</text>
<text class="total-label">本月合计<text wx:if="{{isCurrentMonth}}"> 预估</text></text>
<text class="total-value">{{fmt.safe(monthlyTotal)}}</text>
</view>
<!-- 服务记录明细 -->
@@ -179,29 +177,30 @@
<block wx:for="{{thisMonthRecords}}" wx:key="date" wx:if="{{thisMonthRecordsExpanded || index < visibleRecordGroups}}">
<view class="date-divider">
<text decode class="dd-date">{{item.date}}&nbsp;—</text>
<text decode class="dd-date">{{fmt.safe(item.date)}}&nbsp;—</text>
<text decode class="dd-stats" wx:if="&nbsp;{{item.totalHours}}">{{item.totalHours}}&nbsp;·&nbsp;{{item.totalIncome}}&nbsp;&nbsp;</text>
<text decode class="dd-stats" wx:if="&nbsp;{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时&nbsp;·&nbsp;¥{{fmt.safe(item.totalIncome)}}&nbsp;&nbsp;</text>
<view class="dd-line"></view>
</view>
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName" bindtap="onRecordTap" data-customer-name="{{rec.customerName}}" data-task-id="{{rec.taskId}}">
<view class="record-item" wx:for="{{item.records}}" wx:for-item="rec" wx:key="customerName" bindtap="onRecordTap" data-customer-name="{{rec.customerName}}" data-task-id="{{rec.taskId}}" 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.customerName}}</text>
<text class="record-time">{{rec.timeRange}}</text>
<text class="record-name">{{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">{{rec.hours}}</text>
<text class="record-hours">{{fmt.safe(rec.hours)}}小时</text>
</view>
<view class="record-bottom">
<view class="record-tags">
<text class="course-tag {{rec.courseTypeClass}}">{{rec.courseType}}</text>
<text class="record-location">{{rec.location}}</text>
<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">我的预估收入 <text class="record-income-val">{{rec.income}}</text></text>
<text class="record-income">{{isCurrentMonth ? '我的预估收入' : '我的收入'}} <text class="record-income-val">{{fmt.safe(rec.income)}}</text></text>
</view>
</view>
</view>
@@ -236,14 +235,18 @@
wx:key="name"
wx:if="{{newCustomerExpanded ? index < 20 : index < 5}}"
data-name="{{item.name}}"
data-member-id="{{item.memberId}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.avatarColor}}">
<text>{{item.avatarChar}}</text>
</view>
<view class="customer-info">
<text class="customer-name">{{item.name}}</text>
<text class="customer-detail">最近服务: {{item.lastService}} · {{item.count}}</text>
<view class="customer-name-row">
<text class="customer-name">{{fmt.safe(item.name)}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<text class="customer-detail">最近服务: {{fmt.safe(item.lastService)}} · {{fmt.count(item.count, '次')}}</text>
</view>
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
</view>
@@ -259,6 +262,7 @@
<view class="section-title">
<view class="title-dot dot-pink"></view>
<text>我的常客</text>
<text class="section-subtitle">近90天常客数据</text>
</view>
<view class="customer-list">
@@ -269,14 +273,18 @@
wx:key="name"
wx:if="{{regularCustomerExpanded ? index < 20 : index < 5}}"
data-name="{{item.name}}"
data-member-id="{{item.memberId}}"
bindtap="onCustomerTap"
>
<view class="customer-avatar avatar-{{item.avatarColor}}">
<text>{{item.avatarChar}}</text>
</view>
<view class="customer-info">
<text class="customer-name">{{item.name}}</text>
<text class="customer-detail">{{item.count}}次 · {{item.hours}}h · {{item.income}}</text>
<view class="customer-name-row">
<text class="customer-name">{{fmt.safe(item.name)}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<text class="customer-detail">{{fmt.count(item.count, '次')}} · {{fmt.hours(item.hours)}} · {{fmt.safe(item.income)}}</text>
</view>
<t-icon name="chevron-right" size="24px" color="#c5c5c5" />
</view>

View File

@@ -113,19 +113,26 @@ view {
width: 44px;
}
/* 个人信息 */
.coach-info {
/* CHANGE 2026-03-27 | 个人信息区域:样式对齐 task-list基准 */
/* performance 的 banner-content 已有 padding: 40rpx此处不重复设置 */
.user-info-section {
position: relative;
z-index: 2;
}
.user-info-row {
display: flex;
align-items: center;
gap: 24rpx;
gap: 29rpx;
margin-bottom: 40rpx;
}
.coach-avatar {
width: 100rpx;
height: 100rpx;
.avatar-wrap {
width: 102rpx;
height: 102rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 10rpx 15rpx -3rpx rgba(0,0,0,0.1), 0 4rpx 6rpx -4rpx rgba(0,0,0,0.1);
overflow: hidden;
flex-shrink: 0;
display: flex;
@@ -138,37 +145,42 @@ view {
height: 100%;
}
.coach-meta {
.user-detail {
flex: 1;
}
.coach-name-row {
.user-name-row {
display: flex;
align-items: center;
gap: 12rpx;
gap: 15rpx;
margin-bottom: 7rpx;
line-height: 51rpx;
}
.coach-name {
font-size: 40rpx;
.user-name {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
line-height: 56rpx;
}
.coach-role-tag {
padding: 4rpx 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 24rpx;
.user-role-tag {
font-size: 22rpx;
color: #ffffff;
line-height: 29rpx;
padding: 4rpx 15rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 9999rpx;
color: #ffffff;
display: inline-block;
vertical-align: middle;
}
.coach-store {
.user-store-row {
line-height: 36rpx;
}
.user-store {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 36rpx;
}
/* 收入概览卡片 */
@@ -234,11 +246,33 @@ view {
flex-shrink: 0;
}
/* 常客标题旁灰色说明小字 */
.section-subtitle {
font-size: 22rpx;
font-weight: 400;
color: #a6a6a6;
margin-left: 8rpx;
line-height: 29rpx;
}
.dot-primary { background: #0052d9; }
.dot-success { background: #00a870; }
.dot-cyan { background: #06b6d4; }
.dot-pink { background: #ec4899; }
/* G2预估标签 */
.estimate-tag {
display: inline-block;
padding: 2rpx 12rpx;
background: #fff7ed;
color: #ea580c;
border-radius: 8rpx;
font-size: 20rpx;
font-weight: 500;
margin-left: 8rpx;
line-height: 29rpx;
}
/* ========== 收入档位 ========== */
.tier-card {
position: relative;
@@ -661,17 +695,21 @@ view {
line-height: 29rpx;
}
.tag-basic {
/* CHANGE 2026-03-24 | 课程标签样式:数据库原始 skill_name 由前端映射为英文 CSS key */
/* 基础课类:陪打、基础课 → course-tag--basic */
.course-tag--basic {
background: #ecfdf5;
color: #15803d;
}
.tag-vip {
/* 包厢课类:包厢、包厢课 → course-tag--room */
.course-tag--room {
background: #eff6ff;
color: #1d4ed8;
}
.tag-tip {
/* 激励课类:超休、激励课、打赏课 → course-tag--incentive */
.course-tag--incentive {
background: #fffbeb;
color: #a16207;
}
@@ -760,6 +798,12 @@ view {
line-height: 36rpx;
}
.customer-name-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.customer-detail {
display: block;
font-size: 22rpx;

View File

@@ -1,4 +1,11 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | approved 跳转改为 getRoleHome(data.role),保存 role 到 globalData 和 Storage |
| 2026-03-23 | 审核流程增强 | 进度卡片展示提交信息;底部改为"更换登录账号"+"重新申请"双按钮;重新申请取消当前申请并跳转申请页预填 |
*/
import { request } from "../../utils/request"
import { getRoleHome, syncVisibleTabs } from "../../utils/auth-guard"
Page({
data: {
@@ -7,13 +14,15 @@ Page({
status: "pending" as "pending" | "rejected",
application: null as null | {
id: number
site_code: string
role_type: string
siteCode: string
appliedRoleText: string
phone: string
employeeNumber: string | null
status: string
reject_reason: string | null
created_at: string
reviewNote: string | null
createdAt: string
},
cancelling: false,
},
onLoad() {
@@ -40,19 +49,24 @@ Page({
needAuth: true,
})
// 同步 globalData 和 Storage
// CHANGE 2026-03-23 | 角色路由:保存 role 并按角色跳转
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
userId: data.userId,
status: data.status,
nickname: data.nickname,
role: data.role || undefined,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userId", data.userId)
wx.setStorageSync("userStatus", data.status)
if (data.role) wx.setStorageSync("userRole", data.role)
// 已通过 → 跳转
// 已通过 → 按角色跳转
if (data.status === "approved") {
wx.reLaunch({ url: "/pages/mvp/mvp" })
syncVisibleTabs(data.role)
// CHANGE 2026-03-24 | 防御approved 但 role 为空时跳个人页,不跳登录页
const home = data.role ? getRoleHome(data.role) : '/pages/my-profile/my-profile'
wx.reLaunch({ url: home })
return
}
@@ -77,7 +91,15 @@ Page({
this.setData({
loading: false,
status: data.status,
application: data.latest_application || null,
application: data.latestApplication
? {
...data.latestApplication,
// 提交时间只显示到分钟2026-03-23 01:33
createdAt: data.latestApplication.createdAt
? data.latestApplication.createdAt.replace(/T/, " ").replace(/:\d{2}(\.\d+)?([\+\-].*|Z)?$/, "")
: "",
}
: null,
})
} catch (err: any) {
this.setData({ loading: false })
@@ -96,4 +118,33 @@ Page({
wx.removeStorageSync("userStatus")
wx.reLaunch({ url: "/pages/login/login" })
},
// CHANGE 2026-03-23 | 审核流程增强:重新申请(取消当前 pending 申请 → 跳转申请页预填)
async onReapply() {
if (this.data.cancelling) return
this.setData({ cancelling: true })
try {
const result = await request({
url: "/api/xcx/cancel-application",
method: "POST",
needAuth: true,
})
// 跳转申请页,通过 URL 参数传递预填信息
const params = [
`siteCode=${encodeURIComponent(result.siteCode || "")}`,
`role=${encodeURIComponent(result.appliedRoleText || "")}`,
`phone=${encodeURIComponent(result.phone || "")}`,
`employeeNumber=${encodeURIComponent(result.employeeNumber || "")}`,
].join("&")
wx.reLaunch({ url: `/pages/apply/apply?${params}` })
} catch (err: any) {
const msg = err?.data?.detail || "取消申请失败,请稍后重试"
wx.showToast({ title: msg, icon: "none" })
} finally {
this.setData({ cancelling: false })
}
},
})

View File

@@ -1,6 +1,6 @@
<!-- pages/reviewing/reviewing.wxml — 按 H5 原型结构迁移 -->
<!-- pages/reviewing/reviewing.wxml — 审核中状态页 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 十字纹背景图案H5 bg-pattern -->
<!-- 十字纹背景图案 -->
<view class="bg-pattern"></view>
<!-- 顶部渐变装饰 -->
<view class="top-gradient top-gradient--{{status}}"></view>
@@ -16,14 +16,11 @@
<view class="content" wx:else>
<!-- 图标区域 -->
<view class="icon-area float-animation">
<!-- 背景光晕 -->
<view class="icon-glow icon-glow--{{status}}"></view>
<!-- 主图标 -->
<view class="icon-box icon-box--{{status}}">
<image wx:if="{{status === 'pending'}}" class="icon-main-img" src="/assets/icons/icon-clock-circle.svg" mode="aspectFit" />
<t-icon wx:else name="close-circle" size="98rpx" color="#fff" />
</view>
<!-- 装饰点 -->
<view class="icon-dot icon-dot--{{status}} dot-1 pulse-soft"></view>
<view class="icon-dot icon-dot--{{status}} dot-2 pulse-soft"></view>
</view>
@@ -69,32 +66,39 @@
<text class="step-label step-label--pending">通过</text>
</view>
</view>
<!-- CHANGE 2026-03-23 | 审核流程增强:进度卡片内展示提交信息 -->
<view class="submit-info" wx:if="{{application}}">
<view class="submit-info-row">
<text class="submit-info-label">球房ID</text>
<text class="submit-info-value">{{application.siteCode}}</text>
</view>
<view class="submit-info-row">
<text class="submit-info-label">申请身份</text>
<text class="submit-info-value">{{application.appliedRoleText}}</text>
</view>
<view class="submit-info-row">
<text class="submit-info-label">手机号</text>
<text class="submit-info-value">{{application.phone}}</text>
</view>
<view class="submit-info-row" wx:if="{{application.employeeNumber}}">
<text class="submit-info-label">编号</text>
<text class="submit-info-value">{{application.employeeNumber}}</text>
</view>
<view class="submit-info-row">
<text class="submit-info-label">提交时间</text>
<text class="submit-info-value submit-info-value--time">{{application.createdAt}}</text>
</view>
</view>
</view>
<!-- 拒绝原因卡片 -->
<view class="reject-card" wx:if="{{status === 'rejected' && application && application.reject_reason}}">
<view class="reject-card" wx:if="{{status === 'rejected' && application && application.reviewNote}}">
<view class="reject-header">
<t-icon name="error-circle-filled" size="32rpx" color="#e34d59" />
<text class="reject-title">拒绝原因</text>
</view>
<text class="reject-reason">{{application.reject_reason}}</text>
</view>
<!-- 申请信息摘要 -->
<view class="info-card" wx:if="{{application}}">
<text class="info-card-title">申请信息</text>
<view class="info-row">
<text class="info-label">球房ID</text>
<text class="info-value">{{application.site_code}}</text>
</view>
<view class="info-row">
<text class="info-label">申请身份</text>
<text class="info-value">{{application.role_type}}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-value">{{application.phone}}</text>
</view>
<text class="reject-reason">{{application.reviewNote}}</text>
</view>
<!-- 联系提示 -->
@@ -106,9 +110,19 @@
<!-- 底部按钮区域 -->
<view class="bottom-area" wx:if="{{!loading}}">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
<!-- CHANGE 2026-03-23 | 审核流程增强:双按钮(更换登录账号 + 重新申请) -->
<view class="btn-row">
<view class="switch-btn" bindtap="onSwitchAccount">
<t-icon name="logout" size="28rpx" color="#5e5e5e" />
<text class="switch-btn-text">更换登录账号</text>
</view>
<view class="reapply-btn {{cancelling ? 'reapply-btn--disabled' : ''}}" bindtap="onReapply">
<t-loading wx:if="{{cancelling}}" theme="circular" size="28rpx" color="#fff" />
<block wx:else>
<t-icon name="refresh" size="28rpx" color="#fff" />
<text class="reapply-btn-text">重新申请</text>
</block>
</view>
</view>
</view>
</view>

View File

@@ -296,6 +296,39 @@
border-radius: 2rpx;
}
/* ---- 提交信息区域(进度卡片内) ---- */
.submit-info {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 2rpx solid #f5f5f5;
}
.submit-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10rpx 0;
}
.submit-info-label {
font-size: 22rpx;
color: #a6a6a6;
flex-shrink: 0;
}
.submit-info-value {
font-size: 22rpx;
color: #5e5e5e;
font-weight: 500;
text-align: right;
}
.submit-info-value--time {
font-size: 20rpx;
color: #a6a6a6;
font-weight: 400;
}
/* ---- 拒绝原因卡片 ---- */
.reject-card {
width: 100%;
@@ -325,45 +358,6 @@
line-height: 1.625;
}
/* ---- 申请信息卡片 ---- */
.info-card {
width: 100%;
background: #ffffff;
border-radius: 28rpx;
padding: 28rpx;
margin-bottom: 22rpx;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
}
.info-card-title {
display: block;
font-size: 24rpx;
font-weight: 500;
color: #242424;
margin-bottom: 22rpx;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14rpx 0;
border-bottom: 2rpx solid #f5f5f5;
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: 24rpx;
color: #8b8b8b;
}
.info-value {
font-size: 24rpx;
color: #242424;
font-weight: 500;
}
/* ---- 联系提示 ---- */
/* H5: flex items-center gap-2 text-gray-6 mb-8 */
.contact-hint {
@@ -387,13 +381,20 @@
z-index: 1;
}
/* 双按钮横排布局 */
.btn-row {
display: flex;
flex-direction: row;
gap: 20rpx;
}
/* 原生按钮替代 TDesign — H5: w-full py-3.5=56rpx bg-white border border-gray-200 rounded-xl */
.switch-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
width: 100%;
gap: 10rpx;
flex: 1;
padding: 24rpx 0;
background: #ffffff;
border: 2rpx solid #eeeeee;
@@ -402,11 +403,34 @@
}
.switch-btn-text {
font-size: 24rpx;
font-size: 22rpx;
font-weight: 500;
color: #5e5e5e;
}
/* 重新申请按钮(主色调) */
.reapply-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
flex: 1;
padding: 24rpx 0;
background: linear-gradient(135deg, #f59e0b, #f97316);
border-radius: 22rpx;
box-shadow: 0 4rpx 14rpx rgba(237, 123, 47, 0.25);
}
.reapply-btn--disabled {
opacity: 0.6;
}
.reapply-btn-text {
font-size: 22rpx;
font-weight: 500;
color: #ffffff;
}
/* ---- 动画 ---- */
@keyframes float {
0%, 100% { transform: translateY(0); }

View File

@@ -1,8 +1,14 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import type { TaskDetail, Note } from '../../utils/mock-data'
import { fetchTaskDetail } from '../../services/api'
import { fetchTaskDetail, fetchTaskByMember, createNote, deleteNote } from '../../services/api'
import { sortByTimestamp } from '../../utils/sort'
import { formatRelativeTime } from '../../utils/time'
import { formatMoney } from '../../utils/money'
import { formatStorageLevel } from '../../utils/storage-level'
/** 维客线索项 */
interface RetentionClue {
@@ -38,24 +44,6 @@ interface ServiceSummary {
avgIncome: number
}
/**
* 将 ISO/空格分隔的日期字符串格式化为中文短格式
* "2026-02-07T21:30" → "2月7日 21:30"
* 与 customer-service-records 页面拼接方式保持一致
*/
function formatServiceDate(dateStr: string): string {
if (!dateStr) return dateStr
// 兼容 "2026-02-07T21:30" 和 "2026-02-07 21:30"
const normalized = dateStr.replace('T', ' ')
const [datePart, timePart] = normalized.split(' ')
if (!datePart) return dateStr
const parts = datePart.split('-')
if (parts.length < 3) return dateStr
const month = parseInt(parts[1], 10)
const day = parseInt(parts[2], 10)
return timePart ? `${month}${day}${timePart}` : `${month}${day}`
}
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal' | 'error',
@@ -65,33 +53,25 @@ Page({
// --- 维客线索 ---
retentionClues: [
{ tag: '客户\n基础', tagColor: 'primary', emoji: '🎂', text: '生日 3月15日 · VIP会员 · 注册2年', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🌙', text: '常来夜场 · 月均4-5次', source: 'By:系统', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '💰', text: '高客单价', source: 'By:系统', desc: '近60天场均消费 ¥420高于门店均值 ¥180偏好夜场时段酒水附加消费占比 35%', expanded: false },
{ tag: '玩法\n偏好', tagColor: 'purple', emoji: '🎱', text: '偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣偏爱中式八球 · 斯诺克进阶中 · 最近对花式九球也有兴趣', source: 'By:系统', desc: '中式八球占比 60%,斯诺克 30%近2周开始尝试花式九球技术水平中等偏上', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '⚠️', text: '上次提到想练斯诺克走位', source: 'By:小燕', desc: '2月7日到店时主动提及希望有针对性的走位训练建议下次安排斯诺克专项课程', expanded: false },
{ tag: '社交\n偏好', tagColor: 'purple', emoji: '👥', text: '喜欢带朋友来玩 · 社交型客户', source: 'By:系统', desc: '70%的到店记录都是多人消费,经常介绍新客户;建议推荐团建套餐和会员推荐奖励', expanded: false },
{ tag: '消费\n习惯', tagColor: 'success', emoji: '🍷', text: '酒水消费占比高 · 偏好高端酒水', source: 'By:系统', desc: '每次到店必点酒水偏好芝华士、百威等品牌酒水消费占总消费的40%', expanded: false },
{ tag: '重要\n反馈', tagColor: 'error', emoji: '💬', text: '上次提到想办生日派对', source: 'By:Lucy', desc: '3月15日生日想在店里办派对预计10-15人已记录需求建议提前联系确认', expanded: false },
{ tag: '', tagColor: 'primary', emoji: '', text: '', source: '', expanded: false },
{ tag: '', tagColor: 'success', emoji: '', text: '', source: '', desc: '', expanded: false },
{ tag: '', tagColor: 'error', emoji: '', text: '', source: '', desc: '', expanded: false },
] as RetentionClue[],
// --- 话术参考 ---
talkingPoints: [
'王哥您好,好久不见!最近店里新到了几张国际标准的斯诺克球桌,知道您是斯诺克爱好者,想邀请您有空来体验一下~',
'王哥,最近忙吗?这周末我们有个老客户专属的球友交流赛,奖品还挺丰富的,您要不要来参加?',
'王哥好呀,上次您提到想练练斯诺克的走位,我最近研究了一些新的训练方法,下次来的时候可以一起试试~',
'王哥,好久没见您了,您的老位置 A12 号台一直给您留着呢!最近晚上人不多,环境特别好,随时欢迎您来~',
'王哥您好,我们这个月推出了储值会员专属的夜场优惠套餐,包含球台+酒水,性价比很高,给您留意着呢~',
'',
'',
'',
] as string[],
copiedIndex: -1,
// --- 近期服务记录 ---
serviceSummary: { totalHours: 6.0, totalIncome: 510, avgIncome: 170 } as ServiceSummary,
serviceSummary: { totalHours: 0, totalIncome: 0, avgIncome: 0 } as ServiceSummary,
serviceRecords: [
{ table: 'A12号台', type: '基础课', typeClass: 'basic', duration: 2.5, durationRaw: 3.0, income: 200, isEstimate: true, drinks: '🍷 百威x2 红牛x1', date: formatServiceDate('2026-02-07T21:30') },
{ table: '3号台', type: '基础课', typeClass: 'basic', duration: 2.0, durationRaw: 2.0, income: 160, isEstimate: false, drinks: '🍷 可乐x1', date: formatServiceDate('2026-02-01T20:30') },
{ table: 'VIP1号房', type: '包厢课', typeClass: 'vip', duration: 1.5, durationRaw: 1.5, income: 150, isEstimate: true, drinks: '🍷 芝华士x1 矿泉水x2', date: formatServiceDate('2026-01-28T19:00') },
{ table: '', type: '充值', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 80, isEstimate: false, drinks: '', date: formatServiceDate('2026-01-15T10:00') },
{ table: '', type: '', typeClass: 'basic', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
{ table: '', type: '', typeClass: 'vip', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
{ table: '', type: '', typeClass: 'recharge', recordType: 'recharge', duration: 0, durationRaw: 0, income: 0, isEstimate: false, drinks: '', date: '' },
] as ServiceRecord[],
// --- 放弃弹窗 ---
@@ -101,19 +81,19 @@ Page({
phoneVisible: false,
// --- 储值等级 ---
storageLevel: '非常多',
storageLevel: '',
// --- 关系等级相关 ---
relationLevel: 'excellent' as 'poor' | 'normal' | 'good' | 'excellent',
relationLevelText: '很好',
relationColor: '#e91e63',
relationLevel: 'poor' as 'poor' | 'normal' | 'good' | 'excellent',
relationLevelText: '',
relationColor: '',
// --- Banner 背景(根据任务类型动态切换)---
bannerBgSvg: '/assets/images/banner-bg-red-aurora.svg',
// --- 调试面板 ---
showDebugPanel: false,
debugTaskType: 'high_priority',
debugTaskType: 'high_priority_recall',
debugHeartScore: 8.5,
debugShowExpandBtn: true, // 调试:备注弹窗是否显示展开/收起按钮
@@ -121,17 +101,31 @@ Page({
aiColor: 'indigo' as 'red' | 'orange' | 'yellow' | 'blue' | 'indigo' | 'purple',
},
onLoad(options: { id?: string }) {
onLoad(options: { id?: string; memberId?: string }) {
const id = options?.id || ''
const memberId = options?.memberId || ''
// 随机 AI 配色
const aiColors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple'] as const
const aiColor = aiColors[Math.floor(Math.random() * aiColors.length)]
this.setData({ aiColor })
this.loadData(id)
if (id) {
this.loadData(id)
} else if (memberId) {
// CHANGE 2026-03-25 | 绩效页跳转:无 task_id 时按 member_id 查询最高优先级任务
this.loadByMember(memberId)
} else {
this.setData({ pageState: 'empty' })
}
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/task-detail/task-detail')
},
async loadData(id: string) {
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
try {
const detail = await fetchTaskDetail(id)
if (!detail) {
@@ -141,13 +135,13 @@ Page({
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (detail.taskType === 'high_priority') {
if (detail.taskType === 'high_priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
} else if (detail.taskType === 'priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
} else if (detail.taskType === 'relationship') {
} else if (detail.taskType === 'relationship_building') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (detail.taskType === 'callback') {
} else if (detail.taskType === 'follow_up_visit') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}
@@ -158,6 +152,9 @@ Page({
timeLabel: formatRelativeTime(n.createdAt),
}))
const sorted = sortByTimestamp(notesWithLabel) as (Note & { timeLabel: string })[]
// G4: 根据 balance 计算储值等级
const storageLevel = formatStorageLevel(detail.balance)
this.updateRelationshipDisplay(detail.heartScore)
this.setData({
pageState: 'normal',
@@ -165,9 +162,39 @@ Page({
sortedNotes: sorted,
debugHeartScore: detail.heartScore,
bannerBgSvg,
storageLevel,
// 从 detail 中提取嵌套模块数据到 data 顶层,供 WXML 直接绑定
retentionClues: (detail.retentionClues || []) as RetentionClue[],
talkingPoints: detail.talkingPoints || [],
serviceSummary: detail.serviceSummary || { totalHours: 0, totalIncome: 0, avgIncome: 0 },
// CHANGE 2026-03-27 | 清洗 serviceRecordsnull 值转为组件期望的默认值
// 小程序组件 Number/String property 收到 null 时行为不确定,需显式转换
serviceRecords: (detail.serviceRecords || []).map((r: any) => ({
...r,
durationRaw: r.durationRaw ?? 0,
drinks: r.drinks ?? '',
})),
})
} catch (_e) {
this.setData({ pageState: 'error' })
} finally {
wx.hideLoading()
}
},
/** 按 member_id 查询最高优先级任务,再加载详情 */
async loadByMember(memberId: string) {
// CHANGE 2026-03-25 | 修复fetchTaskByMember 已返回完整详情,
// 直接用返回的 id 走 loadData 标准流程,必须 await 避免 finally 竞态
try {
const detail = await fetchTaskByMember(memberId)
if (!detail || !detail.id) {
this.setData({ pageState: 'empty' })
return
}
await this.loadData(String(detail.id))
} catch (_e) {
this.setData({ pageState: 'empty' })
}
},
@@ -216,7 +243,11 @@ Page({
/** 复制手机号 */
onCopyPhone() {
const phone = '13812345678'
const phone = this.data.detail?.customerPhone || ''
if (!phone) {
wx.showToast({ title: '暂无手机号', icon: 'none' })
return
}
wx.setClipboardData({
data: phone,
success: () => {
@@ -253,18 +284,73 @@ Page({
},
/** 备注弹窗确认 */
onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { content } = e.detail
wx.showToast({ title: '备注已保存', icon: 'success' })
async onNoteConfirm(e: WechatMiniprogram.CustomEvent) {
const { content, serviceScore, returnScore } = e.detail
const detail = this.data.detail
if (!detail) return
this.setData({ noteModalVisible: false })
const newNote: Note = {
id: `note-${Date.now()}`,
content,
tagType: 'customer',
tagLabel: `客户:${this.data.detail?.customerName || ''}`,
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }),
wx.showLoading({ title: '保存中...', mask: true })
try {
// CHANGE 2026-03-27 | 联调:调真实后端 API 创建备注
const result = await createNote({
targetId: detail.customerId ?? Number(detail.id),
content,
taskId: detail.id ? Number(detail.id) : undefined,
ratingServiceWillingness: serviceScore > 0 ? serviceScore : undefined,
ratingRevisitLikelihood: returnScore > 0 ? returnScore : undefined,
})
wx.hideLoading()
wx.showToast({ title: '备注已保存', icon: 'success' })
// 用后端返回的数据构建 Note 对象,追加到列表
const newNote: Note = {
id: String(result.id),
content: result.content || content,
tagType: result.type || 'normal',
tagLabel: result.type === 'follow_up' ? '回访' : '普通',
createdAt: result.createdAt || new Date().toLocaleString('zh-CN', { hour12: false }),
score: result.aiScore || undefined,
}
this.setData({ sortedNotes: [{ ...newNote, timeLabel: '刚刚' }, ...this.data.sortedNotes] })
// CHANGE 2026-03-27 | AI 评分轮询:后台异步等待 aiScore 返回后自动更新星星
if (!result.aiScore) {
this._pollAiScore(String(result.id), 0)
}
} catch (err) {
wx.hideLoading()
console.error('[task-detail] createNote failed:', err)
wx.showToast({ title: '保存失败', icon: 'none' })
}
this.setData({ sortedNotes: [newNote, ...this.data.sortedNotes] })
},
/** CHANGE 2026-03-27 | AI 评分轮询:每 4 秒查一次,最多 5 次20 秒) */
_pollAiScore(noteId: string, attempt: number) {
if (attempt >= 5) return
setTimeout(async () => {
try {
const token = wx.getStorageSync('token')
const detail = this.data.detail
if (!detail || !token) return
// 查 task_detail 接口获取最新备注列表
const taskDetail = await fetchTaskDetail(String(detail.id))
if (!taskDetail?.notes) { this._pollAiScore(noteId, attempt + 1); return }
const found = taskDetail.notes.find((n: any) => String(n.id) === noteId)
if ((found as any)?.aiScore || found?.score) {
// 更新 sortedNotes 中对应备注的 score
const updated = this.data.sortedNotes.map(n =>
String(n.id) === noteId ? { ...n, score: (found as any).aiScore || found!.score } : n
)
this.setData({ sortedNotes: updated })
} else {
this._pollAiScore(noteId, attempt + 1)
}
} catch {
this._pollAiScore(noteId, attempt + 1)
}
}, 4000)
},
/** 备注弹窗取消 */
@@ -279,11 +365,18 @@ Page({
title: '删除备注',
content: '确定要删除这条备注吗?删除后无法恢复。',
confirmColor: '#e34d59',
success: (res) => {
success: async (res) => {
if (res.confirm) {
const notes = this.data.sortedNotes.filter((n) => n.id !== noteId)
this.setData({ sortedNotes: notes })
wx.showToast({ title: '已删除', icon: 'success' })
try {
// CHANGE 2026-03-27 | 联调:调真实后端 API 删除备注
await deleteNote(Number(noteId))
const notes = this.data.sortedNotes.filter((n) => String(n.id) !== noteId)
this.setData({ sortedNotes: notes })
wx.showToast({ title: '已删除', icon: 'success' })
} catch (err) {
console.error('[task-detail] deleteNote failed:', err)
wx.showToast({ title: '删除失败', icon: 'none' })
}
}
},
})
@@ -369,13 +462,13 @@ Page({
// 根据任务类型设置 Banner 背景
let bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
if (type === 'high_priority') {
if (type === 'high_priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-red-aurora.svg'
} else if (type === 'priority_recall') {
bannerBgSvg = '/assets/images/banner-bg-orange-aurora.svg'
} else if (type === 'relationship') {
} else if (type === 'relationship_building') {
bannerBgSvg = '/assets/images/banner-bg-pink-aurora.svg'
} else if (type === 'callback') {
} else if (type === 'follow_up_visit') {
bannerBgSvg = '/assets/images/banner-bg-teal-aurora.svg'
}

View File

@@ -1,15 +1,7 @@
<wxs src="../../utils/format.wxs" module="fmt" />
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="info-circle" size="120rpx" color="#c5c5c5" />
<text class="empty-text">未找到任务信息</text>
</view>
@@ -39,11 +31,11 @@
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{detail.customerName}}</text>
<text class="customer-name">{{fmt.safe(detail.customerName)}}</text>
<text class="task-type-tag">{{detail.taskTypeLabel || '高优先召回'}}</text>
</view>
<view class="sub-info">
<text class="phone">{{phoneVisible ? '13812345678' : '138****5678'}}</text>
<text class="phone">{{phoneVisible ? fmt.safe(detail.customerPhone) : fmt.maskPhone(detail.customerPhone)}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
@@ -74,7 +66,7 @@
<text class="rel-score" style="color: {{relationColor}};">{{fmt.toFixed(detail.heartScore, 1)}}</text>
</view>
<view class="card-desc-wrap">
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
<text class="card-desc">{{fmt.safe(detail.aiAnalysis.summary)}}</text>
</view>
</view>
@@ -90,7 +82,7 @@
</view>
<view class="suggestion-body">
<view class="suggestion-intro-wrap">
<text class="suggestion-intro">该客户已有 15 天未到店,存在流失风险。建议通过微信联系:</text>
<text class="suggestion-intro">{{detail.aiAnalysis.summary || '暂无AI分析'}}</text>
</view>
<view class="suggestion-item" wx:for="{{detail.aiAnalysis.suggestions}}" wx:key="index">
<text class="suggestion-item-text">• {{item}}</text>
@@ -136,6 +128,20 @@
</view>
</view>
<!-- T4.3 行动建议AI 生成) -->
<view class="card" wx:if="{{detail.actionSuggestions && detail.actionSuggestions.length > 0}}">
<view class="card-header">
<text class="section-title title-green">行动建议</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<view class="action-suggestions-list">
<view class="action-suggestion-card" wx:for="{{detail.actionSuggestions}}" wx:key="index">
<text class="action-suggestion-index">{{index + 1}}</text>
<text class="action-suggestion-text">{{item}}</text>
</view>
</view>
</view>
<!-- 我给TA的备注 -->
<view class="card">
<view class="card-header">
@@ -146,17 +152,18 @@
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<view class="note-date-wrap">
<text class="note-date">{{item.timeLabel || item.createdAt}}</text>
<text class="note-date">{{fmt.safe(item.timeLabel || item.createdAt)}}</text>
</view>
<view class="note-top-right">
<star-rating score="{{item.score || 3}}" size="28rpx" readonly="{{true}}" />
<!-- CHANGE 2026-03-27 | 备注联调:无评分时不显示星星 -->
<star-rating wx:if="{{item.score}}" score="{{item.score}}" size="28rpx" readonly="{{true}}" />
<view class="note-delete-btn" catchtap="onDeleteNote" data-id="{{item.id}}" hover-class="note-delete-btn--hover">
<t-icon name="delete" size="32rpx" color="#a6a6a6" />
</view>
</view>
</view>
<view class="note-content-wrap">
<text class="note-content">{{item.content}}</text>
<text class="note-content">{{fmt.safe(item.content)}}</text>
</view>
</view>
</block>
@@ -176,7 +183,7 @@
<view class="svc-summary">
<view class="svc-summary-item svc-summary-blue">
<view class="svc-summary-value-row">
<text class="svc-summary-value svc-val-blue">{{fmt.hours(serviceSummary.totalHours)}}</text>
<text class="svc-summary-value svc-val-blue">{{fmt.hoursH(serviceSummary.totalHours)}}</text>
</view>
<text class="svc-summary-label">总时长</text>
</view>

View File

@@ -457,6 +457,43 @@ page {
gap: 18rpx;
}
/* Action suggestions (T4.3) */
.action-suggestions-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-suggestion-card {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 24rpx;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border: 2rpx solid #bbf7d0;
border-radius: 18rpx;
}
.action-suggestion-index {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #00a870;
color: #fff;
font-size: 24rpx;
line-height: 44rpx;
text-align: center;
font-weight: 600;
flex-shrink: 0;
}
.action-suggestion-text {
flex: 1;
font-size: 26rpx;
line-height: 40rpx;
color: #242424;
}
/* Notes */
.note-count {
font-size: 22rpx;
@@ -683,7 +720,9 @@ page {
color: #777777;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
max-width: 380rpx;
}

View File

@@ -6,11 +6,22 @@
| 2026-03-13 | banner 错位重做 | 添加 userName/userRole/storeName 到 page data移除 banner 组件引用 |
| 2026-03-13 | 任务类型标签做成 SVG | 添加 tagSvgMap 映射 taskType→SVG 路径,供 WXML image 组件使用 |
| 2026-03-13 | 5项修复+精确还原 | 移除 tagSvgMap标签恢复 CSS 渐变实现) |
| 2026-03-21 | P13 前端打磨审查 | G1:头像从fetchMe获取+默认占位图; G2:isCurrentMonth预估判断; T1.2:incomeTrend用formatTrendValue+↑↓箭头; T1.3:abandonReason从task对象获取不硬编码空字符串; T1.4:盖戳动画始终播放移除tierCompleted死代码; B:金额用fmt.money/课时用fmt.hours/计数用fmt.count/空值用fmt.safe/天数用fmt.days; perfData数值字段改为number类型 |
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
| 2026-03-23 | banner 数据为空修复 | loadData 中将后端 performance 字段完整映射到 perfDatatotalHours/basicHours/bonusHours/currentTier/nextTierHours/filledPct/ticks/bonusMoney/incomeMonth/prevMonth 等);移除未使用的 formatTrendValue import |
| 2026-03-24 | 盖戳+进度条动画等数据加载完成 | onReady 不再提前触发动画onLoad 用 Promise.all 等 _loadUserInfo+loadData 都完成后再启动盖戳和进度条动画;下拉刷新同理 |
| 2026-03-24 | 助教标签显示等级 | _loadUserInfo 中助教角色显示"X级助教"(如"初级助教"),其他角色显示角色名;数据来自后端 coach_level 字段 |
| 2026-03-24 | 角色标签显示修复 | role 是英文 codecoach/staff/…),之前 `role==='助教'` 永远不匹配;改为 `role==='coach'` + ROLE_LABELS 映射中文 |
| 2026-03-27 | 卡片新增近60天数据+days(0)修复 | enrichTask 新增 recent60dHours/recent60dIncome 字段映射card-row-2 追加"近60天 Xh | ¥X"展示 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import type { Task } from '../../utils/mock-data'
import { fetchTasks } from '../../services/api'
import { fetchTasks, fetchMe, pinTask, unpinTask, abandonTask, restoreTask, createNote } from '../../services/api'
import { formatMoney } from '../../utils/money'
import { formatDeadline } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
/** CHANGE 2026-03-14 | 所有任务类型统一跳转到 task-detail由详情页根据 taskId 动态展示内容 */
const DETAIL_ROUTE = '/pages/task-detail/task-detail'
@@ -76,6 +87,11 @@ interface EnrichedTask extends Task {
isAbandoned: boolean
deadlineLabel: string
deadlineStyle: 'normal' | 'warning' | 'danger' | 'muted'
expectedDays: number | null
idealIntervalDays: number | null
// CHANGE 2026-03-27 | 近60天服务汇总口径同 task-detail
recent60dHours: number
recent60dIncome: number
}
/** 刻度项 */
@@ -109,71 +125,68 @@ interface PerfData {
sparkDurMs: number
shineRunning: boolean
sparkRunning: boolean
basicHours: string
bonusHours: string
totalHours: string
basicHours: number
bonusHours: number
totalHours: number
tierCompleted: boolean
bonusMoney: string
bonusMoney: number
incomeMonth: string
prevMonth: string
incomeFormatted: string
incomeTrend: string
incomeTrendDir: 'up' | 'down'
incomeTrendValue: number | null // 趋势纯数字(从 incomeTrend 解析,用于千分位格式化)
}
/** Mock: 为任务附加扩展字段 */
function enrichTask(task: Task): EnrichedTask {
const daysSeed = (task.id.charCodeAt(task.id.length - 1) % 15) + 1
const balanceSeedNum = ((task.id.charCodeAt(task.id.length - 1) * 137) % 5000) + 200
const suggestions = [
'建议推荐斯诺克进阶课程,提升客户粘性',
'客户近期消费下降,建议电话关怀了解原因',
'适合推荐周末球友赛活动,增强社交体验',
'高价值客户建议维护关系并推荐VIP权益',
'新客户首次体验后未续费,建议跟进意向',
]
const suggIdx = task.id.charCodeAt(task.id.length - 1) % suggestions.length
const balance = (task as any).balance
// CHANGE 2026-03-27 | 储值展示对齐 task-detail模糊范围无/少/一般/多/非常多)
const balanceLabel = formatStorageLevel(balance)
const lastVisitDays = (task as any).lastVisitDays ?? (task as any).last_visit_days ?? 0
return {
...task,
lastVisitDays: daysSeed,
balanceLabel: formatMoney(balanceSeedNum),
aiSuggestion: suggestions[suggIdx],
lastVisitDays,
balanceLabel,
aiSuggestion: (task as any).aiSuggestion ?? (task as any).ai_suggestion ?? '',
isAbandoned: task.status === 'abandoned',
abandonReason: task.status === 'abandoned' ? '客户已转至其他门店' : undefined,
abandonReason: task.status === 'abandoned' ? (task as any).abandonReason ?? (task as any).abandon_reason : undefined,
deadlineLabel: formatDeadline((task as any).deadline).text,
deadlineStyle: formatDeadline((task as any).deadline).style,
// CHANGE 2026-03-24 | 预期天数标签:来自后端 expected_daysideal_interval_days - last_visit_days
expectedDays: (task as any).expectedDays ?? (task as any).expected_days ?? null,
idealIntervalDays: (task as any).idealIntervalDays ?? (task as any).ideal_interval_days ?? null,
// CHANGE 2026-03-27 | 近60天服务汇总传原始数字WXML 用 WXS 格式化
recent60dHours: (task as any).recent60dHours ?? (task as any).recent60d_hours ?? 0,
recent60dIncome: (task as any).recent60dIncome ?? (task as any).recent60d_income ?? 0,
}
}
/** Mock: 构造业绩进度卡片数据 — 对齐 H5 原型数值 */
function buildPerfData(): PerfData {
const total = 87.5
const filledPct = Math.min(100, parseFloat(((total / 220) * 100).toFixed(1)))
// Mock 档位节点:实际由接口返回,格式为 number[]
const tierNodes = [0, 100, 130, 160, 190, 220]
return {
nextTierHours: 100,
remainHours: 12.5,
currentTier: 1,
tierProgress: 58,
filledPct,
clampedSparkPct: Math.max(0, Math.min(100, filledPct)),
ticks: buildTicks(tierNodes, 220),
shineDurMs: calcShineDur(filledPct),
nextTierHours: 0,
remainHours: 0,
currentTier: 0,
tierProgress: 0,
filledPct: 0,
clampedSparkPct: 0,
ticks: [],
shineDurMs: 1000,
sparkDurMs: SPARK_DUR_MS,
shineRunning: false,
sparkRunning: false,
basicHours: '77.5',
bonusHours: '12',
totalHours: String(total),
tierCompleted: true,
bonusMoney: '800',
incomeMonth: '2月',
prevMonth: '1月',
incomeFormatted: '6,206',
incomeTrend: '↓368',
incomeTrendDir: 'down',
basicHours: 0,
bonusHours: 0,
totalHours: 0,
tierCompleted: false,
bonusMoney: 0,
incomeMonth: '',
prevMonth: '',
incomeFormatted: '0',
incomeTrend: '',
incomeTrendDir: 'up',
}
}
@@ -187,10 +200,11 @@ Page({
bannerMetrics: [] as Array<{ label: string; value: string }>,
bannerTitle: '',
/* CHANGE 2026-03-13 | banner 重做:添加用户信息字段,对齐 H5 原型 */
userName: '小燕',
userRole: '助教',
storeName: '广州朗朗桌球',
avatarUrl: '/assets/images/avatar-coach.png', // MOCK 头像地址
userName: '',
userRole: '',
storeName: '',
avatarUrl: '',
isCurrentMonth: true,
/* CHANGE 2026-03-13 | tagSvgMap 已移除,标签恢复 CSS 渐变实现 */
perfData: {
nextTierHours: 0,
@@ -204,16 +218,17 @@ Page({
sparkDurMs: 1400,
shineRunning: false,
sparkRunning: false,
basicHours: '0',
bonusHours: '0',
totalHours: '0',
basicHours: 0,
bonusHours: 0,
totalHours: 0,
tierCompleted: false,
bonusMoney: '0',
bonusMoney: 0,
incomeMonth: '',
prevMonth: '',
incomeFormatted: '0',
incomeTrend: '',
incomeTrendDir: 'up' as 'up' | 'down',
incomeTrendValue: null as number | null,
} as PerfData,
stampAnimated: false,
hasMore: true,
@@ -245,18 +260,17 @@ Page({
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
const aiColor = colors[Math.floor(Math.random() * colors.length)]
this.setData({ aiColor })
this.loadData()
// CHANGE 2026-03-24 | 盖戳+进度条动画等所有数据加载完成后再启动
this._loadAllThenAnimate()
},
onReady() {
// 页面渲染完成后启动动画循环
setTimeout(() => {
this.setData({ stampAnimated: true })
this._startAnimLoop()
}, 100)
// 动画由 _loadAllThenAnimate 统一控制onReady 不再提前触发
},
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/task-list/task-list')
// 每次显示页面时重新随机 AI 配色,并恢复动画循环
const colors = ['red', 'orange', 'yellow', 'blue', 'indigo', 'purple']
const aiColor = colors[Math.floor(Math.random() * colors.length)]
@@ -345,8 +359,18 @@ Page({
},
onPullDownRefresh() {
this.loadData(() => {
// CHANGE 2026-03-24 | 下拉刷新:重新加载所有数据后再启动动画
this._stopAnimLoop()
this.setData({ stampAnimated: false })
Promise.all([
this._loadUserInfo(),
this.loadData(),
]).then(() => {
wx.stopPullDownRefresh()
setTimeout(() => {
this.setData({ stampAnimated: true })
this._startAnimLoop()
}, 300)
})
},
@@ -360,21 +384,96 @@ Page({
this.setData({ pageState: 'loading', stampAnimated: false })
try {
const res = await fetchTasks()
const allTasks: Task[] = res.tasks || []
// CHANGE 2026-03-24 | 一次性加载全部任务pageSize=200避免分页导致部分类型任务不显示
// CHANGE 2026-03-27 | 并行请求 active + abandoned 任务,放弃任务需要单独请求
const [res, abandonedRes] = await Promise.all([
fetchTasks({ pageSize: 200 }),
fetchTasks({ status: 'abandoned', pageSize: 200 }),
])
const allTasks: Task[] = [
...(res.tasks || []),
...(abandonedRes.tasks || []),
]
const enriched = allTasks.map(enrichTask)
const pinnedTasks = enriched.filter((t) => t.isPinned && !t.isAbandoned)
const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && t.status === 'pending')
// CHANGE 2026-03-24 | 后端返回 status="active",前端原先只匹配 "pending",导致全部被过滤
const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && (t.status === 'pending' || t.status === 'active'))
const abandonedTasks = enriched.filter((t) => t.isAbandoned)
const totalCount = pinnedTasks.length + normalTasks.length + abandonedTasks.length
// CHANGE 2026-03-23 | 修复 banner 数据为空:将后端 performance 字段映射到 perfData
const perfData = buildPerfData()
const perf = res.performance
const bannerTitle = perf ? `${perf.currentTier}` : ''
const perf = res.performance as any
const bannerTitle = perf ? `${perf.currentTier ?? perf.current_tier ?? ''}` : ''
const bannerMetrics: Array<{ label: string; value: string }> = []
if (perf) {
// 后端返回 snake_caseCamelModel 可能已转 camelCase兼容两种命名
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
const monthLabel = perf.monthLabel ?? perf.month_label ?? ''
const prevMonth = perf.prevMonth ?? perf.prev_month ?? ''
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)
perfData.totalHours = totalHours
perfData.basicHours = basicHours
perfData.bonusHours = bonusHours
perfData.currentTier = currentTier
perfData.nextTierHours = nextTierHours
perfData.remainHours = remainHours
perfData.tierCompleted = tierCompleted
perfData.bonusMoney = bonusMoney
perfData.incomeMonth = monthLabel
perfData.prevMonth = prevMonth
perfData.incomeFormatted = formatMoney(totalIncome)
perfData.incomeTrend = incomeTrend
perfData.incomeTrendDir = incomeTrendDir === 'down' ? 'down' : 'up'
// 从 "↑7373" / "↓368" 中提取纯数字,用于千分位格式化
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
}
// G2: 当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const incomeMonth = perfData.incomeMonth
let dataYear = nowYear
let dataMonth = nowMonth
if (incomeMonth) {
const parts = incomeMonth.match(/(\d+)/)
if (parts) dataMonth = parseInt(parts[1], 10)
}
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"(全小程序统一)
const isCurrentMonth = dataYear === nowYear && dataMonth === nowMonth && now.getDate() <= 5
this.setData({
pageState: totalCount > 0 ? 'normal' : 'empty',
pinnedTasks,
@@ -384,15 +483,13 @@ Page({
bannerTitle,
bannerMetrics,
perfData,
isCurrentMonth,
hasMore: res.hasMore ?? false,
})
if (perfData.tierCompleted) {
setTimeout(() => {
this.setData({ stampAnimated: true })
}, 300)
}
} catch {
// 盖戳+进度条动画由 _loadAllThenAnimate 统一触发,此处不再单独启动
} catch (err) {
console.error('[task-list] loadData 异常:', err)
this.setData({ pageState: 'error' })
}
@@ -422,6 +519,14 @@ Page({
})
},
/** 查看我的所有客户 → 跳转业绩页并滚动到底部(常客列表) */
onViewAllCustomers() {
wx.navigateTo({
url: '/pages/performance/performance?scrollToBottom=1',
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
},
onTaskLongPress(e: WechatMiniprogram.TouchEvent) {
this._longPressed = true
const { group, index } = e.currentTarget.dataset
@@ -460,15 +565,25 @@ Page({
noop() {},
onCtxPin() {
// CHANGE 2026-03-27 | 联调:置顶/取消置顶调用后端 API
async onCtxPin() {
const target = this.data.contextMenuTarget
const isPinned = !target.isPinned
wx.showToast({
title: isPinned ? `已置顶「${target.customerName}` : `已取消置顶「${target.customerName}`,
icon: 'none',
})
this.setData({ contextMenuVisible: false })
this._updateTaskPin(target.id, isPinned)
try {
if (isPinned) {
await pinTask(target.id)
} else {
await unpinTask(target.id)
}
wx.showToast({
title: isPinned ? `已置顶「${target.customerName}` : `已取消置顶「${target.customerName}`,
icon: 'none',
})
this._updateTaskPin(target.id, isPinned)
} catch (_e) {
wx.showToast({ title: '操作失败,请重试', icon: 'none' })
}
},
onCtxNote() {
@@ -499,13 +614,18 @@ Page({
})
},
/** 放弃弹窗 - 确认 */
onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
// CHANGE 2026-03-27 | 联调:放弃任务调用后端 API
async onAbandonConfirm(e: WechatMiniprogram.CustomEvent<{ reason: string }>) {
const { reason } = e.detail
const target = this.data.abandonTarget
wx.showToast({ title: `已放弃「${target.customerName}`, icon: 'none' })
this.setData({ abandonModalVisible: false })
this._updateTaskAbandon(target.id, reason)
try {
await abandonTask(target.id, reason)
wx.showToast({ title: `已放弃「${target.customerName}`, icon: 'none' })
this._updateTaskAbandon(target.id, reason)
} catch (_e) {
wx.showToast({ title: '操作失败,请重试', icon: 'none' })
}
},
/** 放弃弹窗 - 取消 */
@@ -513,30 +633,104 @@ Page({
this.setData({ abandonModalVisible: false })
},
/** 长按菜单 - 取消放弃(已放弃任务) */
onCtxCancelAbandon() {
// CHANGE 2026-03-27 | 联调:取消放弃调用后端 API
async onCtxCancelAbandon() {
const target = this.data.contextMenuTarget
this.setData({ contextMenuVisible: false })
wx.showLoading({ title: '处理中...' })
setTimeout(() => {
try {
await restoreTask(target.id)
wx.hideLoading()
wx.showToast({ title: `已取消放弃「${target.customerName}`, icon: 'success' })
this._updateTaskCancelAbandon(target.id)
}, 500)
} catch (_e) {
wx.hideLoading()
wx.showToast({ title: '操作失败,请重试', icon: 'none' })
}
},
onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string }>) {
const { score, content } = e.detail
// CHANGE 2026-03-27 | 联调:备注调用后端 API
async onNoteConfirm(e: WechatMiniprogram.CustomEvent<{ score: number; content: string; serviceScore?: number; returnScore?: number }>) {
const { score, content, serviceScore, returnScore } = e.detail
const target = this.data.noteTarget
wx.showToast({ title: `已保存「${target.customerName}」备注`, icon: 'success' })
this.setData({ noteModalVisible: false })
console.log('[note]', target.id, score, content)
const memberId = (target as any).memberId ?? (target as any).member_id
const taskId = Number(target.id)
try {
await createNote({
targetId: memberId ? Number(memberId) : taskId,
content,
taskId,
score: score || undefined,
ratingServiceWillingness: serviceScore || undefined,
ratingRevisitLikelihood: returnScore || undefined,
})
wx.showToast({ title: `已保存「${target.customerName}」备注`, icon: 'success' })
} catch (_e) {
wx.showToast({ title: '备注保存失败,请重试', icon: 'none' })
}
},
onNoteCancel() {
this.setData({ noteModalVisible: false })
},
/** CHANGE 2026-03-24 | 所有数据加载完成后统一触发盖戳+进度条动画 */
async _loadAllThenAnimate() {
// 并行加载用户信息和任务数据,全部完成后再启动动画
await Promise.all([
this._loadUserInfo(),
this.loadData(),
])
// 所有数据渲染完成后启动动画
setTimeout(() => {
this.setData({ stampAnimated: true })
this._startAnimLoop()
}, 300)
},
/** G1: 从全局用户信息或 fetchMe 获取头像等字段 */
/** CHANGE 2026-03-24 | 助教标签显示"X级助教",其他角色显示角色名 */
async _loadUserInfo() {
const app = getApp<IAppOption>()
const authUser = app.globalData.authUser
if (authUser?.nickname) {
// CHANGE 2026-03-27 | 头像avatarUrl 是相对路径,需拼接完整 URL
const userId = (authUser as any).userId
const hasAvatar = !!(authUser as any).avatarUrl
this.setData({
userName: authUser.nickname,
avatarUrl: hasAvatar && userId ? `${API_BASE}/api/xcx/avatar/${userId}` : '',
})
}
// 兜底:调用 fetchMe 获取完整用户信息
try {
const me = await fetchMe()
if (me) {
const role = (me as any).role || ''
const coachLevel = (me as any).coach_level || (me as any).coachLevel || ''
// CHANGE 2026-03-24 | role 是英文 codecoach/staff/head_coach/manager需映射为中文
const ROLE_LABELS: Record<string, string> = { coach: '助教', staff: '员工', head_coach: '主教练', manager: '店长' }
const roleLabel = ROLE_LABELS[role] || role
// 助教显示"X级助教"(如"初级助教"),其他角色显示中文角色名
const displayTag = (role === 'coach' && coachLevel) ? `${coachLevel}助教` : roleLabel
// CHANGE 2026-03-27 | 头像:读 avatarUrlCamelModel 转换后的字段名),拼接完整 URL
const userId = (me as any).userId
const hasAvatar = !!(me as any).avatarUrl
this.setData({
userName: (me as any).nickname || (me as any).name || this.data.userName,
userRole: displayTag,
storeName: (me as any).storeName || (me as any).store_name || '',
avatarUrl: hasAvatar && userId
? `${API_BASE}/api/xcx/avatar/${userId}`
: this.data.avatarUrl,
})
}
} catch {
// 网络失败时保留 globalData 中的信息
}
},
_updateTaskPin(taskId: string, isPinned: boolean) {
const allTasks = [
...this.data.pinnedTasks,
@@ -646,9 +840,9 @@ Page({
const filledPct = Math.min(100, Math.round((total / 220) * 1000) / 10)
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
this.setData({
'perfData.totalHours': String(total),
'perfData.basicHours': String(basic),
'perfData.bonusHours': String(bonus),
'perfData.totalHours': total,
'perfData.basicHours': basic,
'perfData.bonusHours': bonus,
'perfData.currentTier': currentTier,
'perfData.tierProgress': tierProgress,
'perfData.filledPct': filledPct,

View File

@@ -6,7 +6,9 @@
| 2026-03-13 | banner 错位重做 | 移除通用 banner 组件,页面内实现完整 banner |
| 2026-03-13 | 背景+纹理合并 SVG | 渐变+纹理+光晕合并为 SVG |
| 2026-03-13 | 5项修复+精确还原 | 恢复纹理CSS层、盖戳改回CSS实现、头像引用修复、abandoned标签恢复CSS灰化、全量line-height校准 |
| 2026-03-27 | 卡片新增近60天数据 | card-row-2 追加"近60天 Xh | ¥X"展示(置顶/一般/已放弃三处) |
-->
<wxs src="../../utils/format.wxs" module="fmt" />
<view class="page-task-list">
<!-- ====== 顶部 Banner 区域 — 对齐 H5 .banner-bg.theme-blue.texture-aurora ====== -->
@@ -20,31 +22,32 @@
<view class="user-info-section">
<view class="user-info-row">
<!-- 头像 — H5: w-14 h-14 rounded-2xl bg-white/20 -->
<!-- G1: 从全局用户信息读取头像,无头像时显示默认占位图 -->
<view class="avatar-wrap">
<image src="/assets/images/avatar-coach.png" mode="aspectFill" class="avatar-img" />
<image src="{{avatarUrl || '/assets/images/avatar-coach.png'}}" mode="aspectFill" class="avatar-img" />
</view>
<!-- 姓名+标签+门店 -->
<view class="user-detail">
<view class="user-name-row">
<text class="user-name">{{userName}}</text>
<text class="user-role-tag">{{userRole}}</text>
<text class="user-name">{{fmt.safe(userName)}}</text>
<text class="user-role-tag">{{fmt.safe(userRole)}}</text>
</view>
<view class="user-store-row">
<text class="user-store">{{storeName}}</text>
<text class="user-store">{{fmt.safe(storeName)}}</text>
</view>
</view>
</view>
</view>
<!-- 业绩进度卡片 — H5: .mx-4 > .bg-white/15.backdrop-blur-md.rounded-2xl -->
<view class="perf-card">
<view class="perf-card" bindtap="onPerformanceTap" hover-class="perf-card--hover" hover-stay-time="100">
<!-- L1: 跳档提示 -->
<view class="perf-l1">
<view class="perf-l1-left">
<text class="perf-label">距离{{perfData.nextTierHours}}小时仅剩</text>
<text class="perf-accent">{{perfData.remainHours}}小时</text>
<text class="perf-label">距离{{fmt.hours(perfData.nextTierHours)}}仅剩</text>
<text class="perf-accent">{{fmt.hours(perfData.remainHours)}}</text>
</view>
<view class="perf-l1-right" bindtap="onPerformanceTap">
<view class="perf-l1-right">
<text class="perf-secondary">查看详情</text>
<t-icon name="chevron-right" size="22rpx" color="rgba(255,255,255,0.7)" />
</view>
@@ -69,40 +72,40 @@
<view class="perf-l3-left">
<view class="perf-hours-wrap">
<view class="perf-hours-row">
<text class="hours-green">{{perfData.basicHours}}</text>
<text class="hours-green">{{fmt.hoursNum(perfData.basicHours)}}</text>
<text class="hours-sep">|</text>
<text class="hours-yellow">{{perfData.bonusHours}}</text>
<text class="hours-yellow">{{fmt.hoursNum(perfData.bonusHours)}}</text>
<text class="hours-sep">|</text>
<text class="hours-white">{{perfData.totalHours}}</text>
<text class="hours-white">{{fmt.hoursNum(perfData.totalHours)}}</text>
</view>
<view class="hours-label-row">
<text class="hours-label">基础课 | 激励课 | 全部</text>
</view>
<!-- 红戳徽章 — SVG 实现 -->
<!-- T1.4: 盖戳徽章始终渲染,动画始终播放 -->
<image
class="stamp-badge {{stampAnimated ? 'stamp-animate' : ''}}"
wx:if="{{perfData.tierCompleted}}"
src="/assets/images/stamp-badge.svg"
/>
</view>
</view>
<view class="perf-l3-right">
<view class="bonus-wrap">
<text class="bonus-amount">{{perfData.bonusMoney}}</text>
<text class="bonus-amount">{{fmt.money(perfData.bonusMoney)}}</text>
<text class="bonus-unit">元</text>
</view>
<view class="bonus-label-row">
<text class="bonus-label">达{{perfData.nextTierHours}}h即得</text>
<text class="bonus-label">达{{fmt.hours(perfData.nextTierHours)}}即得</text>
</view>
</view>
</view>
<!-- L4: 预计收入 -->
<!-- L4: 预计收入 — G2: 当月显示"预估",历史月份不显示 -->
<view class="perf-l4">
<text class="perf-l4-label">{{perfData.incomeMonth}}预计收入 | 比{{perfData.prevMonth}}同期</text>
<view class="perf-l4-right" bindtap="onPerformanceTap">
<text class="income-value">¥{{perfData.incomeFormatted}}</text>
<text class="income-trend {{perfData.incomeTrendDir === 'down' ? 'trend-down' : ''}}">{{perfData.incomeTrend}}</text>
<text class="perf-l4-label">{{fmt.safe(perfData.incomeMonth)}}{{isCurrentMonth ? '预估收入' : '收入'}} | 比{{fmt.safe(perfData.prevMonth)}}同期</text>
<view class="perf-l4-right">
<text class="income-value">{{fmt.safe(perfData.incomeFormatted)}}</text>
<text wx:if="{{isCurrentMonth}}" class="income-estimate-tag">预估</text>
<text class="income-trend {{perfData.incomeTrendDir === 'down' ? 'trend-down' : ''}}">{{perfData.incomeTrendDir === 'up' ? '↑' : perfData.incomeTrendDir === 'down' ? '↓' : ''}} {{fmt.thousands(perfData.incomeTrendValue)}}</text>
<t-icon name="chevron-right" size="28rpx" color="rgba(255,255,255,0.7)" />
</view>
</view>
@@ -136,14 +139,14 @@
<!-- 标题行 -->
<view class="section-header">
<text class="section-title">今日 客户维护</text>
<text class="section-count">共 {{taskCount}}</text>
<text class="section-count">共 {{fmt.count(taskCount, '项')}}</text>
</view>
<!-- 📌 置顶区域 -->
<view class="task-group" wx:if="{{pinnedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--pinned">📌 置顶</text>
<text class="group-count">{{pinnedTasks.length}}</text>
<text class="group-count">{{fmt.count(pinnedTasks.length, '项')}}</text>
</view>
<view class="task-card-list">
<view
@@ -157,22 +160,23 @@
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
<text class="tag-text">{{fmt.safe(item.taskTypeLabel)}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<text class="customer-name">{{fmt.safe(item.customerName)}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
<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">最近到店:{{item.lastVisitDays}}前 · 余额:{{item.balanceLabel}}</text>
<text class="visit-text">到店:{{fmt.days(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>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
<text class="ai-suggestion-text">{{fmt.safe(item.aiSuggestion)}}</text>
</view>
</view>
<view class="card-arrow">
@@ -186,7 +190,7 @@
<view class="task-group" wx:if="{{normalTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--normal">正常任务</text>
<text class="group-count">{{normalTasks.length}}</text>
<text class="group-count">{{fmt.count(normalTasks.length, '项')}}</text>
</view>
<view class="task-card-list">
<view
@@ -200,22 +204,23 @@
<view class="card-body">
<view class="card-row-1">
<view class="task-type-tag task-type-tag--{{item.taskType}}">
<text class="tag-text">{{item.taskTypeLabel}}</text>
<text class="tag-text">{{fmt.safe(item.taskTypeLabel)}}</text>
</view>
<text class="customer-name">{{item.customerName}}</text>
<text class="customer-name">{{fmt.safe(item.customerName)}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
<text class="note-indicator" wx:if="{{item.hasNote}}">📝</text>
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
<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">最近到店:{{item.lastVisitDays}}前 · 余额:{{item.balanceLabel}}</text>
<text class="visit-text">到店:{{fmt.days(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>
</view>
<view class="card-row-3">
<ai-inline-icon color="{{aiColor}}" />
<text class="ai-suggestion-text">{{item.aiSuggestion}}</text>
<text class="ai-suggestion-text">{{fmt.safe(item.aiSuggestion)}}</text>
</view>
</view>
<view class="card-arrow">
@@ -229,7 +234,7 @@
<view class="task-group" wx:if="{{abandonedTasks.length > 0}}">
<view class="group-label-row">
<text class="group-label group-label--abandoned">已放弃</text>
<text class="group-count">{{abandonedTasks.length}}</text>
<text class="group-count">{{fmt.count(abandonedTasks.length, '项')}}</text>
</view>
<view class="task-card-list">
<view
@@ -244,16 +249,16 @@
<view class="card-row-1">
<!-- CHANGE 2026-03-13 | abandoned 标签保留原始类型标签,通过 CSS 灰化(对齐 H5 行为) -->
<view class="task-type-tag task-type-tag--{{item.taskType}} task-type-tag--abandoned">
<text class="tag-text">{{item.taskTypeLabel}}</text>
<text class="tag-text">{{fmt.safe(item.taskTypeLabel)}}</text>
</view>
<text class="customer-name customer-name--abandoned">{{item.customerName}}</text>
<text class="customer-name customer-name--abandoned">{{fmt.safe(item.customerName)}}</text>
<heart-icon score="{{item.heartScore}}" size="small" />
</view>
<view class="card-row-2">
<text class="visit-text visit-text--abandoned">最近到店:{{item.lastVisitDays}}前 · 余额:{{item.balanceLabel}}</text>
<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>
</view>
<view class="card-row-abandon" wx:if="{{item.abandonReason}}">
<text class="abandon-reason">放弃原因:{{item.abandonReason}}</text>
<text class="abandon-reason">放弃原因:{{fmt.safe(item.abandonReason)}}</text>
</view>
</view>
<view class="card-arrow">
@@ -267,6 +272,11 @@
<view class="load-more" wx:if="{{!hasMore}}">
<text class="load-more-text">没有更多了</text>
</view>
<!-- 查看所有客户入口 -->
<view class="view-all-customers-btn" hover-class="view-all-customers-btn--hover" bindtap="onViewAllCustomers">
<text class="view-all-customers-text">🔍 查看我的所有客户</text>
</view>
</view>
<!-- ====== P3: 长按上下文菜单 ====== -->

View File

@@ -112,12 +112,13 @@
* px-2=8px→15rpx, py-0.5=2px→4rpx, text-xs→22rpx/29rpx */
.user-role-tag {
font-size: 22rpx;
padding: 0rpx 20rpx 0 20rpx;
line-height: 29rpx;
padding: 4rpx 15rpx;
background: rgba(255,255,255,0.2);
border-radius: 9999rpx;
color: #ffffff;
height: 35rpx;
line-height: 35rpx;
display: inline-block;
vertical-align: middle;
}
/* H5: text-white/70 text-sm → 26rpx/36rpx */
@@ -851,6 +852,16 @@
color: rgba(255,255,255,0.5);
}
/* G2: 预估标签 — 当月数据旁显示 */
.income-estimate-tag {
font-size: 20rpx;
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.18);
padding: 2rpx 12rpx;
border-radius: 6rpx;
line-height: 28rpx;
}
/* ============================================
* Loading / Empty / Error 状态
* ============================================ */
@@ -1010,9 +1021,10 @@
}
/* 卡片边框颜色 — 严格对齐 VI-DESIGN-SYSTEM.md 1.1 节 */
.task-card--high_priority { border-left-color: var(--task-high-priority-border); }
/* CHANGE 2026-03-24 | 类名对齐数据库 task_typehigh_priority_recall / relationship_building */
.task-card--high_priority_recall { border-left-color: var(--task-high-priority-border); }
.task-card--priority_recall { border-left-color: var(--task-priority-recall-border); }
.task-card--relationship { border-left-color: var(--task-relationship-border); }
.task-card--relationship_building { border-left-color: var(--task-relationship-border); }
.task-card--callback { border-left-color: var(--task-callback-border); }
/* 置顶卡片微亮边框 — 严格对齐 VI-DESIGN-SYSTEM.md 4.1 节 */
@@ -1047,6 +1059,22 @@
align-self: stretch;
}
/* 预期天数标签 — 行内药丸,与 overdue-badge 同级同风格 */
.expected-tag {
margin-left: auto;
flex-shrink: 0;
font-size: 22rpx;
font-weight: 600;
border-radius: 8rpx;
padding: 4rpx 14rpx;
line-height: 1.4;
}
.expected-tag--overdue {
color: #e34d59;
background: rgba(227, 77, 89, 0.12);
border: 1rpx solid rgba(227, 77, 89, 0.25);
}
/* --- 卡片第一行:标签 + 客户名 + 心形 + 备注 --- */
/* H5: .flex.items-center.gap-2.mb-1.5 → gap-2=8px→15rpx, mb-1.5=6px→11rpx */
.card-row-1 {
@@ -1074,16 +1102,17 @@
}
/* 标签渐变色 — 严格对齐 VI-DESIGN-SYSTEM.md 1.1 节 */
.task-type-tag--high_priority {
/* CHANGE 2026-03-24 | 类名对齐数据库 task_typehigh_priority_recall / relationship_building */
.task-type-tag--high_priority_recall {
background: linear-gradient(135deg, var(--task-high-priority-from) 0%, var(--task-high-priority-to) 100%);
}
.task-type-tag--priority_recall {
background: linear-gradient(135deg, var(--task-priority-recall-from) 0%, var(--task-priority-recall-to) 100%);
}
.task-type-tag--relationship {
.task-type-tag--relationship_building {
background: linear-gradient(135deg, var(--task-relationship-from) 0%, var(--task-relationship-to) 100%);
}
.task-type-tag--callback {
.task-type-tag--follow_up_visit {
background: linear-gradient(135deg, var(--task-callback-from) 0%, var(--task-callback-to) 100%);
}
@@ -1171,6 +1200,24 @@
color: #c5c5c5;
}
/* --- 查看所有客户按钮 --- */
.view-all-customers-btn {
margin: 24rpx 32rpx 48rpx;
padding: 24rpx 0;
text-align: center;
background: linear-gradient(135deg, #ffffff 0%, #f5fbff 100%);
border: 2rpx solid #1a85ea;
border-radius: 16rpx;
}
.view-all-customers-btn--hover {
opacity: 0.7;
}
.view-all-customers-text {
font-size: 28rpx;
color: #0052d9;
font-weight: 500;
}
/* ============================================
* 长按上下文菜单
* H5: .context-overlay / .context-menu / .ctx-item

View File

@@ -1,4 +1,6 @@
// AI_CHANGELOG
// - 2026-03-23 | Prompt: Task 6 Change B | fetchTasks 增加响应适配层items→tasks,
// 计算 hasMore, 透传 performance对齐后端 /api/xcx/tasks 返回格式。
// - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | fetchSkillTypes() fallback 数据
// value 从 all/chinese/snooker 改为 ALL/BILLIARD/SNOOKERAPI 响应映射从
// data.skills 改为直接 map data 数组(后端返回 [{key,label,emoji,cls}])。
@@ -18,7 +20,6 @@ import type {
TaskDetail,
Note,
PerformanceData,
PerformanceRecord,
BoardFinanceData,
CustomerCard,
CoachCard,
@@ -45,18 +46,33 @@ export interface FetchTasksParams {
}
/** 获取任务列表 + 绩效概览 */
// CHANGE 2026-03-23 | Task 6 Change B增加响应适配层items→tasks, 计算 hasMore, 透传 performance
export async function fetchTasks(params: FetchTasksParams = {}): Promise<{
tasks: Task[]
performance: PerformanceData
total: number
hasMore: boolean
}> {
return request({
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
// 构建查询参数,过滤掉 undefined 值(避免序列化为 "undefined" 字符串)
const query: Record<string, any> = { page_size: pageSize, page }
if (params.status) query.status = params.status
const res = await request({
url: '/api/xcx/tasks',
method: 'GET',
data: params,
// 后端 FastAPI Query 参数为 snake_casepage_size前端 camelCase 需转换
data: query,
needAuth: true,
})
// 后端返回 { items, total, page, pageSize, performance },适配为前端期望的 { tasks, hasMore, ... }
return {
tasks: res.items ?? [],
performance: res.performance,
total: res.total ?? 0,
hasMore: (page * pageSize) < (res.total ?? 0),
}
}
/** 获取任务详情 */
@@ -68,6 +84,15 @@ export async function fetchTaskDetail(taskId: string): Promise<TaskDetail | null
})
}
/** 按 member_id 查询最高优先级 active 任务详情 */
export async function fetchTaskByMember(memberId: string): Promise<TaskDetail | null> {
return request({
url: `/api/xcx/tasks/by-member/${memberId}`,
method: 'GET',
needAuth: true,
})
}
/** 放弃任务 */
export async function abandonTask(taskId: string, reason: string): Promise<void> {
await request({
@@ -120,17 +145,19 @@ export async function fetchNotes(params: { page?: number; pageSize?: number } =
/** 新增备注 */
export async function createNote(data: {
targetType?: string
targetId: number
content: string
taskId?: string
customerId?: string
taskId?: number
ratingServiceWillingness?: number
ratingRevisitLikelihood?: number
}): Promise<{ id: string; createdAt: string }> {
return request({ url: '/api/xcx/notes', method: 'POST', data, needAuth: true })
score?: number
}): Promise<any> {
return request({ url: '/api/xcx/notes', method: 'POST', data: { targetType: 'member', ...data }, needAuth: true })
}
/** 删除备注 */
export async function deleteNote(noteId: string): Promise<void> {
export async function deleteNote(noteId: number): Promise<void> {
await request({ url: `/api/xcx/notes/${noteId}`, method: 'DELETE', needAuth: true })
}
@@ -146,13 +173,31 @@ export async function fetchPerformanceOverview(params: {
return request({ url: '/api/xcx/performance', method: 'GET', data: params, needAuth: true })
}
/** 绩效明细(按月) */
/** 绩效明细(按月)— 对齐后端 PerformanceRecordsResponse */
export async function fetchPerformanceRecords(params: {
year: number
month: number
page?: number
pageSize?: number
}): Promise<{ records: PerformanceRecord[]; hasMore: boolean }> {
}): Promise<{
summary: { totalCount: number; totalHours: number; totalHoursRaw: number; totalIncome: number }
dateGroups: Array<{
date: string
totalHours: string
totalIncome: string
records: Array<{
customerName: string
memberId?: number
avatarChar?: string
timeRange: string
hours: string
courseType: string
location: string
income: string
}>
}>
hasMore: boolean
}> {
return request({
url: '/api/xcx/performance/records',
method: 'GET',
@@ -174,13 +219,24 @@ export async function fetchCustomerDetail(customerId: string): Promise<CustomerD
})
}
/** 客户服务记录 */
/** 客户服务记录(对齐后端 CustomerRecordsResponse */
export async function fetchCustomerRecords(params: {
customerId: string
year?: number
month?: number
table?: string
}): Promise<{ records: any[]; hasMore: boolean }> {
}): Promise<{
customerName: string
customerPhone: string
customerPhoneFull: string
relationIndex: string
totalServiceCount: number
monthCount: number
monthHours: number
monthIncome: number
records: any[]
hasMore: boolean
}> {
const { customerId, ...rest } = params
return request({
url: `/api/xcx/customers/${customerId}/records`,
@@ -190,6 +246,21 @@ export async function fetchCustomerRecords(params: {
})
}
/** 客户消费记录CUST-3按月 */
export async function fetchCustomerConsumptionRecords(params: {
customerId: string
year: number
month: number
}): Promise<any> {
const { customerId, ...rest } = params
return request({
url: `/api/xcx/customers/${customerId}/consumption-records`,
method: 'GET',
data: rest,
needAuth: true,
})
}
// ============================================
// 看板模块
// ============================================
@@ -199,31 +270,32 @@ export async function fetchBoardCoaches(params: {
skill?: string
sort?: string
time?: string
} = {}): Promise<CoachCard[]> {
page?: number
pageSize?: number
} = {}): Promise<{ items: CoachCard[]; total: number; page: number; pageSize: number }> {
const data = await request({
url: '/api/xcx/board/coaches',
method: 'GET',
data: params,
needAuth: true,
})
return data.items
return data
}
// CHANGE 2026-03-19 | RNS1.3 T13: 增加 page/pageSize 参数支持分页
/** 客户看板 */
export async function fetchBoardCustomers(params: {
dimension?: string
project?: string
page?: number
pageSize?: number
} = {}): Promise<CustomerCard[]> {
} = {}): Promise<{ items: CustomerCard[]; total: number; page: number; pageSize: number }> {
const data = await request({
url: '/api/xcx/board/customers',
method: 'GET',
data: params,
needAuth: true,
})
return data.items
return data
}
// CHANGE 2026-03-19 | RNS1.3 T14: 扩展参数为 time/area/compare

View File

@@ -0,0 +1,211 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | 新建公共权限守卫模块ROLE_TABS/ROLE_HOME/PAGE_ROLES 映射、getVisibleTabs/getRoleHome/syncVisibleTabs/checkPageAccess 函数 |
| 2026-03-27 | 权限改造 W5 | 重写为权限码驱动:删除硬编码 PAGE_ROLES/ROLE_TABS/ROLE_HOME改为从后端 permissions 动态计算页面/tab/首页可见性 |
*/
/**
* 页面权限守卫 — 基于后端权限码的动态准入控制
*
* CHANGE 2026-03-27 | 权限改造
* 后端为权限唯一真相源,前端根据 /api/xcx/me 返回的 permissions[] 动态决定:
* - 页面可见性PAGE_PERMISSION_MAP
* - tab 可见性getVisibleTabs
* - 默认首页getPermissionHome
*/
import { request } from './request'
/**
* 页面 → 所需权限码映射
* 未列出的页面 = approved 即可访问notes/chat/my-profile/dev-tools
*/
const PAGE_PERMISSION_MAP: Record<string, string> = {
// 任务体系 → view_tasks
'pages/task-list/task-list': 'view_tasks',
'pages/task-detail/task-detail': 'view_tasks',
'pages/performance/performance': 'view_tasks',
'pages/performance-records/performance-records': 'view_tasks',
// 看板子页 → 各自权限码
'pages/board-finance/board-finance': 'view_board_finance',
'pages/board-customer/board-customer': 'view_board_customer',
'pages/board-coach/board-coach': 'view_board_coach',
// 详情页跟对应看板走
'pages/customer-detail/customer-detail': 'view_board_customer',
'pages/customer-service-records/customer-service-records': 'view_board_customer',
'pages/coach-detail/coach-detail': 'view_board_coach',
}
/**
* 根据权限码列表获取可见 tab
* - 有 view_tasks → 显示 task tab
* - 有 view_board 或任一 view_board_* → 显示 board tab
* - my tab 始终显示
*/
export function getVisibleTabs(permissions: string[]): string[] {
const tabs: string[] = []
if (permissions.includes('view_tasks')) tabs.push('task')
if (
permissions.includes('view_board') ||
permissions.includes('view_board_finance') ||
permissions.includes('view_board_customer') ||
permissions.includes('view_board_coach')
) {
tabs.push('board')
}
tabs.push('my')
return tabs
}
/**
* 根据权限码列表获取默认首页
*
* CHANGE 2026-03-27 | 优先级调整:有看板权限优先去看板,纯任务角色才去 task-list
* - coach只有 view_tasks→ task-list
* - staff/head_coach/manager有看板权限→ 第一个可见看板页
*/
export function getPermissionHome(permissions: string[]): string {
// 看板优先:按 finance > customer > coach 选第一个有权限的
if (permissions.includes('view_board_finance')) return '/pages/board-finance/board-finance'
if (permissions.includes('view_board_customer')) return '/pages/board-customer/board-customer'
if (permissions.includes('view_board_coach')) return '/pages/board-coach/board-coach'
// 无看板权限但有任务权限 → task-listcoach 场景)
if (permissions.includes('view_tasks')) return '/pages/task-list/task-list'
return '/pages/my-profile/my-profile'
}
/** 向后兼容:旧代码中 getRoleHome 的调用点 */
export function getRoleHome(_role: string | undefined): string {
const app = getApp<IAppOption>()
const perms = app.globalData.permissions || []
return getPermissionHome(perms)
}
/**
* 同步 globalData 中的 visibleTabs 和 permissions并主动刷新 tab-bar
*/
export function syncPermissions(permissions: string[]): void {
const app = getApp<IAppOption>()
app.globalData.permissions = permissions
app.globalData.visibleTabs = getVisibleTabs(permissions)
// CHANGE 2026-03-27 | 主动刷新 tab-bar 组件
// checkAuthStatus 异步完成时 tab-bar 可能已 attached 但读到了旧的 visibleTabs
try {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1] as any
const tabBar = currentPage.getTabBar?.()
if (tabBar && typeof tabBar._refreshTabs === 'function') {
tabBar._refreshTabs()
}
}
} catch {
// getCurrentPages 在 onLaunch 早期可能不可用,静默忽略
}
}
/** 向后兼容:旧代码中 syncVisibleTabs 的调用点 */
export function syncVisibleTabs(_role: string | undefined): void {
// 由 syncPermissions 统一处理,此函数保留为空操作
// 实际同步在 checkPageAccess 中通过 syncPermissions 完成
}
/**
* 获取当前用户可见的看板二级 tab 列表
* 用于看板页面动态显示/隐藏 finance/customer/coach tab
*/
export function getVisibleBoardTabs(): string[] {
const app = getApp<IAppOption>()
const perms = app.globalData.permissions || []
const tabs: string[] = []
if (perms.includes('view_board_finance')) tabs.push('finance')
if (perms.includes('view_board_customer')) tabs.push('customer')
if (perms.includes('view_board_coach')) tabs.push('coach')
return tabs
}
/**
* 页面权限守卫 — 在页面 onShow 中调用
*
* 返回 true 表示可以继续渲染false 表示已跳转(页面应 return
*
* 检查顺序:
* 1. 无 token → 跳登录页
* 2. 请求 /api/xcx/me 获取最新状态、角色、权限码
* 3. disabled → 清 token + 跳登录页(强制登出)
* 4. 非 approved → 跳对应状态页
* 5. 权限码不满足当前页面要求 → 跳默认首页
*/
export async function checkPageAccess(pagePath: string): Promise<boolean> {
const token = wx.getStorageSync('token')
if (!token) {
wx.reLaunch({ url: '/pages/login/login' })
return false
}
try {
const data = await request({
url: '/api/xcx/me',
method: 'GET',
needAuth: true,
})
const app = getApp<IAppOption>()
const permissions: string[] = data.permissions || []
app.globalData.authUser = {
userId: data.userId,
status: data.status,
nickname: data.nickname,
avatarUrl: data.avatarUrl || undefined,
role: data.role || undefined,
}
wx.setStorageSync('userId', data.userId)
wx.setStorageSync('userStatus', data.status)
if (data.role) wx.setStorageSync('userRole', data.role)
// CHANGE 2026-03-27 | 权限码驱动:同步权限和 tab 可见性
syncPermissions(permissions)
// 状态检查
switch (data.status) {
case 'disabled':
wx.removeStorageSync('token')
wx.removeStorageSync('refreshToken')
app.globalData.token = undefined
app.globalData.refreshToken = undefined
wx.reLaunch({ url: '/pages/login/login' })
return false
case 'new':
wx.reLaunch({ url: '/pages/apply/apply' })
return false
case 'pending':
wx.reLaunch({ url: '/pages/reviewing/reviewing' })
return false
case 'rejected':
wx.reLaunch({ url: '/pages/no-permission/no-permission' })
return false
}
// approved — 基于权限码检查页面访问权限
const requiredPerm = PAGE_PERMISSION_MAP[pagePath]
if (requiredPerm && !permissions.includes(requiredPerm)) {
// 无权限,跳到有权限的默认首页
wx.reLaunch({ url: getPermissionHome(permissions) })
return false
}
// approved 但无角色/无权限:放行 my-profile其余跳 my-profile
if (requiredPerm && permissions.length === 0) {
if (pagePath === 'pages/my-profile/my-profile') return true
wx.reLaunch({ url: '/pages/my-profile/my-profile' })
return false
}
return true
} catch {
// 网络错误不阻塞401 由 request.ts 处理)
return true
}
}

View File

@@ -4,8 +4,10 @@
*
* 用法:
* import { nameToAvatarColor } from '../../utils/avatar-color'
* const avatarColor = nameToAvatarColor('王先生') // => 'blue'
* const avatarColor = nameToAvatarColor(String(memberId)) // => 'blue'
* // wxml: class="avatar-{{rec.avatarColor}}"
*
* 未知客户(空字符串/散客)返回 'default'(灰色),对应 app.wxss .avatar-default。
*/
/** 24 色标准色板 key 列表(与 app.wxss .avatar-{key} 一一对应) */
@@ -36,16 +38,39 @@ export const AVATAR_PALETTE = [
'ocean',
] as const
export type AvatarColorKey = typeof AVATAR_PALETTE[number]
export type AvatarColorKey = typeof AVATAR_PALETTE[number] | 'default'
/**
* 根据名字首字(或任意字符串)稳定映射到头像颜色 key
* 相同输入永远返回相同颜色,适合用于客户/助教头像
* 简单字符串哈希djb2 变体),将任意字符串映射为 32 位正整数
* 比单字符 charCode 取模分布更均匀,相近 ID 也能产生不同颜色
*/
function simpleHash(str: string): number {
let hash = 5381
for (let i = 0; i < str.length; i++) {
// hash * 33 + charCode无符号右移保持正数
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0
}
return hash
}
/**
* 根据标识符(推荐用 member_id 字符串)稳定映射到头像颜色 key。
* 相同输入永远返回相同颜色。
*
* @param name 姓名或任意标识符(取第一个字符的 charCode
* - 空字符串 / "0" / 负数字符串 → 'default'(灰色,未知客户/散客
* - 其他 → djb2 哈希取模24 色均匀分布
*
* @param identifier member_id 字符串或其他标识符
* @returns AvatarColorKey
*/
export function nameToAvatarColor(name: string): AvatarColorKey {
const code = (name?.charCodeAt(0) ?? 0)
return AVATAR_PALETTE[code % AVATAR_PALETTE.length]
export function nameToAvatarColor(identifier: string): AvatarColorKey {
// 未知客户 / 散客:空、"0"、负数
if (!identifier || identifier === '0' || identifier === 'undefined' || identifier === 'null') {
return 'default'
}
const num = Number(identifier)
if (!isNaN(num) && num <= 0) {
return 'default'
}
return AVATAR_PALETTE[simpleHash(identifier) % AVATAR_PALETTE.length]
}

View File

@@ -1,26 +1,42 @@
/**
* 环境配置
* 环境配置 — 按小程序运行版本自动切换 API 地址
*
* 根据小程序运行环境自动切换 API 地址:
* - develop开发版→ 本机
* - trial体验版→ 测试环境
* - release正式版→ 正式环境
* develop → 本机开发服务器(需在开发者工具中关闭域名校验)
* trial → 测试环境(体验版)
* release → 正式环境
*/
function getApiBase(): string {
const accountInfo = wx.getAccountInfoSync()
const envVersion = accountInfo.miniProgram.envVersion
type EnvVersion = "develop" | "trial" | "release"
switch (envVersion) {
case "develop":
return "http://127.0.0.1:8000"
case "trial":
return "https://api.langlangzhuoqiu.cn"
case "release":
return "https://api.langlangzhuoqiu.cn"
default:
return "https://api.langlangzhuoqiu.cn"
interface EnvConfig {
name: string
baseURL: string
}
function getEnvVersion(): EnvVersion {
try {
const info = wx.getAccountInfoSync()
return (info?.miniProgram?.envVersion as EnvVersion) || "develop"
} catch {
return "develop"
}
}
export const API_BASE = getApiBase()
const ENV_MAP: Record<EnvVersion, EnvConfig> = {
develop: {
name: "开发环境",
baseURL: "http://127.0.0.1:8000",
},
trial: {
name: "测试环境",
baseURL: "https://test-api.langlangzhuoqiu.cn",
},
release: {
name: "正式环境",
baseURL: "https://api.langlangzhuoqiu.cn",
},
}
export const ENV_VERSION = getEnvVersion()
export const ENV_CONFIG = ENV_MAP[ENV_VERSION]
export const API_BASE = ENV_CONFIG.baseURL

View File

@@ -4,7 +4,10 @@
/** 数字保留 N 位小数;空值返回 '--' */
function toFixed(num, digits) {
if (num === undefined || num === null) return '--'
return num.toFixed(digits)
// CHANGE 2026-03-27 | 防护:传入字符串时先转数字,避免 toFixed is not a function
var n = typeof num === 'number' ? num : parseFloat(num)
if (isNaN(n)) return '--'
return n.toFixed(digits)
}
/** 空值兜底null/undefined/'' 统一返回 '--' */
@@ -59,18 +62,149 @@ function count(value, unit) {
function percent(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '0%'
return value.toFixed(1) + '%'
var n = typeof value === 'number' ? value : parseFloat(value)
if (isNaN(n)) return '--'
return n.toFixed(1) + '%'
}
/**
* 课时格式化WXS 版)
* 整数 → Nh;非整数 → N.Nh0 → 0h;空 → --
* 课时格式化WXS 版)— 带"小时"后缀
* 整数 → N小时;非整数 → N.N小时0 → 0小时;空 → --
*/
function hours(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '0h'
if (value % 1 === 0) return value + 'h'
return value.toFixed(1) + 'h'
var n = typeof value === 'number' ? value : parseFloat(value)
if (isNaN(n)) return '--'
if (n === 0) return '0小时'
if (n % 1 === 0) return n + '小时'
return n.toFixed(1) + '小时'
}
/**
* 课时纯数字WXS 版)— 不带后缀
* 整数 → N非整数 → N.N0 → 0空 → --
*/
function hoursNum(value) {
if (value === undefined || value === null) return '--'
var n = typeof value === 'number' ? value : parseFloat(value)
if (isNaN(n)) return '--'
if (n === 0) return '0'
if (n % 1 === 0) return '' + n
return n.toFixed(1)
}
/**
* 课时紧凑格式WXS 版)— 带"h"后缀
* 用于一行内多指标的紧凑场景如常客列表5次 · 12.5h · ¥1,250
* 整数 → Nh非整数 → N.Nh0 → 0h空 → --
*/
function hoursH(value) {
if (value === undefined || value === null) return '--'
var n = typeof value === 'number' ? value : parseFloat(value)
if (isNaN(n)) return '--'
if (n === 0) return '0h'
if (n % 1 === 0) return n + 'h'
return n.toFixed(1) + 'h'
}
/**
* 千分位格式化WXS 版)
* 1234 → 1,234-5678 → -5,678空 → --
*/
function thousands(value) {
if (value === undefined || value === null) return '--'
var neg = value < 0
var abs = Math.round(Math.abs(value))
var s = abs.toString()
var result = ''
var c = 0
for (var i = s.length - 1; i >= 0; i--) {
if (c > 0 && c % 3 === 0) result = ',' + result
result = s[i] + result
c++
}
return neg ? '-' + result : result
}
/**
* 同比/环比差值格式化WXS 版)
* +¥1,200 / -¥800 / --
*/
function trendValue(value) {
if (value === undefined || value === null || value === 0) return '--'
var abs = Math.round(Math.abs(value))
var s = abs.toString()
var result = ''
var c = 0
for (var i = s.length - 1; i >= 0; i--) {
if (c > 0 && c % 3 === 0) result = ',' + result
result = s[i] + result
c++
}
return (value > 0 ? '+¥' : '-¥') + result
}
/**
* 天数格式化WXS 版)
* "3天" / "--"
*/
function days(value) {
if (value === undefined || value === null) return '--'
// CHANGE 2026-03-27 | 修复0天前不应返回 '--'0 是有效值(今天到店)
if (value === 0) return '0天'
return value + '天'
}
/**
* 储值等级格式化WXS 版)
* 无/少/一般/多/非常多
*/
function storageLevel(balance) {
if (balance === undefined || balance === null || balance === 0) return '无'
if (balance < 200) return '少'
if (balance < 500) return '一般'
if (balance < 1500) return '多'
return '非常多'
}
/**
* 手机号脱敏WXS 版)
* "13812342304" → "138****2304";短号/空值原样返回
*/
function maskPhone(phone) {
if (!phone || phone.length < 7) return phone || ''
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4)
}
/**
* 格式化负数金额(优惠减扣专用)
* CHANGE 2026-03-27 | board-finance-phase2 T3 | 优惠总计展示为 -¥xxx 格式
*/
function negativeMoney(val) {
var n = parseFloat(val)
if (isNaN(n) || n === 0) return '-¥0'
var abs = n < 0 ? -n : n
return '-' + money(abs)
}
/**
* 环比文本格式化:持平→灰色无箭头,新增→灰色无箭头,上升→↑,下降→↓
*/
function compareText(compare, isDown) {
if (!compare) return ''
if (compare === '持平') return '持平'
if (compare === '新增') return '新增'
return (isDown ? '↓' : '↑') + compare
}
/**
* 环比样式类:持平/新增→flat灰色上升→up红色下降→down绿色
*/
function compareClass(compare, isDown, size) {
if (!compare) return ''
var s = size || 'xs'
if (compare === '持平' || compare === '新增') return 'compare-text-flat-' + s
return isDown ? ('compare-text-down-' + s) : ('compare-text-up-' + s)
}
module.exports = {
@@ -80,4 +214,14 @@ module.exports = {
count: count,
percent: percent,
hours: hours,
hoursH: hoursH,
hoursNum: hoursNum,
thousands: thousands,
trendValue: trendValue,
days: days,
storageLevel: storageLevel,
maskPhone: maskPhone,
negativeMoney: negativeMoney,
compareText: compareText,
compareClass: compareClass,
}

View File

@@ -4,7 +4,7 @@
// 类型定义
// ============================================================
export type TaskType = 'callback' | 'priority_recall' | 'relationship' | 'high_priority'
export type TaskType = 'high_priority_recall' | 'priority_recall' | 'follow_up_visit' | 'relationship_building'
/** 备注评分记录 */
export interface Note {
@@ -45,6 +45,44 @@ export interface TaskDetail extends Task {
preferences?: string[]
consumptionHabits?: string
socialPreference?: string
/** 客户手机号(后端返回) */
customerPhone?: string
/** 客户储值余额(元,用于计算储值等级) */
balance?: number
/** AI 生成的行动建议列表 */
actionSuggestions?: string[]
/** 客户 IDmember_id */
customerId?: number
/** 维客线索 */
retentionClues?: Array<{
tag: string
tagColor: string
emoji: string
text: string
source: string
desc?: string
}>
/** 话术参考 */
talkingPoints?: string[]
/** 服务记录汇总 */
serviceSummary?: {
totalHours: number
totalIncome: number
avgIncome: number
}
/** 近期服务记录 */
serviceRecords?: Array<{
table: string | null
type: string
typeClass: string
recordType?: string
duration: number
durationRaw?: number
income: number
isEstimate?: boolean
drinks?: string | null
date: string
}>
}
export interface Note {
@@ -109,6 +147,10 @@ export interface BoardFinanceData {
metrics: FinanceMetric[]
timeRange: string
filterOptions: Array<{ value: string; text: string }>
/** P13: AI 智能洞察列表 */
aiInsights?: Array<{ icon: string; text: string }>
/** P13: 是否为预估数据 */
isEstimated?: boolean
}
export interface CustomerCard {
@@ -126,6 +168,8 @@ export interface CoachCard {
avatar: string
level: string
keyMetrics: Array<{ label: string; value: string }>
/** P13: 任务执行统计 */
taskStats?: { recall: number; callback: number }
}
export interface CustomerDetail {
@@ -136,6 +180,8 @@ export interface CustomerDetail {
heartScore: number
phone: string
spiIndex: number
/** 累计服务次数 */
totalServiceCount?: number
consumptionRecords: ConsumptionRecord[]
}
@@ -181,128 +227,12 @@ export interface UserProfile {
// ============================================================
export const mockTasks: Task[] = [
{
id: 'task-001',
customerName: '张伟',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '回访',
deadline: '2026-03-10',
heartScore: 9.2,
hobbies: ['chinese', 'karaoke'],
isPinned: true,
hasNote: true,
status: 'pending',
},
{
id: 'task-002',
customerName: '李娜',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'high_priority',
taskTypeLabel: '高优先召回',
deadline: '2026-03-08',
heartScore: 6.5,
hobbies: ['snooker'],
isPinned: false,
hasNote: false,
status: 'pending',
},
{
id: 'task-003',
customerName: '王磊',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'relationship',
taskTypeLabel: '关系构建',
deadline: '2026-03-12',
heartScore: 7.8,
hobbies: ['chinese', 'mahjong'],
isPinned: false,
hasNote: true,
status: 'pending',
},
{
id: 'task-004',
customerName: '赵敏',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'callback',
taskTypeLabel: '回访',
deadline: '2026-03-09',
heartScore: 8.8,
hobbies: ['chinese'],
isPinned: false,
hasNote: false,
status: 'pending',
},
{
id: 'task-005',
customerName: '陈浩',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'priority_recall',
taskTypeLabel: '优先召回',
deadline: '2026-03-07',
heartScore: 4.2,
hobbies: ['snooker', 'karaoke'],
isPinned: true,
hasNote: true,
status: 'pending',
},
{
id: 'task-006',
customerName: '刘洋',
customerAvatar: '/assets/images/avatar-default.png',
taskType: 'relationship',
taskTypeLabel: '关系构建',
deadline: '2026-03-15',
heartScore: 5.0,
hobbies: ['mahjong'],
isPinned: false,
hasNote: false,
status: 'completed',
},
{ id: '', customerName: '', customerAvatar: '', taskType: 'follow_up_visit', taskTypeLabel: '', deadline: '', heartScore: 0, hobbies: [], isPinned: false, hasNote: false, status: 'pending' },
{ id: '', customerName: '', customerAvatar: '', taskType: 'high_priority_recall', taskTypeLabel: '', deadline: '', heartScore: 0, hobbies: [], isPinned: true, hasNote: false, status: 'pending' },
]
export const mockTaskDetails: TaskDetail[] = [
{
...mockTasks[0],
customerName: '王先生',
taskTypeLabel: '高优先召回',
heartScore: 8.5,
aiAnalysis: {
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好。上次服务评价 5 星,多次指定您为服务助教。',
suggestions: ['询问近期是否有空,邀请体验新到的器材', '告知本周末有会员专属活动', '根据其偏好时段(晚间)推荐合适的时间'],
},
notes: [
{ id: 'note-h5-1', content: '已通过微信联系王先生,表示对新到的斯诺克球桌感兴趣,周末可能来体验。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-02-05' },
{ id: 'note-h5-2', content: '王先生最近出差较多,到店频率降低。建议等他回来后再约。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-01-20' },
{ id: 'note-h5-3', content: '上次到店时推荐了会员续费活动,客户说考虑一下。', tagType: 'customer' as const, tagLabel: '客户:王先生', createdAt: '2026-01-08' },
],
notes: [],
lastVisitDate: '2026-03-01',
lastSpendAmount: 380,
callbackReason: '上次体验课后未续费,需跟进意向',
},
{
...mockTasks[1],
aiAnalysis: {
summary: '李娜已 23 天未到店,消费趋势下降,存在流失风险。',
suggestions: ['电话关怀了解原因', '发送专属优惠券', '推荐闺蜜同行活动'],
},
notes: [],
daysAbsent: 23,
spendTrend: 'down',
churnRisk: 'high',
},
{
...mockTasks[2],
aiAnalysis: {
summary: '王磊是中台球爱好者,社交活跃,适合发展为核心会员。',
suggestions: ['邀请参加周末球友赛', '推荐 VIP 会员权益', '介绍同水平球友'],
},
notes: [],
preferences: ['中式台球', '麻将'],
consumptionHabits: '周末下午为主,偏好包厢',
socialPreference: '喜欢组队打球,社交型消费者',
},
{ id: '', customerName: '', customerAvatar: '', taskType: 'follow_up_visit', taskTypeLabel: '', deadline: '', heartScore: 0, hobbies: [], isPinned: false, hasNote: false, status: 'pending', aiAnalysis: { summary: '', suggestions: ['', ''] }, notes: [{ id: '', content: '', tagType: 'coach', tagLabel: '', createdAt: '' }] },
]
// ============================================================
@@ -317,84 +247,14 @@ export const mockTaskDetails: TaskDetail[] = [
//
// 以下是任务详情页的 MOCK 数据(脱敏版本)
export const mockRetentionClues: RetentionClue[] = [
{
id: 'clue-001',
category: 'consumption',
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好',
detail: '上次服务评价 5 星,多次指定您为服务助教。消费频次稳定,主要偏好中式台球 1v1 课程。',
source: 'ai_consumption',
createdAt: '2026-03-05 14:00',
},
{
id: 'clue-002',
category: 'play_pref',
summary: '中式台球爱好者,技术水平中等偏上',
detail: '对斯诺克也有兴趣,建议推荐进阶课程。',
source: 'manual',
recordedByAssistantId: 'assistant-001',
recordedByName: '王芳',
createdAt: '2026-03-01 10:30',
},
{
id: 'clue-003',
category: 'social',
summary: '社交活跃,经常带朋友来店',
detail: '可以发展为核心会员,推荐参加周末球友赛。',
source: 'ai_note',
// 后端脱敏:移除 recordedByAssistantId 和 recordedByName
createdAt: '2026-02-28 16:45',
},
{
id: 'clue-004',
category: 'promo_pref',
summary: '对储值活动敏感,倾向于大额充值',
detail: '上次充值 2000 元,选择尊享套餐。建议定期推送专属优惠。',
source: 'manual',
// 后端脱敏:移除 recordedByAssistantId 和 recordedByName因为不是当前用户
createdAt: '2026-02-25 09:15',
},
{ id: '', category: 'customer_basic', summary: '', source: 'ai_consumption', createdAt: '' },
{ id: '', category: 'consumption', summary: '', detail: '', source: 'manual', createdAt: '' },
]
// 客户详情页的 MOCK 数据(完整版本,不脱敏)
export const mockRetentionCluesForCustomerDetail: RetentionClue[] = [
{
id: 'clue-001',
category: 'consumption',
summary: '最近 3 个月每周均有 1-2 次课程互动,客户反馈良好',
detail: '上次服务评价 5 星,多次指定您为服务助教。消费频次稳定,主要偏好中式台球 1v1 课程。',
source: 'ai_consumption',
createdAt: '2026-03-05 14:00',
},
{
id: 'clue-002',
category: 'play_pref',
summary: '中式台球爱好者,技术水平中等偏上',
detail: '对斯诺克也有兴趣,建议推荐进阶课程。',
source: 'manual',
recordedByAssistantId: 'assistant-001',
recordedByName: '王芳',
createdAt: '2026-03-01 10:30',
},
{
id: 'clue-003',
category: 'social',
summary: '社交活跃,经常带朋友来店',
detail: '可以发展为核心会员,推荐参加周末球友赛。',
source: 'ai_note',
recordedByAssistantId: 'assistant-003',
recordedByName: '李明',
createdAt: '2026-02-28 16:45',
},
{
id: 'clue-004',
category: 'promo_pref',
summary: '对储值活动敏感,倾向于大额充值',
detail: '上次充值 2000 元,选择尊享套餐。建议定期推送专属优惠。',
source: 'manual',
recordedByAssistantId: 'assistant-002',
recordedByName: '刘强',
createdAt: '2026-02-25 09:15',
},
{ id: '', category: 'customer_basic', summary: '', source: 'ai_consumption', recordedByAssistantId: '', recordedByName: '', createdAt: '' },
{ id: '', category: 'feedback', summary: '', detail: '', source: 'manual', recordedByAssistantId: '', recordedByName: '', createdAt: '' },
]
// ============================================================
@@ -403,90 +263,8 @@ export const mockRetentionCluesForCustomerDetail: RetentionClue[] = [
// mock 数据贴近 H5 原型 notes.html 中的 12 条备注
export const mockNotes: Note[] = [
{
id: 'note-001',
content: '小燕本月表现优秀课时完成率达到120%客户评价全部5星。建议下月提升课时费标准同时安排更多VIP客户给她。',
tagType: 'coach',
tagLabel: '助教:小燕',
createdAt: '2024-11-27 16:00',
},
{
id: 'note-002',
content: '客户今天表示下周有朋友生日聚会想预约包厢。已告知包厢需要提前3天预约客户说会尽快确定时间再联系。',
tagType: 'customer',
tagLabel: '客户:王先生',
createdAt: '2024-11-27 15:30',
},
{
id: 'note-003',
content: '完成高优先召回任务。客户反馈最近工作太忙这周末会来店里。已提醒客户储值卡还有2000元余额下月到期需要续费。',
tagType: 'customer',
tagLabel: '客户:王先生',
createdAt: '2024-11-26 18:45',
},
{
id: 'note-004',
content: 'Lucy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。建议适当调整排班。',
tagType: 'coach',
tagLabel: '助教Lucy',
createdAt: '2024-11-26 11:30',
},
{
id: 'note-005',
content: '泡芙本月表现优秀课时完成率达到120%客户评价全部5星。建议下月提升课时费标准。',
tagType: 'customer',
tagLabel: '客户:陈女士',
createdAt: '2024-11-25 10:20',
},
{
id: 'note-006',
content: '泡芙的斯诺克教学水平有明显提升,最近几位客户反馈都很好。可以考虑让她带更多斯诺克方向的课程。',
tagType: 'coach',
tagLabel: '助教:泡芙',
createdAt: '2024-11-25 14:20',
},
{
id: 'note-007',
content: '客户对今天的服务非常满意,特别提到小燕的教学很专业。客户表示会推荐朋友来店里体验。',
tagType: 'customer',
tagLabel: '客户:李女士',
createdAt: '2024-11-24 21:15',
},
{
id: 'note-008',
content: '小燕反馈近期有几位客户希望增加晚间时段的课程建议协调排班增加21:00-23:00时段的助教配置。',
tagType: 'coach',
tagLabel: '助教:小燕',
createdAt: '2024-11-24 09:00',
},
{
id: 'note-009',
content: '关系构建任务完成。与客户进行了30分钟的深入交流了解到客户是企业高管经常需要商务宴请场地。已记录客户需求后续可以推荐团建套餐。',
tagType: 'customer',
tagLabel: '客户:张先生',
createdAt: '2024-11-23 19:30',
},
{
id: 'note-010',
content: '客户今天充值了5000元选择的是尊享套餐。客户提到喜欢安静的环境以后尽量安排包厢。',
tagType: 'customer',
tagLabel: '客户:张先生',
createdAt: '2024-11-22 14:00',
},
{
id: 'note-011',
content: 'Lucy本周请假2天处理家事已安排泡芙和小燕分担她的客户。回来后需要跟进客户交接情况。',
tagType: 'coach',
tagLabel: '助教Lucy',
createdAt: '2024-11-22 10:15',
},
{
id: 'note-012',
content: 'Lucy最近工作状态很好主动承担了培训新员工的任务。但需要注意她最近加班较多避免过度疲劳。',
tagType: 'customer',
tagLabel: '客户:李女士',
createdAt: '2024-11-21 09:45',
},
{ id: '', content: '', tagType: 'coach', tagLabel: '', createdAt: '' },
{ id: '', content: '', tagType: 'customer', tagLabel: '', createdAt: '' },
]
// ============================================================
@@ -494,23 +272,18 @@ export const mockNotes: Note[] = [
// ============================================================
export const mockPerformance: PerformanceData = {
monthlyIncome: 12680,
incomeChange: 15.3,
currentTier: '银牌助教',
nextTierGap: 3320,
todayServiceCount: 4,
weekServiceCount: 18,
monthServiceCount: 67,
monthlyIncome: 0,
incomeChange: 0,
currentTier: '',
nextTierGap: 0,
todayServiceCount: 0,
weekServiceCount: 0,
monthServiceCount: 0,
}
export const mockPerformanceRecords: PerformanceRecord[] = [
{ id: 'pr-001', customerName: '张伟', amount: 380, date: '2026-03-05', type: '课时', category: '中式台球' },
{ id: 'pr-002', customerName: '李娜', amount: 200, date: '2026-03-04', type: '散客', category: '斯诺克' },
{ id: 'pr-003', customerName: '王磊', amount: 1500, date: '2026-03-03', type: '储值', category: '会员充值' },
{ id: 'pr-004', customerName: '赵敏', amount: 280, date: '2026-03-02', type: '课时', category: '中式台球' },
{ id: 'pr-005', customerName: '陈浩', amount: 150, date: '2026-03-01', type: '散客', category: '中式台球' },
{ id: 'pr-006', customerName: '刘洋', amount: 320, date: '2026-02-28', type: '课时', category: '斯诺克' },
{ id: 'pr-007', customerName: '张伟', amount: 2000, date: '2026-02-25', type: '储值', category: '会员充值' },
{ id: '', customerName: '', amount: 0, date: '', type: '', category: '' },
{ id: '', customerName: '', amount: 0, date: '', type: '', category: '' },
]
// ============================================================
@@ -519,124 +292,21 @@ export const mockPerformanceRecords: PerformanceRecord[] = [
export const mockBoardFinance: BoardFinanceData = {
metrics: [
{
title: '本月营收',
value: 86500,
unit: '元',
trend: 'up',
trendValue: '+12.5%',
helpText: '包含课时费、散客消费、储值充值等所有收入',
},
{
title: '本月支出',
value: 34200,
unit: '元',
trend: 'down',
trendValue: '-3.2%',
helpText: '包含人工、水电、耗材等运营成本',
},
{
title: '净利润',
value: 52300,
unit: '元',
trend: 'up',
trendValue: '+22.1%',
},
{
title: '客单价',
value: 186,
unit: '元',
trend: 'flat',
trendValue: '+0.5%',
helpText: '本月平均每位客户消费金额',
},
],
timeRange: '2026-03',
filterOptions: [
{ value: 'today', text: '今日' },
{ value: 'week', text: '本周' },
{ value: 'month', text: '本月' },
{ value: 'quarter', text: '本季度' },
{ title: '', value: 0, unit: '', trend: 'flat', trendValue: '' },
{ title: '', value: 0, unit: '', trend: 'flat', trendValue: '' },
],
timeRange: '',
filterOptions: [],
}
export const mockCustomers: CustomerCard[] = [
{
id: 'cust-001',
name: '张伟',
avatar: '/assets/images/avatar-default.png',
heartScore: 9.2,
tags: ['VIP', '中式台球'],
keyMetric: { label: '本月消费', value: '¥2,380' },
},
{
id: 'cust-002',
name: '李娜',
avatar: '/assets/images/avatar-default.png',
heartScore: 6.5,
tags: ['斯诺克'],
keyMetric: { label: '本月消费', value: '¥680' },
},
{
id: 'cust-003',
name: '王磊',
avatar: '/assets/images/avatar-default.png',
heartScore: 7.8,
tags: ['中式台球', '麻将'],
keyMetric: { label: '本月消费', value: '¥1,500' },
},
{
id: 'cust-004',
name: '赵敏',
avatar: '/assets/images/avatar-default.png',
heartScore: 8.8,
tags: ['中式台球', '新客'],
keyMetric: { label: '本月消费', value: '¥280' },
},
{
id: 'cust-005',
name: '陈浩',
avatar: '/assets/images/avatar-default.png',
heartScore: 4.2,
tags: ['斯诺克', 'K歌'],
keyMetric: { label: '本月消费', value: '¥150' },
},
{ id: '', name: '', avatar: '', heartScore: 0, tags: [], keyMetric: { label: '', value: '' } },
{ id: '', name: '', avatar: '', heartScore: 0, tags: [], keyMetric: { label: '', value: '' } },
]
export const mockCoaches: CoachCard[] = [
{
id: 'coach-001',
name: '李明',
avatar: '/assets/images/avatar-default.png',
level: '金牌助教',
keyMetrics: [
{ label: '本月课时', value: '86 节' },
{ label: '本月收入', value: '¥18,600' },
{ label: '服务客户', value: '32 人' },
],
},
{
id: 'coach-002',
name: '王芳',
avatar: '/assets/images/avatar-default.png',
level: '银牌助教',
keyMetrics: [
{ label: '本月课时', value: '67 节' },
{ label: '本月收入', value: '¥12,680' },
{ label: '服务客户', value: '25 人' },
],
},
{
id: 'coach-003',
name: '刘强',
avatar: '/assets/images/avatar-default.png',
level: '铜牌助教',
keyMetrics: [
{ label: '本月课时', value: '45 节' },
{ label: '本月收入', value: '¥8,200' },
{ label: '服务客户', value: '18 人' },
],
},
{ id: '', name: '', avatar: '', level: '', keyMetrics: [{ label: '', value: '' }] },
{ id: '', name: '', avatar: '', level: '', keyMetrics: [{ label: '', value: '' }] },
]
// ============================================================
@@ -644,19 +314,16 @@ export const mockCoaches: CoachCard[] = [
// ============================================================
export const mockCustomerDetail: CustomerDetail = {
id: 'cust-001',
name: '张伟',
avatar: '/assets/images/avatar-default.png',
tags: ['VIP', '中式台球', '老客户'],
heartScore: 9.2,
phone: '138****6789',
spiIndex: 87,
id: '',
name: '',
avatar: '',
tags: [''],
heartScore: 0,
phone: '',
spiIndex: 0,
consumptionRecords: [
{ id: 'cr-001', date: '2026-03-05', project: '中式台球 1v1', duration: 90, amount: 380, coachName: '王芳' },
{ id: 'cr-002', date: '2026-03-01', project: '斯诺克练习', duration: 60, amount: 200, coachName: '李明' },
{ id: 'cr-003', date: '2026-02-25', project: '中式台球 1v1', duration: 120, amount: 480, coachName: '王芳' },
{ id: 'cr-004', date: '2026-02-20', project: '会员充值', duration: 0, amount: 2000, coachName: '-' },
{ id: 'cr-005', date: '2026-02-15', project: '中式台球小组课', duration: 90, amount: 280, coachName: '刘强' },
{ id: '', date: '', project: '', duration: 0, amount: 0, coachName: '' },
{ id: '', date: '', project: '', duration: 0, amount: 0, coachName: '' },
],
}
@@ -665,82 +332,13 @@ export const mockCustomerDetail: CustomerDetail = {
// ============================================================
export const mockChatMessages: ChatMessage[] = [
{
id: 'msg-001',
role: 'user',
content: '帮我看看张伟最近的消费情况',
timestamp: '2026-03-05T14:00:00+08:00',
referenceCard: {
type: 'customer',
title: '张伟 — 消费概览',
summary: '近 30 天消费 3 次,总额 ¥1,060',
data: {
'最近到店': '2026-03-05',
'偏好项目': '中式台球 1v1',
'常约助教': '王芳',
'爱心评分': '9.2',
},
},
},
{
id: 'msg-002',
role: 'assistant',
content: '张伟最近一个月消费了 3 次,总金额 ¥1,060。消费频次稳定主要偏好中式台球 1v1 课程。',
timestamp: '2026-03-05T14:00:05+08:00',
},
{
id: 'msg-003',
role: 'user',
content: '他适合推荐什么课程?',
timestamp: '2026-03-05T14:01:00+08:00',
},
{
id: 'msg-004',
role: 'assistant',
content: '根据张伟的消费习惯和技术水平,建议推荐以下课程:\n1. 斯诺克进阶课 — 拓展球类兴趣\n2. 中式台球高级技巧班 — 提升现有水平\n3. 周末球友赛 — 增强社交粘性',
timestamp: '2026-03-05T14:01:08+08:00',
},
{
id: 'msg-005',
role: 'user',
content: '好的,帮我记一下,下次回访时推荐斯诺克进阶课',
timestamp: '2026-03-05T14:02:00+08:00',
},
{
id: 'msg-006',
role: 'assistant',
content: '已记录。下次回访张伟时,我会提醒你推荐斯诺克进阶课程。',
timestamp: '2026-03-05T14:02:03+08:00',
},
{ id: '', role: 'user', content: '', timestamp: '' },
{ id: '', role: 'assistant', content: '', timestamp: '' },
]
export const mockChatHistory: ChatHistoryItem[] = [
{
id: 'chat-001',
title: '张伟消费分析',
lastMessage: '已记录。下次回访张伟时,我会提醒你推荐斯诺克进阶课程。',
timestamp: '2026-03-05T14:02:03+08:00',
customerName: '张伟',
},
{
id: 'chat-002',
title: '本周业绩汇总',
lastMessage: '本周总收入 ¥18,600环比上周增长 8.3%。',
timestamp: '2026-03-04T09:30:00+08:00',
},
{
id: 'chat-003',
title: '李娜召回策略',
lastMessage: '建议发送专属优惠券并电话关怀,了解未到店原因。',
timestamp: '2026-03-03T16:00:00+08:00',
customerName: '李娜',
},
{
id: 'chat-004',
title: '新客户接待建议',
lastMessage: '首次到店客户建议安排体验课,重点介绍会员权益。',
timestamp: '2026-03-02T11:20:00+08:00',
},
{ id: '', title: '', lastMessage: '', timestamp: '', customerName: '' },
{ id: '', title: '', lastMessage: '', timestamp: '' },
]
// ============================================================
@@ -748,8 +346,8 @@ export const mockChatHistory: ChatHistoryItem[] = [
// ============================================================
export const mockUserProfile: UserProfile = {
name: '小燕',
avatar: '/assets/images/avatar-coach.png',
role: '助教',
storeName: '朗朗桌球',
name: '',
avatar: '',
role: '',
storeName: '',
}

View File

@@ -63,3 +63,15 @@ export function toProgressWidth(value: number | null | undefined): string {
if (value === null || value === undefined) return '0%'
return `${Math.min(100, Math.max(0, value)).toFixed(1)}%`
}
/**
* 同比/环比差值格式化
* +¥1,200 / -¥800 / --
* @param value 差值(元,整数)
*/
export function formatTrendValue(value: number | null | undefined): string {
if (value === null || value === undefined || value === 0) return '--'
const abs = Math.round(Math.abs(value))
const formatted = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return value > 0 ? `${formatted}` : `${formatted}`
}

View File

@@ -84,7 +84,15 @@ function wxRequest(options: RequestOptions): Promise<any> {
header: options.header || {},
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
// CHANGE 2026-03-23 | 响应解包
// 后端 ResponseWrapperMiddleware 将 2xx JSON 包装为 { code: 0, data: <原始body> }
// 前端统一解包,调用方直接拿到业务数据
const body = res.data as any
if (body && typeof body === "object" && body.code === 0 && "data" in body) {
resolve(body.data)
} else {
resolve(body)
}
} else {
reject({ statusCode: res.statusCode, data: res.data })
}
@@ -101,6 +109,10 @@ function wxRequest(options: RequestOptions): Promise<any> {
*
* 成功 → 保存新 token 并返回 true
* 失败 → 清除 token、跳转登录页、返回 false
*
* CHANGE 2026-03-22 | 竞态修复
* 刷新前记录当前 token 快照,刷新失败时检查 token 是否已被
* onLogin 更新。若已更新,说明用户已重新登录,不清除新 token。
*/
async function tryRefreshToken(): Promise<boolean> {
const rt = getRefreshToken()
@@ -110,6 +122,9 @@ async function tryRefreshToken(): Promise<boolean> {
return false
}
// 记录刷新前的 token用于竞态检测
const tokenBeforeRefresh = getAccessToken()
try {
const data = await wxRequest({
url: "/api/xcx/refresh",
@@ -118,21 +133,69 @@ async function tryRefreshToken(): Promise<boolean> {
header: { "Content-Type": "application/json" },
needAuth: false,
})
if (data.access_token && data.refresh_token) {
saveTokens(data.access_token, data.refresh_token)
// CHANGE 2026-03-23 | camelCase 修复:后端 CamelModel 序列化输出 camelCase
if (data.accessToken && data.refreshToken) {
saveTokens(data.accessToken, data.refreshToken)
return true
}
// 响应格式异常,视为刷新失败
// 竞态检测token 已被 onLogin 更新,不清除
if (getAccessToken() !== tokenBeforeRefresh) {
return false
}
clearTokens()
redirectToLogin()
return false
} catch {
// 竞态检测token 已被 onLogin 更新,不清除
if (getAccessToken() !== tokenBeforeRefresh) {
return false
}
clearTokens()
redirectToLogin()
return false
}
}
// ============================================
// 全局 Loading 管理(引用计数 + 1 秒延迟)
// ============================================
/** 当前活跃请求数 */
let _loadingCount = 0
/** 1 秒延迟定时器 */
let _loadingTimer: ReturnType<typeof setTimeout> | null = null
/** loading 是否已显示 */
let _loadingShown = false
function _onRequestStart(): void {
_loadingCount++
if (_loadingCount === 1) {
// 首个请求,启动 1 秒延迟
_loadingTimer = setTimeout(() => {
_loadingTimer = null
if (_loadingCount > 0) {
_loadingShown = true
wx.showLoading({ title: "加载中...", mask: true })
}
}, 1000)
}
}
function _onRequestEnd(): void {
_loadingCount = Math.max(0, _loadingCount - 1)
if (_loadingCount === 0) {
if (_loadingTimer) {
clearTimeout(_loadingTimer)
_loadingTimer = null
}
if (_loadingShown) {
_loadingShown = false
wx.hideLoading()
}
}
}
/**
* 处理排队中的请求:刷新成功后全部重试,失败则全部拒绝
*/
@@ -172,13 +235,18 @@ export function request(options: RequestOptions): Promise<any> {
const finalOptions: RequestOptions = { ...options, header: headers }
return wxRequest(finalOptions).catch(async (err) => {
_onRequestStart()
return wxRequest(finalOptions).then(
(data) => { _onRequestEnd(); return data },
async (err) => {
// 非 401 或不需要认证的请求,直接抛出
if (err.statusCode !== 401 || !needAuth) {
_onRequestEnd()
throw err
}
// 401 → 尝试刷新 token
_onRequestEnd() // 当前请求结束计数(重试会重新计数)
if (isRefreshing) {
// 已有刷新请求在进行中,排队等待
return new Promise((resolve, reject) => {

View File

@@ -0,0 +1,13 @@
/**
* 储值等级格式化
* 规范docs/prd/specs/P13-miniapp-fe-polish.md §G4
*
* 阈值:= 0 → "无"、< 200 → "少"、< 500 → "一般"、< 1500 → "多"、≥ 1500 → "非常多"
*/
export function formatStorageLevel(balance: number | null | undefined): string {
if (balance === null || balance === undefined || balance === 0) return '无'
if (balance < 200) return '少'
if (balance < 500) return '一般'
if (balance < 1500) return '多'
return '非常多'
}

View File

@@ -3,18 +3,20 @@
* 纯函数,无 wx.* 依赖
*/
export type TaskType = 'callback' | 'priority_recall' | 'relationship'
export type TaskType = 'high_priority_recall' | 'priority_recall' | 'follow_up_visit' | 'relationship_building'
const TASK_TYPE_COLOR_MAP: Record<TaskType, string> = {
callback: '#0052d9',
priority_recall: '#e34d59',
relationship: '#00a870',
high_priority_recall: '#e34d59',
priority_recall: '#ed7b2f',
follow_up_visit: '#0052d9',
relationship_building: '#00a870',
}
const TASK_TYPE_LABEL_MAP: Record<TaskType, string> = {
callback: '回访',
high_priority_recall: '高优先召回',
priority_recall: '优先召回',
relationship: '关系构建',
follow_up_visit: '客户回访',
relationship_building: '关系构建',
}
/**

View File

@@ -109,6 +109,40 @@ export function formatIMTime(value: number | string | undefined | null): string
return `${date.getFullYear()}-${month}-${day} ${timeStr}`
}
/**
* 短日期格式化
* "3月15日" / "--"
*/
export function formatDateShort(date: string | Date | null | undefined): string {
if (!date) return '--'
const d = typeof date === 'string' ? new Date(date) : date
if (isNaN(d.getTime())) return '--'
return `${d.getMonth() + 1}${d.getDate()}`
}
/**
* 完整日期格式化
* "2026-03-15" / "--"
*/
export function formatDateFull(date: string | Date | null | undefined): string {
if (!date) return '--'
const d = typeof date === 'string' ? new Date(date) : date
if (isNaN(d.getTime())) return '--'
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* 天数格式化
* "3天" / "--"
*/
export function formatDays(days: number | null | undefined): string {
if (days === null || days === undefined || days === 0) return '--'
return `${days}`
}
/**
* 判断相邻消息是否需要显示时间分割线(间隔 >= 5 分钟,首条始终显示)
*/

View File

@@ -12,8 +12,8 @@
"outputPath": ""
},
"coverView": false,
"postcss": false,
"minified": false,
"postcss": true,
"minified": true,
"enhance": true,
"showShadowRootInWxmlPanel": false,
"packNpmManually": true,
@@ -30,10 +30,10 @@
"condition": true,
"es6": true,
"compileWorklet": false,
"uglifyFileName": false,
"uglifyFileName": true,
"uploadWithSourceMap": true,
"minifyWXSS": false,
"minifyWXML": false,
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"swc": false,
"disableSWC": true,

View File

@@ -33,10 +33,10 @@ interface ApiUserInfo {
user_id: number
status: 'new' | 'pending' | 'approved' | 'rejected' | 'disabled'
nickname: string
avatar_url?: string
role?: string
store_name?: string
coach_level?: string
avatar?: string
}
/** AUTH-4: 刷新 token 响应 */
@@ -54,7 +54,7 @@ interface ApiTaskItem {
id: string
customer_name: string
customer_avatar: string
task_type: 'callback' | 'priority_recall' | 'relationship' | 'high_priority'
task_type: 'high_priority_recall' | 'priority_recall' | 'follow_up_visit' | 'relationship_building'
task_type_label: string
deadline: string
heart_score: number
@@ -115,6 +115,14 @@ interface ApiTaskDetail extends ApiTaskItem {
service_records: ApiServiceRecord[]
ai_analysis: { summary: string; suggestions: string[] }
notes: ApiNoteItem[]
/** P13: 客户手机号 */
customer_phone?: string
/** P13: 放弃原因 */
abandon_reason?: string
/** P13: AI 行动建议列表 */
action_suggestions?: string[]
/** P13: 客户储值余额(用于前端计算储值等级) */
balance?: number
}
// ============================================
@@ -199,6 +207,8 @@ interface ApiCustomerDetail {
relation_index: string
tags: string[]
retention_clues: ApiRetentionClue[]
/** P13: 累计服务次数 */
total_service_count?: number
consumption_records: Array<{
id: string
date: string
@@ -236,6 +246,8 @@ interface ApiBoardCoachItem {
total_income: number
customer_count: number
satisfaction: number
/** P13: 任务执行统计 */
task_stats?: { recall: number; callback: number }
}
/** BOARD-2: 客户看板项 */

View File

@@ -1,3 +1,8 @@
/* AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | authUser 增加 role 字段globalData 增加 visibleTabs 字段 |
*/
/// <reference path="./types/index.d.ts" />
interface IAppOption {
@@ -8,6 +13,10 @@ interface IAppOption {
userId: number
status: string // pending / approved / rejected / disabled
nickname?: string
// CHANGE 2026-03-24 | 头像:存储用户头像 URL
avatarUrl?: string
// CHANGE 2026-03-23 | 角色路由:存储用户角色 code
role?: string // coach / staff / head_coach / manager
}
/** access_token */
token?: string
@@ -17,6 +26,10 @@ interface IAppOption {
currentSiteId?: number
/** 用户关联的店铺列表 */
sites?: Array<{ siteId: number; siteName: string; roles: string[] }>
// CHANGE 2026-03-23 | 角色路由tab-bar 可见 key 列表
visibleTabs?: string[]
// CHANGE 2026-03-27 | 权限改造 W5后端返回的权限码列表
permissions?: string[]
}
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback
}