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:
@@ -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 小图标(30rpx,6 种配色) |
|
||||
| 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-3,6 大板块 + 环比开关) |
|
||||
| `/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-list,H5 原型 1:1 重写,含四种任务类型分组、上下文菜单、备注弹窗)
|
||||
- [ ] 任务管理功能联调(置顶、放弃、备注 API 对接)
|
||||
- [ ] 数据看板页面(助教业绩、客户分析)
|
||||
- [ ] 会员中心页面
|
||||
- [ ] 助教预约功能
|
||||
- [ ] 订单查询功能
|
||||
- [x] 认证流程页面(登录 → 申请 → 等待审批 → 首页)
|
||||
- [x] 任务列表页面(task-list,四种任务类型分组、上下文菜单、备注弹窗)
|
||||
- [x] 任务详情页面(task-detail,维客线索、服务记录、AI 分析)
|
||||
- [x] 数据看板页面(助教/客户/财务三大看板)
|
||||
- [x] 绩效页面(performance,档位进度、收入明细、新客/常客)
|
||||
- [x] 客户详情页面(customer-detail,完整档案 + AI 洞察)
|
||||
- [x] 助教详情页面(coach-detail,业绩数据 + 客户列表)
|
||||
- [x] AI 对话页面(chat,SSE 流式输出 + 8 个 AI App)
|
||||
- [x] 个人中心页面(my-profile)
|
||||
- [x] VI 颜色系统统一(24 色头像色板、AI 配色系统)
|
||||
- [ ] 前后端联调(services/api.ts 当前为 mock 模式,待恢复真实 API 调用)
|
||||
- [ ] 多门店切换 UI
|
||||
- [ ] 消息通知(微信订阅消息)
|
||||
- [ ] CI/CD(代码检查、自动上传体验版)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 拦截处理)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '💙' // 蓝色 - 待发展
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.heart-icon {
|
||||
font-size: 22rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: -4rpx;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/* AI_CHANGELOG
|
||||
| 日期 | Prompt | 变更 |
|
||||
|------|--------|------|
|
||||
| 2026-03-23 | 角色路由+页面权限守卫 | 从 globalData.visibleTabs 动态读取可见 tab,pageLifetimes.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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 | 权限改造 W5:checkPageAccess 完成后再刷新 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 | P1:redirectTo 替代 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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 | 权限改造 W5:checkPageAccess 完成后再刷新 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 | P1:redirectTo 替代 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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 板块;筛选联动调 _loadData;areaOptions 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_flat;mapExpenseItems/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 返回 camelCase:giftRows[].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 | P1:redirectTo 替代 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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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 />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: 对齐后端 ServiceRecordItem(CamelModel),移除废弃字段
|
||||
/** 服务记录(对齐 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)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ page {
|
||||
line-height: 44rpx;
|
||||
}
|
||||
.value-primary { color: #0052d9; }
|
||||
.value-green { color: #2ba471; }
|
||||
.value-warning { color: #ed7b2f; }
|
||||
.summary-divider {
|
||||
width: 2rpx;
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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-pattern,error 色) -->
|
||||
<!-- 十字纹背景图案 -->
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 key(WXSS 不支持中文类名) */
|
||||
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()
|
||||
|
||||
@@ -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}} —</text>
|
||||
<text decode class="dd-stats" wx:if="{{item.totalHoursLabel}}">{{item.totalHoursLabel}} · 预估 {{item.totalIncomeLabel}} </text>
|
||||
<text decode class="dd-date">{{fmt.safe(item.date)}} —</text>
|
||||
<text decode class="dd-stats" wx:if="{{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时 · {{isCurrentMonth ? '预估 ' : ''}}¥{{fmt.safe(item.totalIncome)}} </text>
|
||||
<view class="dd-line"></view>
|
||||
</view>
|
||||
|
||||
<!-- 该日期下的记录 -->
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 key(WXSS 不支持中文类名) */
|
||||
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 | 头像:读 avatarUrl(CamelModel 转换后的字段名),拼接完整 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 | 优先用 taskId,fallback 用 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' }),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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}} —</text>
|
||||
<text decode class="dd-date">{{fmt.safe(item.date)}} —</text>
|
||||
|
||||
<text decode class="dd-stats" wx:if=" {{item.totalHours}}">{{item.totalHours}} · {{item.totalIncome}} </text>
|
||||
<text decode class="dd-stats" wx:if=" {{item.totalHours}}">{{fmt.safe(item.totalHours)}}小时 · ¥{{fmt.safe(item.totalIncome)}} </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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 | 清洗 serviceRecords:null 值转为组件期望的默认值
|
||||
// 小程序组件 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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 字段完整映射到 perfData(totalHours/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 是英文 code(coach/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_days(ideal_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_case(CamelModel 可能已转 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 是英文 code(coach/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 | 头像:读 avatarUrl(CamelModel 转换后的字段名),拼接完整 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,
|
||||
|
||||
@@ -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: 长按上下文菜单 ====== -->
|
||||
|
||||
@@ -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_type(high_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_type(high_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
|
||||
|
||||
@@ -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/SNOOKER;API 响应映射从
|
||||
// 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_case(page_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
|
||||
|
||||
211
apps/miniprogram/miniprogram/utils/auth-guard.ts
Normal file
211
apps/miniprogram/miniprogram/utils/auth-guard.ts
Normal 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-list(coach 场景)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.Nh;0 → 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.N;0 → 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.Nh;0 → 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,
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
/** 客户 ID(member_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: '',
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
13
apps/miniprogram/miniprogram/utils/storage-level.ts
Normal file
13
apps/miniprogram/miniprogram/utils/storage-level.ts
Normal 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 '非常多'
|
||||
}
|
||||
@@ -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: '关系构建',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 分钟,首条始终显示)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
apps/miniprogram/typings/api.d.ts
vendored
16
apps/miniprogram/typings/api.d.ts
vendored
@@ -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: 客户看板项 */
|
||||
|
||||
13
apps/miniprogram/typings/index.d.ts
vendored
13
apps/miniprogram/typings/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user