微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -20,7 +20,14 @@ apps/miniprogram/
│ ├── pages/ # 页面目录
│ │ ├── mvp/ # MVP 全链路验证页
│ │ ├── index/ # 首页
│ │ ├── login/ # 登录页
│ │ ├── apply/ # 入驻申请页
│ │ ├── reviewing/ # 审核中等待页
│ │ ├── no-permission/ # 无权限提示页
│ │ ├── dev-tools/ # 开发调试面板(仅 develop 环境)
│ │ └── logs/ # 日志页
│ ├── components/ # 全局组件
│ │ └── dev-fab/ # 浮动调试按钮(仅 develop 环境显示)
│ ├── utils/ # 工具函数
│ │ ├── config.ts # 环境配置API 地址自动切换)
│ │ └── util.ts # 通用工具(日期格式化等)
@@ -52,6 +59,11 @@ apps/miniprogram/
|------|------|
| `pages/mvp/mvp` | MVP 全链路验证(从后端读取测试数据) |
| `pages/index/index` | 首页(待开发) |
| `pages/login/login` | 登录页 |
| `pages/apply/apply` | 入驻申请页 |
| `pages/reviewing/reviewing` | 审核中等待页 |
| `pages/no-permission/no-permission` | 无权限提示页 |
| `pages/dev-tools/dev-tools` | 开发调试面板(仅 develop 环境,通过 dev-fab 浮动按钮进入) |
| `pages/logs/logs` | 日志页(框架默认) |
## 后端 API 集成
@@ -73,9 +85,9 @@ apps/miniprogram/
```
wx.login() 获取 code
POST /api/xcx-auth/login → 获取 JWT受限令牌
POST /api/xcx-auth/login → 获取 JWT受限令牌status=new
POST /api/xcx-auth/apply → 提交入驻申请球房ID + 身份 + 手机号)
POST /api/xcx-auth/apply → 提交入驻申请球房ID + 身份 + 手机号status → pending
管理员在后台审批
@@ -86,20 +98,44 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
正常使用业务功能
```
用户状态流转:
- `new`:新用户,尚未提交申请
- `pending`:已提交申请,等待审批
- `approved`:审批通过,可正常使用
- `rejected`:审批拒绝,可重新申请
- `disabled`:账号禁用
令牌类型:
- 受限令牌(`limited=True`pending 用户,仅可访问申请和状态查询端点
- 受限令牌(`limited=True`new/pending/rejected 用户,仅可访问申请和状态查询端点
- 完整令牌approved 用户,包含 `user_id` + `site_id` + `roles`
### 开发模式
后端支持开发模式(`WX_DEV_MODE=true`),提供 mock 登录端点跳过微信 code2Session
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/xcx-auth/dev-login` | POST | 开发模式 mock 登录(仅开发环境) |
参数:
- `openid`:模拟的微信 openid
- `status`可选指定用户状态new/pending/approved/rejected
### 关键 API 端点
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/xcx-auth/login` | POST | 微信登录code → JWT |
| `/api/xcx-auth/dev-login` | POST | 开发模式 mock 登录(仅开发环境) |
| `/api/xcx-auth/apply` | POST | 提交入驻申请 |
| `/api/xcx-auth/status` | GET | 查询用户状态和申请记录 |
| `/api/xcx-auth/sites` | GET | 获取关联门店列表 |
| `/api/xcx-auth/switch-site` | POST | 切换当前门店 |
| `/api/xcx-auth/refresh` | POST | 刷新令牌 |
| `/api/xcx/tasks` | GET | 获取任务列表 |
| `/api/xcx/tasks/{task_id}/pin` | POST | 置顶/取消置顶任务 |
| `/api/xcx/tasks/{task_id}/abandon` | POST | 放弃/取消放弃任务 |
| `/api/xcx/notes` | GET/POST/DELETE | 备注 CRUD |
| `/api/xcx-test` | GET | MVP 全链路验证 |
> 完整 API 文档见 [`apps/backend/docs/API-REFERENCE.md`](../backend/docs/API-REFERENCE.md)
@@ -130,9 +166,33 @@ POST /api/xcx-auth/login → 重新登录获取完整令牌(含 site_id + ro
- H5 原型设计稿位于 `docs/h5_ui/`
- 认证数据存储在 `zqyy_app` 数据库的 `auth` Schema
## 开发调试面板dev-tools
仅在 develop 环境可用的调试工具通过页面底部浮动按钮dev-fab 组件)进入。
功能:
- 展示当前用户上下文(角色、权限、绑定关系、门店信息)
- 一键切换角色coach / staff / site_admin / tenant_admin后端真实修改 `user_site_roles` 并重签 token
- 一键切换用户状态new / pending / approved / rejected / disabled后端真实修改 `users.status` 并重签 token
- 页面跳转列表,点击可跳转到任意已注册页面
安全保障:
- dev-fab 组件通过 `wx.getAccountInfoSync().miniProgram.envVersion` 判断环境,仅 `develop` 时渲染
- 后端 dev 端点仅在 `WX_DEV_MODE=true` 时注册路由,生产环境不可访问
依赖的后端端点(均需 JWT
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/xcx/dev-context` | GET | 获取当前用户调试上下文 |
| `/api/xcx/dev-switch-role` | POST | 切换角色 |
| `/api/xcx/dev-switch-status` | POST | 切换用户状态 |
| `/api/xcx/dev-switch-binding` | POST | 切换绑定关系 |
## Roadmap
- [ ] 完善认证流程页面(登录 → 申请 → 等待审批 → 首页)
- [ ] 任务管理页面(任务列表、置顶、放弃、备注)
- [ ] 数据看板页面(助教业绩、客户分析)
- [ ] 会员中心页面
- [ ] 助教预约功能

View File

@@ -1,997 +0,0 @@
# 一、文档信息
* 产品名称:球房运营助手(微信小程序)
* 版本V1.0(原型版,全权限视角)
* 撰写日期YYYY-MM-DD
* 适用平台微信小程序iOS / Android 手机竖屏)
* 文档范围:仅描述小程序前端界面与交互行为,不包含后端服务和接口字段定义。
---
# 二、背景与目标
本小程序用于提升台球厅经营管理效率,为店长、助教管理、助教等内部人员提供任务管理、业绩查看、运营看板和智能助手对话能力。
当前阶段目标:
* 交付一套基于“全功能、全权限角色视角”的微信小程序前端原型。
* 明确各页面布局、组件及交互行为,便于前端和原型工具直接实现。
* 角色权限控制、数据口径、字段来源均由后端与后续迭代处理,原型仅展示有权限时的页面样式。
---
# 三、范围与约束说明
1. **设备与环境**
* 仅面向手机端微信小程序iOS / Android竖屏使用。
* 暂不考虑 iPad 等大屏适配。
2. **门店范围**
* 当前仅支持一个店铺场景,后端如扩展多门店,在后续版本处理。
3. **权限与角色**
* 原型以“全功能视角”展示所有模块与入口。
* 实际上线时,不同角色(店长/管理层/助教管理/助教)的权限由后端接口控制,对无权限功能采取“入口隐藏”的方式。
* 原型中不绘制模块级“无权限访问”占位状态。
4. **接口与数据**
* 本文不描述具体接口、字段名、数据结构。
* 各类展示字段以接口实际返回为准,本文若举例字段,仅为示意,不代表完整字段列表。
5. **登录/申请流程的权限提示**
* 登录后如账号未通过审核或无访问权限,将展示对应状态页(审核中、无权限),这属于整体访问控制,不属于“模块权限占位”,在原型中需要体现。
---
# 四、角色说明(仅用于理解,不做权限逻辑)
* 店长 / 公司管理层:实际场景中拥有全功能权限。
* 助教管理:看板中财务板块不可见(上线时通过隐藏入口实现)。
* 助教:看板中财务板块和助教板块不可见(上线时通过隐藏入口实现)。
原型中统一以“全功能视角”展示,不做差异。
---
# 五、全局设计规范
## 5.1 语言与格式
* 语言:简体中文。
* 金额单位:
* 元:取整,不显示小数。
* 万元:保留两位小数。
* 时间显示格式:
* 标准格式:`YYYY-MM-DD HH:mm:ss`
* 在不影响理解情况下,可根据页面需要简化为 `YYYY-MM-DD``MM-DD HH:mm` 等,具体由设计与前端协商。
## 5.2 导航与返回规则
* 底部一级导航TabBar
* Tab 顺序:任务 / 看板 / 我的
* 文字:`任务``看板``我的`
* 每个 Tab 对应一个一级页面,点击 Tab 时:
* 若当前已在该 Tab 内的子页面,点击 Tab 返回该 Tab 的根页面,并滚动至顶部。
* 顶部导航:
* 除特别说明外,二级/详情页隐藏微信原生导航栏,使用自定义头部,左上角为返回图标,行为为返回上一页面。
* 弹窗与浮层:
* 使用标准底部弹出或中部弹窗,与微信交互习惯一致。
## 5.3 悬浮助手按钮
* 悬浮按钮在所有业务页面(任务、看板、我的及其子页面)显示,不在“登录/申请/审核中/无权限”页面显示。
* 默认位置:页面右下角(不遮挡底部 TabBar随页面滚动悬浮。
* 点击行为:进入“助手对话页面”,默认打开最近一次会话(若有)。
## 5.4 提示、错误与加载状态
* **网络异常 / 接口错误(列表/卡片区域)**
* 在对应数据区域显示文字:`加载失败,请点击重试`
* 下方提供“重试”按钮,点击重新请求该区域数据。
* 作为所有列表/卡片区域的统一错误样式。
* **空数据状态**
* 统一使用简单文字:
* 列表类统一为:`暂无数据` 或根据场景显示 `暂无任务` 等。
* 不使用插画或占位图。
* **加载状态**
* 使用区域加载:在列表或卡片区域显示文字:`加载中...`
* 不做骨架屏和复杂动画。
---
# 六、信息架构与页面列表
## 6.1 顶层结构
* 登录相关
* 登录页
* 账号申请页
* 审核中页
* 无权限页
* Tab 1任务
* 任务列表页(默认首页)
* 任务详情页
* 业绩详情页
* Tab 2看板
* 看板首页(含:财务 / 客户 / 助教 三级视图)
* 客户详情页
* 助教详情页
* Tab 3我的
* 我的首页
* 备注记录页
* 助手对话记录页
* 首页设置页
* 退出账号(确认弹窗)
* 全局
* 助手对话页
---
# 七、关键流程说明
## 7.1 登录与申请流程
1. 用户打开小程序 → 登录页。
2. 点击“使用微信登录”,完成微信授权。
3. 登录后:
* 若查无此用户,也无此用户提交过申请 → 进入账号申请页。
* 若查到该用户提交过申请,状态为“审核中” → 进入“审核中”状态页。
* 若查到该用户提交过申请,状态为“拒绝/未通过” → 进入“无权限”状态页。
* 若查到该用户申请已通过 → 跳转至用户设置的默认首页(初始为“任务”页)。
## 7.2 默认首页配置流程
* 初始默认首页为“任务”。
* 用户可在“我的 → 首页设置”中将首页设置为:任务 / 看板。
* 设置为“切换即保存”,与账号绑定(不因退出登录而重置)。
---
# 八、页面级需求
以下各页面按【页面名称 / 入口 / 布局结构 / 功能与交互 / 状态】描述。
---
## 8.1 登录与访问控制相关
### 8.1.1 登录页
**入口**
* 小程序启动未登录状态。
**布局结构**
* 顶部App Logo 占位 + 应用名称(球房运营助手)。
* 中部:一句产品描述文案,例如:
* `为台球厅提升运营效率的内部管理工具`
* 底部区域:
* 主按钮:`使用微信登录`(微信授权登录入口)。
* 下方文案 + 勾选框:
* 复选框 + 文案:`我已阅读并同意《用户协议》和《隐私政策》`
* 协议名称为可点击文本(具体跳转页面可后续补充)。
**功能与交互**
* 用户必须勾选协议复选框才能点击“使用微信登录”,否则按钮为禁用态。
* 点击“使用微信登录”调用微信授权流程,登录成功后进入流程 7.1 所述分支。
* 登录失败时在底部弹出错误提示Toast重试留在本页。
---
### 8.1.2 账号申请页
**入口**
* 登录成功后,系统查无该用户及其申请记录时。
**布局结构**
* 顶部:标题 `申请访问权限`
* 主体:
* 文本说明:简单说明需要申请原因,例如:
* `请填写申请说明,审核通过后即可使用小程序功能。`
* 表单区:
* 字段 1
* 标签:`申请说明`,带红色星号(必填)。
* 多行文本输入框,用于填写自我介绍、岗位、所属门店等说明(具体内容由用户自由填写)。
* 底部:
* 主按钮:`提交申请`
**功能与交互**
* “申请说明”为必填,如为空则点击“提交申请”时在输入框下方显示错误提示:`申请说明不能为空`
* 提交成功后,进入“审核中”页。
* 接口错误时,弹出错误提示,停留在本页。
---
### 8.1.3 审核中页
**入口**
* 登录后发现该用户有申请记录,状态为“审核中”。
**布局结构**
* 居中展示:
* 图标(等待/审核中占位图标)。
* 标题文案:`申请审核中`
* 说明文案:例如:`您的访问申请正在审核中,请稍后再试或联系管理员。`
* 不提供其他操作按钮,保持不可操作状态。
**功能与交互**
* 用户可关闭小程序或退出;再次进入时仍按登录逻辑判断状态。
---
### 8.1.4 无权限页
**入口**
* 登录后发现该用户申请状态为“拒绝/未通过”,或无访问权限。
**布局结构**
* 居中展示:
* 图标(禁止/无权限占位图标)。
* 标题文案:`无访问权限`
* 说明文案:例如:`您的访问申请未通过,或当前账号无访问权限。如需使用,请联系管理员。`
* 不提供操作按钮,不可操作状态。
**功能与交互**
* 用户可关闭小程序或退出;如后续权限变更,再次登录时可进入首页。
---
## 8.2 Tab任务
### 8.2.1 任务列表页(默认首页)
**入口**
* 底部 TabBar 点击“任务”。
* 登录通过后,如未设置其他默认首页,则默认进入本页。
**整体布局**
* 顶部:自定义导航栏(标题:`任务`),左侧无返回按钮。
* Banner 区:当前用户信息与业绩概览。
* 任务列表:按紧急程度排序的任务列表。
* 悬浮助手按钮:右下角。
**Banner 区内容**
* 展示内容示例:
* 第一行:`用户名` + `身份`(例如:张三 / 助教)
* 第二行:一句聚合文案,例如:
`本月目标 5 万,已完成 3 万,任务 50 个,完成进度 60%`
* 第三行:`X 月预计收入12345 元`(单位为元,取整)
* Banner 整块区域可点击,跳转至“业绩详情页”。
**任务列表结构**
* 列表为单列列表,不按任务类型分组,仅通过排序和颜色区分。
* 排序规则:按紧急程度从高到低排序,类型依次为:
* 高优先召回(红)
* 优先召回(橙)
* 关系构建(粉)
* 客户回访(蓝)
**单条任务卡片布局**
* 第一行(标题行):
* 左侧:任务类型标签(带背景色的颜色块或 icon颜色按类型区分红/橙/粉/蓝)。
* 紧随其后:客户姓名。
* 右侧:`>` 箭头图标,提示可点击进入详情。
* 第二行(补充行):
* 核心信息 + 召回说明,具体字段根据当前任务类型与接口返回内容展示,例如:最近到店时间、召回原因、优先级说明等。
* 其他:
* 不提供搜索框和筛选组件,任务集合由接口控制。
**交互说明**
* 点击整条任务卡片:进入“任务详情页”。
* 长按任务卡片:在长按位置上方弹出黑底浮层菜单,样式类似微信对话长按菜单,菜单项:
* `任务置底`
* `问问助手`
* `备注`
* “任务置底”:前端仅调用接口,排序规则由后端控制;前端不单独维护生命周期状态。
* “问问助手”:跳转至“助手对话页”,以该任务信息为引用,开启新对话主题。
* “备注”:弹出底部浮层,输入备注内容并保存,备注按时间排序纳入“备注记录”。
**空状态**
* 当列表为空时,在列表区域居中显示文案:`暂无任务`
---
### 8.2.2 任务详情页
**入口**
* 任务列表页点击某条任务。
**布局结构**
* 顶部:自定义导航栏
* 左:返回按钮 `<`
* 中:标题,例如 `任务详情`
* 主体内容区:
* 模块一:客户基本信息
* 示例字段:姓名、手机号、会员编号、性别、标签(如 VIP/新客)、所属门店等(以接口为准)。
* 模块二:消费习惯
* 文本描述形式,例如:“偏好晚间 21:00 后到店,喜欢中式台球,平均消费 300 元/月”等。
* 模块三:与我的关系
* 等级:很好 / 好 / 一般 / 较陌生
* 每个等级附带一段文字说明(例如“最近 3 个月每周均有1次课程”等
* 模块四:任务建议
* 纯文本内容,给出执行建议、沟通话术提示等。
* 底部固定操作栏:
* 左按钮:`问问助手`
* 右按钮:`备注`
**交互说明**
* `问问助手`
* 跳转至助手对话页。
* 以当前任务的关键信息(任务类型、客户名、任务说明等)作为引用内容,显示为灰底卡片,用户在其下输入文本发送。
* 通过此入口固定新建一个新对话主题。
* `备注`
* 底部弹出浮层,包含备注输入框和“保存”按钮。
* 保存后生成一条备注记录,类型标记为“任务备注”,记入“备注记录”,按照创建时间倒序展示。
---
### 8.2.3 业绩详情页
**入口**
* 任务列表页 Banner 区点击。
**布局结构**
* 顶部 Banner
* 展示:用户名 + 身份 + 本月业绩进度 + 本月预计收入。
* 示例:
* 第一行:`张三(助教)`
* 第二行:`本月目标5 万已完成3 万任务50 个完成进度60%`
* 第三行:`本月预计收入1.23 万元`
* 下方内容区:多组指标,以两列卡片布局展示。
**指标分组示意**
* 分组一:`收入构成`
* 分组二:`台球助教业绩`
* 分组三:`充值业绩`
* 分组四:`酒水业绩`
每组都有组标题一行,下面为两列卡片网格。
**单个指标卡片内容**
* 布局:
* 卡片内上下两行,可视为“名称行 + 数据行”。
* 字段:
* 指标名称
* 当前值
* 目标值
* 完成度(百分比)
* 对齐与单位:
* 数值区居中对齐。
* 单位规则:
* 元:整数,无小数。
* 万元:保留 2 位小数。
* 完成度:使用 `%`
**交互**
* 页面整体可滚动。
* 卡片本身无需额外交互(本期不跳转、不长按)。
**时间范围**
* 本页仅展示当前“本月”的业绩数据,不提供时间周期切换。
---
## 8.3 Tab看板
### 8.3.1 看板首页(含财务/客户/助教)
**入口**
* 底部 TabBar 点击“看板”。
**顶部区域**
* 一级标签(顶部 Tab
* `财务` / `客户` / `助教`
* 默认选中:`财务`(原型中展示全功能视角)
**筛选区域**
* 位置:一级标签下方。
* 展示方式:多标签筛选按钮,每个按钮点击后展开下拉菜单,交互类似外卖/点评类应用。
* 联动规则:
* 更改任一筛选条件后,立即刷新当前视图数据(无需额外“确定”按钮)。
* 不提供“重置筛选”按钮。
**滚动行为**
* 当用户向上滚动列表内容时,筛选区域保持吸顶显示。
* 当用户向下快速滚动时,可自动收起/隐藏筛选区域,仅保留一级 Tab增强可视区域。
* 向上滚动时再次展示筛选区域。
---
### 8.3.2 看板 财务视图
**入口**
* 看板顶部 Tab 选择“财务”(默认)。
**筛选条件**
* 条件 1时间月份
* 选项:
1. 本月(默认)
2. 上个月
3. 最近 3 个月
4. 最近半年
5. 本季度
6. 上个季度
7. 本周
8. 上周
9. 指定时间周期
* 选择“指定时间周期”时:
* 打开日期区间选择组件,可选择开始日期与结束日期。
* 最大跨度366 天。
* 当用户选择的时间跨度超过 366 天时,非模态提示:例如 `时间跨度不可超过 366 天`,并阻止该选择生效。
* 条件 2区域
* 选项:
1. 全部(默认)
2. 大厅子级A 区、B 区、C 区)
3. 麻将房
4. 团建房
5. 具体房间台桌
* 选择“具体房间台桌”时:
* 弹出选择弹窗,列表单选。
* 列表按“大厅 / 麻将房 / 团建房”分组展示具体房间或台桌。
* 如接口获取失败或为空,在弹窗中显示:`网络错误,请重试`,并提供“重试”入口。
**财务汇总行**
* 展示位置:筛选区域下方第一行。
* 分为三列:
* 当前筛选条件下实际收入
* 当前筛选条件下实际支出
* 当前筛选条件下净利润
* 显隐与“预计”字样:
* 某些筛选条件下不显示支出与净利润,由接口控制。
* 某些时间维度(例如本月、本周等)可显示“预计”字样:`12345 元(预计)`,由接口在数据中标记。
**内容分区**
分为四个部分,依次:
1. 营业数据
2. 收入构成
3. 支出构成
4. 利润构成
每一部分包含:
* 标题行:如 `营业数据`
* 指标卡片区:每行 3 个卡片,自动换行。
**指标卡片结构**
* 每个卡片:
* 第一行(标题行):左侧图标(简单占位)、右侧为指标名称(例如“总流水”、“客单价”等)。
* 第二行(详情行):文字 + 数值,或文字 / 数值单独展示:
* 例如:`本期12345 元`,或 `毛利率35%`
* 指标列表(示意,实际由接口控制):
* 营业数据:总流水、客单价、开台数、场次、平均停留时长等。
* 收入构成:桌费、助教费、酒水、餐饮、包房费、其他。
* 支出构成:房租、水电、人工、耗材、推广等。
* 利润构成:毛利、净利、毛利率、净利率等。
**交互**
* 长按任意指标卡片:
* 启动助手对话,跳转至“助手对话页”,以该指标为引用内容(来源:财务看板 + 指标名 + 当前数值等),开启新对话主题。
* 列表下拉刷新,重新拉取数据。
---
### 8.3.3 看板 客户视图
**入口**
* 看板顶部 Tab 选择“客户”。
**筛选条件**
* 条件 1客户类型
* 最近到店:按最近到店时间由近到远。
* 最应召回:按当天召回因子由高到低(默认)。
* 最近充值:按充值时间由近到远。
* 最高消费:最近 60 天到店消费金额由高到低(不含充值)。
* 最高余额:按单个客户所有会员卡金额总计由高到低。
* 最频繁:最近 60 天到店次数由多到少。
* 潜力股:最近 60 天到店间隔有缩短趋势的客户。
* 最专一:最近 60 天使用助教服务 ≥10 次,且 ≥8 次为同一助教,最近 2 次均为该助教。
* 条件 2偏爱项目
* 不限(默认)
* 中式/追分
* 斯诺克
* 麻将
* 团建
**助教身份默认筛选(后台行为,前端不显式展示)**
* 当登录用户身份为“助教”时,后台默认增加过滤条件:仅显示最近 14 天内该助教提供过课程服务的客户。
* 前端不提供取消或修改该条件的开关,也不在 UI 中单独标识。
**客户列表卡片布局**
* 第一行:
* 左侧:
* 客户名称
* 等级标(如等级图标或字母 A/B/C
* VIP 标识如“VIP”标签有则显示。
* 右侧:最喜欢的助教列表,文字形式展示,例如:
* `💖 助教A、💖 助教B、💛 助教C...`
* 最多展示前三,超过则以省略号表示。
* 第二行:
* 当前排序条件对应的核心指标(如召回因子、储值金额、累计消费等)。
* 最近到店时间(副文案)。
* 可在末尾增加一句简短说明,例如:`最近 30 天到店 5 次` 等。
**其他字段**
* 在“最高余额”等维度时,应显示该客户当前余额字段(由接口提供),格式按金额规则显示。
**交互**
* 点击客户卡片:进入“客户详情页”。
* 长按客户卡片(可选):可考虑后续扩展为快速备注或助手入口,本期可不实现。
---
### 8.3.4 客户详情页
**入口**
* 看板 客户视图列表点击某客户。
**布局**
* 顶部导航:标题 `客户详情`,左侧返回按钮。
* 模块一:客户基本信息
* 示例字段姓名、手机号、会员编号、性别、等级、VIP 标识、所属门店等。
* 手机号可支持点击拨号(后续实现时决定)。
* 模块二:消费习惯
* 标签 + 文本说明的形式:
* 标签示例:`常来夜场``偏爱中式``高客单价` 等。
* 文本说明:简要描述消费偏好、时段、频率等。
* 模块三:与我的关系
* 等级:很好 / 好 / 一般 / 较陌生。
* 等级下附文字说明,描述互动频率、最近服务情况等。
* 底部固定操作栏:
* `问问助手`
* `备注`
**交互**
* `问问助手`
* 跳转到助手对话页。
* 引用当前客户的关键信息客户名、ID、最近消费等作为灰底引用卡片。
* 开启新对话主题。
* `备注`
* 底部弹出备注输入浮层,类型标记为“客户备注”,保存后进入“备注记录”。
---
### 8.3.5 看板 助教学视图
**入口**
* 看板顶部 Tab 选择“助教”。
**筛选条件**
* 条件 1排序维度
* 创收最多(默认):按该助教带来的球房流水由高到低。
* 创收最低:按球房流水由低到高。
* 业绩最高:按业绩完成百分比由高到低。
* 业绩最低:按业绩完成百分比由低到高.
* 工资最高:按工资由高到低。
* 工资最低:按工资由低到高。
* 潜在客源储值:按该助教客户关系 >0.7 的所有客户储值金额总和由高到低。
* 条件 2擅长项目
* 不限(默认)
* 中式/追分
* 斯诺克
* 麻将
* 团建
* 条件 3时间月份
* 同财务视图:本月(默认)、上月、最近 3 个月、最近半年、本季度、上个季度、本周、上周、指定时间周期。
* “指定时间周期”同样使用日期区间选择组件,并限制最大跨度 366 天,超出时非模态提示。
**助教列表卡片布局**
* 第一行:
* 左侧:
* 助教姓名
* 等级标(如星级/等级)
* 擅长项目(标签形式,展示主擅长方向)。
* 右侧:
* 关系最好的客户列表,展示客户名称和关系指数(例如:`客户A 0.98、客户B 0.92、客户C 0.89...`),最多展示前三,超过以省略号表示。
* 第二行:
* 当前排序维度对应的数值信息,附单位/说明:
* 如创收最多:`本期流水12345 元`
* 业绩最高:`完成度87%`
* 工资:`本期工资8000 元`
* 上课时长等(小时)。
* 不显示头像。
**交互**
* 点击助教卡片:进入“助教详情页”。
---
### 8.3.6 助教详情页
**入口**
* 看板 助教视图列表点击某助教。
**布局**
* 顶部导航:标题 `助教详情`,左侧返回按钮。
* 模块一:助教基本信息
* 字段示例:姓名、工号、所属门店、擅长项目、等级等。
* 模块二:流水与业绩
* 本月带来的球房流水(数值,单位元或万元)。
* 最近 3 个月带来的球房流水(数值)。
* 综合业绩完成度(一个总进度百分比)。
* 模块三:工资与上课时长
* 本月工资总额。
* 对应时间段的上课总时长(小时)。
* 模块四:前 10 个客户指数最高的客户列表
* 列表项字段:客户名 + 指数数值01 或百分比展示)。
* 底部固定操作栏:
* `问问助手`
* `备注`
**交互**
* `问问助手`:以助教信息和主要指标为引用,开启新对话主题。
* `备注`:对该助教添加备注记录,类型为“助教备注”。
---
## 8.4 Tab我的
### 8.4.1 我的首页
**入口**
* 底部 TabBar 点击“我的”。
**布局**
* 顶部:用户信息区域
* 用户名、身份(店长/助教等)、所属门店等信息。
* 列表菜单项:
* `备注记录`
* `助手对话记录`
* `首页设置`
* `退出账号`
**交互**
* 点击各行进入对应子页面或触发弹窗。
---
### 8.4.2 备注记录页
**入口**
* “我的”首页点击 `备注记录`
**布局**
* 标题:`备注记录`
* 列表按时间倒序(由近到远)平铺,不按日期分组。
* 每条记录显示:
* 备注全文(不做截断或只做必要的单行/多行控制)。
* 关联对象:例如 `客户:张三` / `任务XXX` / `助教:李四`
* 创建时间。
**交互**
* 点击备注记录:不进入详情页(即本页即为详情展示),不支持编辑/删除。
* 列表为空时:显示 `暂无数据`
---
### 8.4.3 助手对话记录页
**入口**
* “我的”首页点击 `助手对话记录`
**布局**
* 标题:`助手对话记录`
* 列表项字段:
* 对话标题:由接口返回(一般为首条消息摘要)。
* 最近一次对话时间。
* 消息条数概览(例如:`共 25 条消息`)。
* 列表按最近更新时间倒序。
**交互**
* 点击某条记录:
* 进入“助手对话页”,直接打开该会话。
* 默认滚动到该对话的最后一条消息位置。
* 不提供删除对话能力。
* 列表为空时:显示 `暂无数据`
---
### 8.4.4 首页设置页
**入口**
* “我的”首页点击 `首页设置`
**布局**
* 标题:`首页设置`
* 内容:
* 单选列表:
* `任务`
* `看板`
* 每项前有单选圆点,当前选中项高亮。
* 底部:返回按钮。
**交互**
* 用户点击某一选项后立即生效,作为新的默认首页设置(切换即保存,不需要额外保存按钮)。
* 退出账号不会清除该设置,再次登录仍使用该默认首页。
---
### 8.4.5 退出账号(确认弹窗)
**入口**
* “我的”首页点击 `退出账号`
**交互**
* 弹出确认弹窗:
* 标题:`确认退出`
* 文案:`确认退出当前账号吗?`
* 按钮:
* 取消
* 退出
* 点击“退出”:
* 清除登录态。
* 不清理由于当前账号相关的本地配置(如首页设置、筛选条件等)。
* 跳转回“登录页”。
* 点击“取消”:关闭弹窗,留在“我的”页。
---
## 8.5 全局:助手对话页
### 8.5.1 入口
* 悬浮助手按钮。
* 任务详情页底部按钮“问问助手”。
* 客户详情页底部按钮“问问助手”。
* 助教详情页底部按钮“问问助手”。
* 看板各视图中长按指标启动助手。
* “助手对话记录”页点击某一条历史记录。
### 8.5.2 聊天形式
* 对话双方:用户(“我”)与“助手”。
* UI 全面仿微信对话界面:
* 左侧:助手气泡,显示助手头像(固定)和名称。
* 右侧:用户气泡,显示用户头像和名称。
* 对话记录:
* 最近 50 条消息默认加载。
* 上拉加载更早记录。
### 8.5.3 引用内容展示
* 从任务/客户/助教/看板等入口进入助手时,将引用上下文:
* 引用内容显示为一块灰底小卡片,位于即将发送的消息气泡上方。
* 卡片内容包括:
* 来源类型:任务 / 客户 / 助教 / 看板(具体子模块如财务/客户看板等)。
* 标题或名称(例如客户名、任务标题、指标名)。
* 部分摘要文案或关键数据。
* 引用内容不可编辑,用户只能在下方输入框中补充自己的提问文本后发送。
### 8.5.4 会话管理
* 每个“新对话主题”形成一个独立会话,出现在“助手对话记录”列表中。
* 来源:
* 从“助手对话记录”进入:
* 直接打开对应会话,加载历史记录,滚动到最后一条消息。
* 如距最后一条消息超过 1 小时,在输入框区域上方显示横条提示,提供两个按钮:
* `新对话主题`
* `继续对话`
* 选择“新对话主题”:清空当前对话展示区域,开始新的对话会话,该会话作为新条目记录在“助手对话记录”中,历史对话仍保留在原会话条目中。
* 选择“继续对话”:在当前会话中继续发送消息,对话标题不变。
* 从任务/客户/助教/看板入口的“问问助手”或长按启动:
* 固定开启“新对话主题”,不受 1 小时规则影响,始终新建会话,并带入引用内容。
### 8.5.5 输入与发送
* 输入区包含:
* 文本输入框。
* “按住说话”按钮(语音转文字)。
* 发送按钮。
**语音转文字交互**
* 点击“按住说话”按钮并按住:
* 显示录音状态动画,松开后结束录音并开始识别。
* 识别结果展示在输入框中,用户可编辑后再点击“发送”。
* 识别失败时,弹出提示:`识别失败,请重试`,不发送消息。
**键盘与滚动行为**
* 键盘弹出时,列表自动滚动到底部,确保最新消息和输入框可见。
* 发送消息后,自动滚动到底部。
---
# 九、非功能性要求
* 关键页面(任务列表、看板页、助手对话页)首次可接受加载时间:≤ 10 秒(在普通网络环境下)。
* 看板页面数据:
* 各数据块采用懒加载策略,优先加载当前视图及首屏必要数据,其他部分可在滚动时或后台加载,避免一次性加载过多影响首屏体验。
* 本阶段不做埋点与统计需求设计。

View File

@@ -0,0 +1,12 @@
/** @type {import('jest').Config} */
module.exports = {
testMatch: ['**/tests/**/*.test.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: 'tsconfig.test.json',
}],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/miniprogram/$1',
},
}

View File

@@ -1,14 +1,65 @@
{
"pages": [
"pages/mvp/mvp",
"pages/login/login",
"pages/apply/apply",
"pages/reviewing/reviewing",
"pages/no-permission/no-permission",
"pages/task-list/task-list",
"pages/board-finance/board-finance",
"pages/my-profile/my-profile",
"pages/task-detail/task-detail",
"pages/task-detail-callback/task-detail-callback",
"pages/task-detail-priority/task-detail-priority",
"pages/task-detail-relationship/task-detail-relationship",
"pages/notes/notes",
"pages/performance/performance",
"pages/performance-records/performance-records",
"pages/board-customer/board-customer",
"pages/board-coach/board-coach",
"pages/customer-detail/customer-detail",
"pages/customer-service-records/customer-service-records",
"pages/coach-detail/coach-detail",
"pages/chat/chat",
"pages/chat-history/chat-history",
"pages/index/index",
"pages/logs/logs"
"pages/dev-tools/dev-tools",
"pages/logs/logs",
"pages/mvp/mvp"
],
"tabBar": {
"color": "#8b8b8b",
"selectedColor": "#0052d9",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/task-list/task-list",
"text": "任务",
"iconPath": "assets/icons/tab-task.png",
"selectedIconPath": "assets/icons/tab-task-active.png"
},
{
"pagePath": "pages/board-finance/board-finance",
"text": "看板",
"iconPath": "assets/icons/tab-board.png",
"selectedIconPath": "assets/icons/tab-board-active.png"
},
{
"pagePath": "pages/my-profile/my-profile",
"text": "我的",
"iconPath": "assets/icons/tab-my.png",
"selectedIconPath": "assets/icons/tab-my-active.png"
}
]
},
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "Weixin",
"navigationBarTitleText": "球房运营助手",
"navigationBarBackgroundColor": "#ffffff"
},
"usingComponents": {
"dev-fab": "/components/dev-fab/dev-fab"
},
"componentFramework": "glass-easel",
"lazyCodeLoading": "requiredComponents"
}
}

View File

@@ -1,18 +1,70 @@
// app.ts
// 应用入口 — 启动时检查登录状态并路由到对应页面
import { request } from "./utils/request"
App<IAppOption>({
globalData: {},
onLaunch() {
// 展示本地存储能力
const logs = wx.getStorageSync('logs') || []
logs.unshift(Date.now())
wx.setStorageSync('logs', logs)
// 登录
wx.login({
success: res => {
console.log(res.code)
// 发送 res.code 到后台换取 openId, sessionKey, unionId
},
})
onLaunch() {
// 从 Storage 恢复 token 和用户信息
const token = wx.getStorageSync("token")
const refreshToken = wx.getStorageSync("refreshToken")
const userId = wx.getStorageSync("userId")
if (token) {
this.globalData.token = token
this.globalData.refreshToken = refreshToken
if (userId) {
this.globalData.authUser = {
userId,
status: wx.getStorageSync("userStatus") || "new",
}
}
// 有 token → 查询最新用户状态并路由
this.checkAuthStatus()
}
// 无 token → 停留在 login 页(首页已设为 login
},
})
async checkAuthStatus() {
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
// 持久化用户信息
this.globalData.authUser = {
userId: data.user_id,
status: data.status,
nickname: data.nickname,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.status)
// 根据状态路由
switch (data.status) {
case "approved":
wx.reLaunch({ url: "/pages/task-list/task-list" })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
break
case "new":
wx.reLaunch({ url: "/pages/apply/apply" })
break
case "rejected":
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" })
break
}
} catch {
// token 无效或网络错误 → 停留在 login 页
}
},
})

View File

@@ -8,3 +8,104 @@
padding: 200rpx 0;
box-sizing: border-box;
}
/* ============================================
* 设计 Token 全局变量(基于 design-tokens.json
* ============================================ */
page {
/* 颜色 */
--color-primary: #0052d9;
--color-primary-light: #ecf2fe;
--color-success: #00a870;
--color-warning: #ed7b2f;
--color-error: #e34d59;
--color-gray-1: #f3f3f3;
--color-gray-2: #eeeeee;
--color-gray-3: #e7e7e7;
--color-gray-4: #dcdcdc;
--color-gray-5: #c5c5c5;
--color-gray-6: #a6a6a6;
--color-gray-7: #8b8b8b;
--color-gray-8: #777777;
--color-gray-9: #5e5e5e;
--color-gray-10: #4b4b4b;
--color-gray-11: #393939;
--color-gray-12: #2c2c2c;
--color-gray-13: #242424;
/* 字号 */
--font-xs: 24rpx;
--font-sm: 28rpx;
--font-base: 32rpx;
--font-lg: 36rpx;
--font-xl: 40rpx;
--font-2xl: 48rpx;
/* 圆角 */
--radius-sm: 8rpx;
--radius-md: 16rpx;
--radius-lg: 24rpx;
--radius-xl: 32rpx;
/* 阴影 */
--shadow-lg: 0 8rpx 32rpx rgba(0,0,0,0.06);
--shadow-xl: 0 16rpx 48rpx rgba(0,0,0,0.08);
/* 间距基准 */
--spacing-base: 8rpx;
/* TDesign 主题覆盖 */
--td-brand-color: #0052d9;
--td-brand-color-light: #ecf2fe;
--td-success-color: #00a870;
--td-warning-color: #ed7b2f;
--td-error-color: #e34d59;
/* 页面默认样式 */
background-color: var(--color-gray-1);
font-size: var(--font-base);
color: var(--color-gray-13);
}
/* ============================================
* 通用工具类
* ============================================ */
/* 安全区适配 */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
/* 文本省略 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Flex 布局 */
.flex-row {
display: flex;
flex-direction: row;
align-items: center;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-1 {
flex: 1;
}

View File

@@ -0,0 +1,21 @@
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 机器人头部 -->
<rect x="5" y="7" width="14" height="12" rx="4" fill="white"/>
<!-- 天线 -->
<path d="M12 7V4" stroke="white" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="3" r="1.5" fill="white"/>
<!-- 眼睛 -->
<circle cx="9" cy="11.5" r="2" fill="#667eea"/>
<circle cx="15" cy="11.5" r="2" fill="#667eea"/>
<!-- 眼睛高光 -->
<circle cx="9.5" cy="11" r="0.7" fill="white"/>
<circle cx="15.5" cy="11" r="0.7" fill="white"/>
<!-- 微笑嘴巴 -->
<path d="M9.5 15C10 16 14 16 14.5 15" stroke="#667eea" stroke-width="1.5" stroke-linecap="round"/>
<!-- 腮红 -->
<circle cx="7" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<circle cx="17" cy="13.5" r="1" fill="#f5a0c0" opacity="0.6"/>
<!-- 耳朵 -->
<rect x="3" y="10" width="2" height="4" rx="1" fill="white"/>
<rect x="19" y="10" width="2" height="4" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1001 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4b4b4b" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00a870">
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#a6a6a6">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ed7b2f">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>

After

Width:  |  Height:  |  Size: 150 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#a6a6a6">
<path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.32.32 0 0 0 .165-.054l1.9-1.106a.96.96 0 0 1 .465-.116.94.94 0 0 1 .272.04 10.6 10.6 0 0 0 2.822.384c.136 0 .271-.002.405-.009a6.9 6.9 0 0 1-.315-2.053c0-3.694 3.614-6.69 8.076-6.69.233 0 .463.01.691.027C16.964 4.837 13.132 2.188 8.691 2.188zm-2.97 5.28a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zm5.96 0a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zM24 14.2c0-3.355-3.4-6.08-7.59-6.08s-7.59 2.725-7.59 6.08c0 3.356 3.4 6.08 7.59 6.08.772 0 1.515-.094 2.215-.268a.77.77 0 0 1 .224-.033.79.79 0 0 1 .382.095l1.565.912a.26.26 0 0 0 .135.044c.13 0 .238-.108.238-.242 0-.06-.024-.117-.04-.175l-.32-1.218a.48.48 0 0 1 .175-.547C22.95 17.89 24 16.165 24 14.2zm-10.14-.426a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7zm5.1 0a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0052d9">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e34d59">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ed7b2f">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
<circle cx="12" cy="12" r="10" fill="white" opacity="0.3"/>
<circle cx="12" cy="12" r="6" fill="white"/>
<circle cx="12" cy="12" r="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#5e5e5e" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="13" width="4" height="8" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="10" y="8" width="4" height="13" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="16" y="3" width="4" height="18" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="13" width="4" height="8" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="10" y="8" width="4" height="13" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="16" y="3" width="4" height="18" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" stroke="#8b8b8b" stroke-width="1.5"/>
<path d="M5.5 21a6.5 6.5 0 0113 0h-13z" stroke="#8b8b8b" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="4" width="14" height="17" rx="2" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<rect x="8" y="2" width="8" height="4" rx="1" fill="#0052d9" stroke="#0052d9" stroke-width="1"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="4" width="14" height="17" rx="2" stroke="#8b8b8b" stroke-width="1.5"/>
<rect x="8" y="2" width="8" height="4" rx="1" stroke="#8b8b8b" stroke-width="1.5"/>
<path d="M9 12l2 2 4-4" stroke="#8b8b8b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0052d9">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.32.32 0 0 0 .165-.054l1.9-1.106a.96.96 0 0 1 .465-.116.94.94 0 0 1 .272.04 10.6 10.6 0 0 0 2.822.384c.136 0 .271-.002.405-.009a6.9 6.9 0 0 1-.315-2.053c0-3.694 3.614-6.69 8.076-6.69.233 0 .463.01.691.027C16.964 4.837 13.132 2.188 8.691 2.188zm-2.97 5.28a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zm5.96 0a1.03 1.03 0 1 1 0-2.06 1.03 1.03 0 0 1 0 2.06zM24 14.2c0-3.355-3.4-6.08-7.59-6.08s-7.59 2.725-7.59 6.08c0 3.356 3.4 6.08 7.59 6.08.772 0 1.515-.094 2.215-.268a.77.77 0 0 1 .224-.033.79.79 0 0 1 .382.095l1.565.912a.26.26 0 0 0 .135.044c.13 0 .238-.108.238-.242 0-.06-.024-.117-.04-.175l-.32-1.218a.48.48 0 0 1 .175-.547C22.95 17.89 24 16.165 24 14.2zm-10.14-.426a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7zm5.1 0a.85.85 0 1 1 0-1.7.85.85 0 0 1 0 1.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,39 @@
Component({
properties: {
/** 是否显示 */
visible: {
type: Boolean,
value: true,
},
/** 跳转目标页面 */
targetUrl: {
type: String,
value: '/pages/chat/chat',
},
/** 可选:携带客户 ID 参数 */
customerId: {
type: String,
value: '',
},
/** 距底部距离rpxTabBar 页面用 200非 TabBar 页面用 120 */
bottom: {
type: Number,
value: 200,
},
},
methods: {
onTap() {
let url = this.data.targetUrl
if (this.data.customerId) {
url += `?customerId=${this.data.customerId}`
}
wx.navigateTo({
url,
fail: () => {
wx.showToast({ title: '页面跳转失败', icon: 'none' })
},
})
},
},
})

View File

@@ -0,0 +1,14 @@
<!-- AI 悬浮对话按钮 — SVG 机器人 + 渐变流动背景 -->
<view
class="ai-float-btn-container"
wx:if="{{visible}}"
style="bottom: {{bottom}}rpx"
bindtap="onTap"
>
<view class="ai-float-btn">
<!-- 高光叠加层 -->
<view class="ai-float-btn-highlight"></view>
<!-- 机器人 SVG小程序用 image 引用) -->
<image class="ai-icon-svg" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
</view>
</view>

View File

@@ -0,0 +1,57 @@
/* AI 悬浮按钮 — 忠于 H5 原型:渐变流动背景 + 机器人 SVG */
/* H5: 56px → 56×2×0.875 = 98rpx */
.ai-float-btn-container {
position: fixed;
right: 28rpx;
z-index: 100;
}
.ai-float-btn {
width: 98rpx;
height: 98rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
/* 渐变动画背景 — 忠于 H5 原型 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #f5576c 75%, #667eea 100%);
background-size: 400% 400%;
animation: gradientShift 8s ease infinite;
box-shadow: 0 8rpx 40rpx rgba(102, 126, 234, 0.4);
}
.ai-float-btn:active {
transform: scale(0.95);
}
/* 高光叠加层 */
.ai-float-btn-highlight {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(145deg, rgba(255,255,255,0.2) 0%, transparent 50%, rgba(0,0,0,0.1) 100%);
border-radius: 50%;
pointer-events: none;
}
/* 背景渐变流动动画 */
@keyframes gradientShift {
0% { background-position: 0% 50%; }
25% { background-position: 50% 100%; }
50% { background-position: 100% 50%; }
75% { background-position: 50% 0%; }
100% { background-position: 0% 50%; }
}
/* SVG 图标 H5: 30px → 52rpx */
.ai-icon-svg {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 1;
}

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,26 @@
Component({
properties: {
/** Banner 主题色 */
theme: {
type: String,
value: 'blue',
},
/** Banner 标题 */
title: {
type: String,
value: '',
},
/** 指标列表 [{label, value}] */
metrics: {
type: Array,
value: [],
},
},
methods: {
/** 背景图加载失败时降级为纯渐变色CSS 已处理) */
onBgError() {
// 背景图加载失败CSS 渐变色自动降级,无需额外处理
},
},
})

View File

@@ -0,0 +1,12 @@
<view class="banner banner--{{theme}}">
<view class="banner-bg"></view>
<view class="banner-overlay">
<text class="banner-title">{{title}}</text>
<view class="banner-metrics">
<view class="metric-item" wx:for="{{metrics}}" wx:key="label">
<text class="metric-value">{{item.value}}</text>
<text class="metric-label">{{item.label}}</text>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,66 @@
.banner {
position: relative;
width: 100%;
height: 280rpx;
border-radius: var(--radius-lg);
overflow: hidden;
}
.banner-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* 主题渐变降级(背景图加载失败时) */
.banner--blue { background: linear-gradient(135deg, #0052d9, #0080ff); }
.banner--red { background: linear-gradient(135deg, #e34d59, #ff6b6b); }
.banner--orange { background: linear-gradient(135deg, #ed7b2f, #ffaa44); }
.banner--pink { background: linear-gradient(135deg, #d94da0, #ff6bcc); }
.banner--teal { background: linear-gradient(135deg, #00a870, #00d68f); }
.banner--coral { background: linear-gradient(135deg, #e06c5a, #ff8a7a); }
.banner--dark-gold { background: linear-gradient(135deg, #8b6914, #c9a227); }
.banner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 32rpx;
box-sizing: border-box;
}
.banner-title {
font-size: var(--font-lg);
font-weight: 600;
color: #ffffff;
margin-bottom: 16rpx;
}
.banner-metrics {
display: flex;
gap: 40rpx;
}
.metric-item {
display: flex;
flex-direction: column;
}
.metric-value {
font-size: var(--font-2xl);
font-weight: 700;
color: #ffffff;
}
.metric-label {
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.8);
margin-top: 4rpx;
}

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,25 @@
// 自定义底部导航栏 — 非 TabBar 页面模拟系统导航
Component({
properties: {
/** 当前激活的 tab: task / board / my */
active: {
type: String,
value: 'board',
},
},
methods: {
onTap(e: WechatMiniprogram.TouchEvent) {
const tab = e.currentTarget.dataset.tab as string
if (tab === this.data.active) return
if (tab === 'task') {
wx.switchTab({ url: '/pages/task-list/task-list' })
} else if (tab === 'board') {
wx.switchTab({ url: '/pages/board-finance/board-finance' })
} else if (tab === 'my') {
wx.switchTab({ url: '/pages/my-profile/my-profile' })
}
},
},
})

View File

@@ -0,0 +1,39 @@
<!-- 自定义底部导航栏 — 用于非 TabBar 的看板子页面SVG icon 忠于 H5 原型 -->
<view class="tab-bar">
<view
class="tab-bar-item {{active === 'task' ? 'tab-bar-item--active' : ''}}"
bindtap="onTap"
data-tab="task"
>
<image
class="tab-bar-icon"
src="{{active === 'task' ? '/assets/icons/tab-task-nav-active.svg' : '/assets/icons/tab-task-nav.svg'}}"
mode="aspectFit"
/>
<text class="tab-bar-label">任务</text>
</view>
<view
class="tab-bar-item {{active === 'board' ? 'tab-bar-item--active' : ''}}"
bindtap="onTap"
data-tab="board"
>
<image
class="tab-bar-icon"
src="{{active === 'board' ? '/assets/icons/tab-board-nav-active.svg' : '/assets/icons/tab-board-nav.svg'}}"
mode="aspectFit"
/>
<text class="tab-bar-label">看板</text>
</view>
<view
class="tab-bar-item {{active === 'my' ? 'tab-bar-item--active' : ''}}"
bindtap="onTap"
data-tab="my"
>
<image
class="tab-bar-icon"
src="{{active === 'my' ? '/assets/icons/tab-my-nav-active.svg' : '/assets/icons/tab-my-nav.svg'}}"
mode="aspectFit"
/>
<text class="tab-bar-label">我的</text>
</view>
</view>

View File

@@ -0,0 +1,39 @@
/* 自定义底部导航栏 — 模拟系统 TabBar 外观 */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-around;
height: 100rpx;
background: #ffffff;
border-top: 1rpx solid #eeeeee;
padding-bottom: env(safe-area-inset-bottom);
z-index: 50;
}
.tab-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 4rpx;
}
.tab-bar-icon {
width: 44rpx;
height: 44rpx;
}
.tab-bar-label {
font-size: 20rpx;
color: #8b8b8b;
}
.tab-bar-item--active .tab-bar-label {
color: #0052d9;
font-weight: 500;
}

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,28 @@
/**
* 开发调试浮动按钮组件
*
* 仅在 develop 环境下显示,点击跳转到 dev-tools 页面。
* 使用 movable-view 实现可拖拽。
*/
Component({
data: {
visible: false,
x: 580, // 初始位置右下角附近rpx 换算后的 px 近似值)
y: 1100,
},
lifetimes: {
attached() {
// 仅 develop 环境显示
const accountInfo = wx.getAccountInfoSync()
const env = accountInfo.miniProgram.envVersion
this.setData({ visible: env === "develop" })
},
},
methods: {
goDevTools() {
wx.navigateTo({ url: "/pages/dev-tools/dev-tools" })
},
},
})

View File

@@ -0,0 +1,15 @@
<!--
开发调试浮动按钮 — 仅 develop 环境渲染
可拖拽,点击跳转到 dev-tools 页面
-->
<movable-area wx:if="{{visible}}" class="fab-area">
<movable-view
class="fab-btn"
direction="all"
x="{{x}}"
y="{{y}}"
bindtap="goDevTools"
>
<text class="fab-icon">🛠</text>
</movable-view>
</movable-area>

View File

@@ -0,0 +1,29 @@
/* 浮动按钮覆盖全屏,不阻挡页面交互 */
.fab-area {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
.fab-btn {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: rgba(24, 144, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.fab-icon {
font-size: 40rpx;
line-height: 96rpx;
text-align: center;
width: 96rpx;
}

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,83 @@
Component({
properties: {
/** 筛选标签文字 */
label: {
type: String,
value: '',
},
/** 选项列表 */
options: {
type: Array,
value: [] as Array<{ value: string; text: string }>,
},
/** 当前选中值 */
value: {
type: String,
value: '',
},
},
observers: {
'value, options'(val: string, opts: Array<{ value: string; text: string }>) {
const matched = (opts || []).find((o) => o.value === val)
this.setData({ selectedText: matched ? matched.text : '' })
},
},
data: {
expanded: false,
selectedText: '',
panelTop: 0,
},
lifetimes: {
attached() {
const { value, options } = this.data
const matched = (options as Array<{ value: string; text: string }>).find(
(o) => o.value === value,
)
this.setData({ selectedText: matched ? matched.text : '' })
},
},
methods: {
toggleDropdown() {
if (!this.data.options || (this.data.options as any[]).length === 0) return
if (!this.data.expanded) {
// 展开时计算按钮底部位置,作为面板 top
this.createSelectorQuery()
.select('.filter-dropdown')
.boundingClientRect((rect) => {
if (rect) {
this.setData({
panelTop: rect.bottom,
expanded: true,
})
} else {
this.setData({ expanded: true })
}
})
.exec()
} else {
this.setData({ expanded: false })
}
},
onSelect(e: WechatMiniprogram.TouchEvent) {
const val = e.currentTarget.dataset.value as string
const opts = this.data.options as Array<{ value: string; text: string }>
const matched = opts.find((o) => o.value === val)
this.setData({
expanded: false,
selectedText: matched ? matched.text : '',
})
this.triggerEvent('change', { value: val })
},
/** 点击遮罩层关闭 */
onMaskTap() {
this.setData({ expanded: false })
},
},
})

View File

@@ -0,0 +1,27 @@
<!-- 筛选下拉组件 — 全屏宽度面板 + 遮罩层 -->
<view class="filter-dropdown-wrap" wx:if="{{options && options.length > 0}}">
<view class="filter-dropdown {{expanded ? 'filter-dropdown--active' : ''}}" bindtap="toggleDropdown">
<text class="filter-label">{{selectedText || label}}</text>
<t-icon name="caret-down-small" size="32rpx" class="filter-arrow {{expanded ? 'filter-arrow--up' : ''}}" />
</view>
<!-- 遮罩层 -->
<view class="dropdown-mask" wx:if="{{expanded}}" catchtap="onMaskTap" />
<!-- 全屏宽度下拉面板 -->
<view
class="dropdown-panel {{expanded ? 'dropdown-panel--show' : ''}}"
style="top: {{panelTop}}px"
>
<view
class="dropdown-item {{item.value === value ? 'dropdown-item--active' : ''}}"
wx:for="{{options}}"
wx:key="value"
data-value="{{item.value}}"
bindtap="onSelect"
>
<text>{{item.text}}</text>
<t-icon wx:if="{{item.value === value}}" name="check" size="32rpx" color="var(--color-primary)" />
</view>
</view>
</view>

View File

@@ -0,0 +1,107 @@
.filter-dropdown-wrap {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
/* 触发按钮 */
.filter-dropdown {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 16rpx 20rpx;
background: #ffffff;
border-radius: var(--radius-md);
border: 2rpx solid var(--color-gray-1);
transition: border-color 0.2s, background-color 0.2s;
}
.filter-dropdown--active {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.filter-label {
font-size: 24rpx;
color: var(--color-gray-12);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.filter-dropdown--active .filter-label {
color: var(--color-primary);
}
/* 箭头旋转动画 */
.filter-arrow {
transition: transform 0.25s ease;
color: var(--color-gray-6);
}
.filter-arrow--up {
transform: rotate(180deg);
color: var(--color-primary);
}
/* 遮罩层 — 半透明黑色背景,忠于 H5 原型 */
.dropdown-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
/* 下拉面板 — 全屏宽度,固定定位,从筛选栏下方展开 */
.dropdown-panel {
position: fixed;
left: 0;
right: 0;
max-height: 60vh;
overflow-y: auto;
background: #ffffff;
border-radius: 0 0 28rpx 28rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
z-index: 1000;
opacity: 0;
transform: translateY(-16rpx);
transition: opacity 0.25s ease, transform 0.25s ease;
pointer-events: none;
padding: 16rpx 0;
}
.dropdown-panel--show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* 选项项 — 更大的 padding忠于 H5 原型 */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 34rpx 32rpx;
font-size: 28rpx;
color: var(--color-gray-12, #2c2c2c);
transition: background-color 0.15s;
}
.dropdown-item:active {
background: var(--color-gray-1, #f3f3f3);
}
.dropdown-item--active {
color: var(--color-primary, #0052d9);
font-weight: 500;
}
.dropdown-item + .dropdown-item {
border-top: 1rpx solid rgba(0, 0, 0, 0.03);
}

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,30 @@
Component({
properties: {
/** 评分 0-10超出范围自动 clamp */
score: {
type: Number,
value: 0,
},
},
data: {
heartEmoji: '💙',
},
observers: {
score(val: number) {
const s = val < 0 ? 0 : val > 10 ? 10 : val
let emoji: string
if (s > 8.5) {
emoji = '💖'
} else if (s > 7) {
emoji = '🧡'
} else if (s > 5) {
emoji = '💛'
} else {
emoji = '💙'
}
this.setData({ heartEmoji: emoji })
},
},
})

View File

@@ -0,0 +1 @@
<text class="heart-icon">{{heartEmoji}}</text>

View File

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

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,36 @@
/** type → Emoji + 标签文字映射 */
const TAG_MAP: Record<string, { emoji: string; label: string }> = {
chinese: { emoji: '🎱', label: '中式' },
snooker: { emoji: '斯', label: '斯诺克' },
mahjong: { emoji: '🀅', label: '麻将' },
karaoke: { emoji: '🎤', label: 'K歌' },
}
Component({
properties: {
/** 喜好类型 */
type: {
type: String,
value: 'chinese',
},
},
observers: {
type(val: string) {
const tag = TAG_MAP[val] || { emoji: '❓', label: val }
this.setData({ emoji: tag.emoji, label: tag.label })
},
},
data: {
emoji: '🎱',
label: '中式',
},
lifetimes: {
attached() {
const tag = TAG_MAP[this.data.type] || { emoji: '❓', label: this.data.type }
this.setData({ emoji: tag.emoji, label: tag.label })
},
},
})

View File

@@ -0,0 +1,4 @@
<view class="hobby-tag">
<text class="tag-emoji">{{emoji}}</text>
<text class="tag-label">{{label}}</text>
</view>

View File

@@ -0,0 +1,19 @@
.hobby-tag {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 16rpx;
border-radius: var(--radius-sm);
background-color: var(--color-gray-1);
font-size: var(--font-xs);
color: var(--color-gray-9);
line-height: 1.4;
}
.tag-emoji {
font-size: var(--font-sm);
}
.tag-label {
font-size: var(--font-xs);
}

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,67 @@
Component({
properties: {
/** 指标名称 */
title: {
type: String,
value: '',
},
/** 指标数值(已格式化字符串) */
value: {
type: String,
value: null,
},
/** 单位(元/次/人) */
unit: {
type: String,
value: '',
},
/** 环比趋势 */
trend: {
type: String,
value: 'flat', // 'up' | 'down' | 'flat'
},
/** 环比数值(如 "+12.5%" */
trendValue: {
type: String,
value: '',
},
/** 帮助说明文字 */
helpText: {
type: String,
value: '',
},
},
observers: {
value(val: string | null | undefined) {
// null/undefined 显示 "--"
this.setData({
displayValue: val == null || val === '' ? '--' : val,
})
},
},
data: {
displayValue: '--',
},
lifetimes: {
attached() {
const val = this.data.value
this.setData({
displayValue: val == null || val === '' ? '--' : val,
})
},
},
methods: {
onTap() {
this.triggerEvent('tap')
},
onHelpTap(e: WechatMiniprogram.TouchEvent) {
// 阻止冒泡到卡片 tap
e.stopPropagation?.()
this.triggerEvent('helpTap')
},
},
})

View File

@@ -0,0 +1,22 @@
<view class="metric-card" bindtap="onTap">
<view class="metric-header">
<text class="metric-title">{{title}}</text>
<view class="metric-help" wx:if="{{helpText}}" catchtap="onHelpTap">
<t-icon name="help-circle" size="32rpx" color="#a6a6a6" />
</view>
</view>
<view class="metric-body">
<text class="metric-value">{{displayValue}}</text>
<text class="metric-unit" wx:if="{{unit}}">{{unit}}</text>
</view>
<view class="metric-trend" wx:if="{{trendValue}}">
<view class="trend-tag trend-{{trend}}">
<t-icon wx:if="{{trend === 'up'}}" name="arrow-up" size="24rpx" />
<t-icon wx:elif="{{trend === 'down'}}" name="arrow-down" size="24rpx" />
<text wx:else class="trend-flat-icon">-</text>
<text class="trend-text">{{trendValue}}</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,82 @@
.metric-card {
background: #ffffff;
border-radius: var(--radius-lg);
padding: 24rpx;
box-shadow: var(--shadow-lg);
}
.metric-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.metric-title {
font-size: var(--font-sm);
color: var(--color-gray-7);
}
.metric-help {
padding: 4rpx;
}
.metric-body {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 8rpx;
}
.metric-value {
font-size: 48rpx;
font-weight: 700;
color: var(--color-gray-13);
font-variant-numeric: tabular-nums;
}
.metric-unit {
font-size: var(--font-sm);
color: var(--color-gray-7);
}
.metric-trend {
display: flex;
align-items: center;
}
.trend-tag {
display: inline-flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 12rpx;
border-radius: var(--radius-sm);
font-size: var(--font-xs);
}
/* 上升 → 绿色 */
.trend-up {
color: var(--color-success);
background: rgba(0, 168, 112, 0.08);
}
/* 下降 → 红色 */
.trend-down {
color: var(--color-error);
background: rgba(227, 77, 89, 0.08);
}
/* 持平 → 灰色 */
.trend-flat {
color: var(--color-gray-7);
background: rgba(139, 139, 139, 0.08);
}
.trend-flat-icon {
font-weight: 700;
}
.trend-text {
font-size: var(--font-xs);
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,9 @@
{
"component": true,
"usingComponents": {
"t-rate": "tdesign-miniprogram/rate/rate",
"t-textarea": "tdesign-miniprogram/textarea/textarea",
"t-button": "tdesign-miniprogram/button/button",
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,79 @@
Component({
properties: {
/** 控制弹窗显示/隐藏 */
visible: {
type: Boolean,
value: false,
},
/** 客户名(弹窗标题显示) */
customerName: {
type: String,
value: '',
},
/** 初始评分 0-10 */
initialScore: {
type: Number,
value: 0,
},
/** 初始备注内容 */
initialContent: {
type: String,
value: '',
},
},
observers: {
'visible, initialScore, initialContent'(visible: boolean) {
if (visible) {
const clamped = Math.max(0, Math.min(10, this.data.initialScore))
this.setData({
starValue: clamped / 2,
content: this.data.initialContent,
score: clamped,
})
}
},
},
data: {
/** 星星值 0-5半星制 */
starValue: 0,
/** 内部评分 0-10 */
score: 0,
/** 备注内容 */
content: '',
},
methods: {
/** 星星评分变化 */
onRateChange(e: WechatMiniprogram.CustomEvent<{ value: number }>) {
const starVal = e.detail.value
this.setData({
starValue: starVal,
score: starVal * 2,
})
},
/** 文本内容变化 */
onContentChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ content: e.detail.value })
},
/** 确认提交 */
onConfirm() {
if (!this.data.content.trim()) return
this.triggerEvent('confirm', {
score: this.data.score,
content: this.data.content.trim(),
})
},
/** 取消关闭 */
onCancel() {
this.triggerEvent('cancel')
},
/** 阻止冒泡 */
noop() {},
},
})

View File

@@ -0,0 +1,46 @@
<view class="modal-mask" wx:if="{{visible}}" catchtap="onCancel">
<view class="modal-content" catchtap="noop">
<!-- 头部 -->
<view class="modal-header">
<text class="modal-title">添加备注{{customerName ? ' - ' + customerName : ''}}</text>
<view class="modal-close" bindtap="onCancel">
<t-icon name="close" size="40rpx" color="#a6a6a6" />
</view>
</view>
<!-- 评分区域 -->
<view class="rating-section">
<text class="rating-label">评分</text>
<t-rate
value="{{starValue}}"
count="{{5}}"
allow-half="{{true}}"
size="48rpx"
bind:change="onRateChange"
/>
</view>
<!-- 文本输入 -->
<view class="textarea-section">
<t-textarea
value="{{content}}"
placeholder="请输入备注内容..."
maxlength="{{500}}"
autosize="{{true}}"
bind:change="onContentChange"
/>
</view>
<!-- 底部按钮 -->
<view class="modal-footer">
<t-button theme="default" size="large" block bindtap="onCancel">取消</t-button>
<t-button
theme="primary"
size="large"
block
disabled="{{!content.trim()}}"
bindtap="onConfirm"
>确认</t-button>
</view>
</view>
</view>

View File

@@ -0,0 +1,68 @@
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
}
.modal-content {
width: 100%;
background: #ffffff;
border-radius: 48rpx 48rpx 0 0;
padding: 40rpx 40rpx 60rpx;
box-sizing: border-box;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.modal-title {
font-size: var(--font-lg, 36rpx);
font-weight: 600;
color: var(--color-gray-13, #242424);
}
.modal-close {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-gray-1, #f3f3f3);
}
.rating-section {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 32rpx;
}
.rating-label {
font-size: var(--font-sm, 28rpx);
color: var(--color-gray-9, #5e5e5e);
flex-shrink: 0;
}
.textarea-section {
margin-bottom: 40rpx;
}
.modal-footer {
display: flex;
gap: 24rpx;
}
.modal-footer t-button {
flex: 1;
}

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"t-rate": "tdesign-miniprogram/rate/rate"
}
}

View File

@@ -0,0 +1,38 @@
Component({
properties: {
/** 评分 0-10内部转换为 0-5 星 */
score: {
type: Number,
value: 0,
},
/** 星星尺寸 */
size: {
type: String,
value: '40rpx',
},
/** 是否只读 */
readonly: {
type: Boolean,
value: true,
},
},
observers: {
score(val: number) {
// score(0-10) → star(0-5)
const clamped = Math.max(0, Math.min(10, val))
this.setData({ starValue: clamped / 2 })
},
},
data: {
starValue: 0,
},
lifetimes: {
attached() {
const clamped = Math.max(0, Math.min(10, this.data.score))
this.setData({ starValue: clamped / 2 })
},
},
})

View File

@@ -0,0 +1,7 @@
<t-rate
value="{{starValue}}"
count="{{5}}"
allow-half
size="{{size}}"
disabled="{{readonly}}"
/>

View File

@@ -0,0 +1,3 @@
:host {
display: inline-block;
}

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "申请访问权限",
"navigationStyle": "custom",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -0,0 +1,146 @@
import { request } from "../../utils/request"
Page({
data: {
statusBarHeight: 0,
siteCode: "",
role: "",
phone: "",
employeeNumber: "",
nickname: "",
submitting: false,
},
onLoad() {
const { statusBarHeight } = wx.getSystemInfoSync()
this.setData({ statusBarHeight })
},
onShow() {
this._checkAccess()
},
/** 校验用户身份:无 token 跳登录,非 new/rejected 跳对应页 */
async _checkAccess() {
const token = wx.getStorageSync("token")
if (!token) {
wx.reLaunch({ url: "/pages/login/login" })
return
}
try {
const data = await request({
url: "/api/xcx/me",
method: "GET",
needAuth: true,
})
const app = getApp<IAppOption>()
app.globalData.authUser = {
userId: data.user_id,
status: data.status,
nickname: data.nickname,
}
wx.setStorageSync("userId", data.user_id)
wx.setStorageSync("userStatus", data.status)
switch (data.status) {
case "new":
break
case "rejected":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
case "approved":
wx.reLaunch({ url: "/pages/mvp/mvp" })
break
case "pending":
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
break
case "disabled":
wx.reLaunch({ url: "/pages/no-permission/no-permission" })
break
}
} catch {
// 网络错误不阻塞,允许用户继续填表
}
},
onBack() {
wx.navigateBack({ fail: () => wx.reLaunch({ url: "/pages/login/login" }) })
},
/* 原生 input 的 bindinput 事件 */
onSiteCodeInput(e: WechatMiniprogram.Input) {
this.setData({ siteCode: e.detail.value })
},
onRoleInput(e: WechatMiniprogram.Input) {
this.setData({ role: e.detail.value })
},
onPhoneInput(e: WechatMiniprogram.Input) {
this.setData({ phone: e.detail.value })
},
onEmployeeNumberInput(e: WechatMiniprogram.Input) {
this.setData({ employeeNumber: e.detail.value })
},
onNicknameInput(e: WechatMiniprogram.Input) {
this.setData({ nickname: e.detail.value })
},
async onSubmit() {
if (this.data.submitting) return
const { siteCode, role, phone, nickname, employeeNumber } = this.data
if (!siteCode.trim()) {
wx.showToast({ title: "请输入球房ID", icon: "none" })
return
}
if (!role.trim()) {
wx.showToast({ title: "请输入申请身份", icon: "none" })
return
}
if (!/^\d{11}$/.test(phone)) {
wx.showToast({ title: "请输入11位手机号", icon: "none" })
return
}
if (!nickname.trim()) {
wx.showToast({ title: "请输入昵称", icon: "none" })
return
}
this.setData({ submitting: true })
try {
await request({
url: "/api/xcx/apply",
method: "POST",
data: {
site_code: siteCode.trim(),
applied_role_text: role.trim(),
phone,
employee_number: employeeNumber.trim() || undefined,
nickname: nickname.trim(),
},
needAuth: true,
})
wx.showToast({ title: "申请已提交", icon: "success" })
setTimeout(() => {
wx.reLaunch({ url: "/pages/reviewing/reviewing" })
}, 800)
} catch (err: any) {
const msg =
err?.data?.detail ||
(err?.statusCode === 409
? "您已有待审核的申请"
: err?.statusCode === 422
? "表单信息有误,请检查"
: "提交失败,请稍后重试")
wx.showToast({ title: msg, icon: "none" })
} finally {
this.setData({ submitting: false })
}
},
})

View File

@@ -0,0 +1,107 @@
<!-- pages/apply/apply.wxml — 按 H5 原型结构迁移 -->
<view class="page" style="padding-top: {{statusBarHeight}}px;">
<!-- 顶部导航栏 -->
<view class="navbar">
<view class="navbar-back" bindtap="onBack">
<t-icon name="chevron-left" size="35rpx" color="#4b4b4b" />
</view>
<text class="navbar-title">申请访问权限</text>
</view>
<!-- 主体内容 -->
<view class="main">
<!-- 欢迎卡片 + 审核流程(整合) -->
<view class="welcome-card">
<view class="welcome-header">
<view class="welcome-icon-box">
<t-icon name="check-circle-filled" size="42rpx" color="#fff" />
</view>
<view class="welcome-text">
<text class="welcome-title">欢迎加入球房运营助手</text>
<text class="welcome-desc">请填写申请信息,等待管理员审核</text>
</view>
</view>
<!-- 审核流程步骤条 -->
<view class="steps-bar">
<view class="steps-row">
<view class="step-item">
<view class="step-circle step-circle--active">1</view>
<text class="step-label step-label--active">提交申请</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">等待审核</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">审核通过</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">4</view>
<text class="step-label">开始使用</text>
</view>
</view>
</view>
</view>
<!-- 表单区域 -->
<view class="form-card">
<!-- 球房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" />
</view>
<!-- 申请身份 -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>申请身份</text>
</view>
<input class="form-input" type="text" placeholder="请输入申请身份(如:助教、店长等)" value="{{role}}" bindinput="onRoleInput" />
</view>
<!-- 手机号 -->
<view class="form-item form-item--border">
<view class="form-label">
<text class="required">*</text>
<text>手机号</text>
</view>
<input class="form-input" type="number" placeholder="请输入手机号" value="{{phone}}" maxlength="11" bindinput="onPhoneInput" />
</view>
<!-- 编号(选填) -->
<view class="form-item form-item--border">
<view class="form-label">
<text>编号</text>
<text class="optional-tag">选填</text>
</view>
<input class="form-input" type="text" placeholder="请输入编号" value="{{employeeNumber}}" maxlength="50" bindinput="onEmployeeNumberInput" />
</view>
<!-- 昵称 -->
<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" />
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-area">
<text class="form-tip">请认真填写,信息不完整可能导致审核不通过</text>
<view class="submit-btn {{submitting ? 'submit-btn--disabled' : ''}}" bindtap="onSubmit">
<t-loading wx:if="{{submitting}}" theme="circular" size="32rpx" color="#fff" />
<text wx:else class="submit-btn-text">提交申请</text>
</view>
<text class="bottom-tip">审核通常需要 1-3 个工作日</text>
</view>
</view>
<dev-fab />

View File

@@ -0,0 +1,271 @@
/* pages/apply/apply.wxss — H5 px × 2 × 0.875 精确转换 */
.page {
min-height: 100vh;
background: linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 50%, #ecfeff 100%);
display: flex;
flex-direction: column;
box-sizing: border-box;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
/* ---- 顶部导航栏 h-11=44px→78rpx ---- */
.navbar {
height: 78rpx;
display: flex;
align-items: center;
justify-content: center;
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1rpx solid rgba(229, 231, 235, 0.5);
}
.navbar-back {
position: absolute;
left: 28rpx;
padding: 8rpx;
}
/* text-base=16px→28rpx */
.navbar-title {
font-size: 28rpx;
font-weight: 500;
color: #242424;
letter-spacing: 0.5rpx;
}
/* ---- 主体内容 p-4=16px→28rpx ---- */
.main {
flex: 1;
padding: 28rpx;
padding-bottom: 380rpx;
}
/* ---- 欢迎卡片 p-5=20px→36rpx, rounded-2xl=16px→28rpx ---- */
.welcome-card {
background: linear-gradient(135deg, #0052d9, #60a5fa);
border-radius: 28rpx;
padding: 36rpx;
margin-bottom: 28rpx;
box-shadow: 0 14rpx 36rpx rgba(0, 82, 217, 0.2);
}
/* gap-4=16px→28rpx, mb-4=16px→28rpx */
.welcome-header {
display: flex;
align-items: center;
gap: 28rpx;
margin-bottom: 28rpx;
}
/* w-12 h-12=48px→84rpx, rounded-xl=12px→22rpx */
.welcome-icon-box {
width: 84rpx;
height: 84rpx;
min-width: 84rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-text {
display: flex;
flex-direction: column;
gap: 8rpx;
}
/* text-lg=18px→32rpx */
.welcome-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
/* text-sm=14px→24rpx */
.welcome-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 300;
}
/* ---- 审核流程步骤条 p-4=16px→28rpx, rounded-xl=12px→22rpx ---- */
.steps-bar {
background: rgba(255, 255, 255, 0.1);
border-radius: 22rpx;
padding: 28rpx;
}
.steps-row {
display: flex;
align-items: center;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
/* w-7 h-7=28px→50rpx, text-xs=12px→22rpx */
.step-circle {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.step-circle--active {
background: #ffffff;
color: #0052d9;
font-weight: 600;
}
/* text-xs=12px→22rpx */
.step-label {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
}
.step-label--active {
color: rgba(255, 255, 255, 0.9);
}
/* h-0.5=2px→4rpx, mx-2=8px→14rpx */
.step-line {
flex: 1;
height: 4rpx;
background: rgba(255, 255, 255, 0.3);
margin: 0 10rpx;
margin-bottom: 24rpx;
}
/* ---- 表单卡片 rounded-2xl=16px→28rpx ---- */
.form-card {
background: #ffffff;
border-radius: 28rpx;
overflow: hidden;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.04);
}
/* px-5=20px→36rpx, py-4=16px→28rpx+2rpx 视觉补偿 */
.form-item {
padding: 30rpx 36rpx;
}
.form-item--border {
border-bottom: 2rpx solid #f3f3f3;
}
/* text-sm=14px→24rpx, mb-2=8px→14rpx */
.form-label {
display: flex;
align-items: center;
gap: 4rpx;
margin-bottom: 14rpx;
font-size: 24rpx;
font-weight: 500;
color: #242424;
}
/* text-sm=14px→24rpx */
.required {
color: #e34d59;
font-size: 24rpx;
}
/* text-xs=12px→22rpx */
.optional-tag {
font-size: 20rpx;
color: #a6a6a6;
font-weight: 400;
margin-left: 10rpx;
}
/* px-4=16px→28rpx, py-3=12px→22rpx, rounded-xl=12px→22rpx, text-sm=14px→24rpx
小程序 input 组件内部有压缩py 加 4rpx 补偿到视觉等高 */
.form-input {
width: 100%;
height: 80rpx;
padding: 0 28rpx;
background: #f8f8f8;
border-radius: 22rpx;
border: 2rpx solid #f3f3f3;
font-size: 24rpx;
font-weight: 300;
color: #242424;
box-sizing: border-box;
}
.form-input::placeholder {
color: #c5c5c5;
font-weight: 300;
}
/* ---- 表单提示(移入底部固定区) ---- */
.form-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: #a6a6a6;
margin-bottom: 18rpx;
font-weight: 300;
}
/* ---- 底部提交 p-4=16px→28rpx, pb-8=32px→56rpx ---- */
.bottom-area {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 28rpx;
padding-bottom: calc(56rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.95);
border-top: 2rpx solid #f3f3f3;
z-index: 10;
}
/* py-4=16px→28rpx (用padding代替固定高度), rounded-xl=12px→22rpx, text-base=16px→28rpx */
.submit-btn {
width: 100%;
padding: 28rpx 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0052d9, #3b82f6);
border-radius: 22rpx;
box-shadow: 0 10rpx 28rpx rgba(0, 82, 217, 0.3);
}
.submit-btn--disabled {
opacity: 0.6;
}
/* text-base=16px→28rpx */
.submit-btn-text {
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
}
/* text-xs=12px→22rpx, mt-3=12px→22rpx */
.bottom-tip {
display: block;
text-align: center;
font-size: 20rpx;
color: #c5c5c5;
margin-top: 22rpx;
font-weight: 300;
}

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "助教看板",
"enablePullDownRefresh": true,
"usingComponents": {
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/components/board-tab-bar/board-tab-bar",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,263 @@
// 助教看板页 — 排序×技能×时间三重筛选4 种维度卡片
// TODO: 联调时替换 mock 数据为真实 API 调用
export {}
/** 排序维度 → 卡片模板映射 */
type DimType = 'perf' | 'salary' | 'sv' | 'task'
const SORT_TO_DIM: Record<string, DimType> = {
perf_desc: 'perf',
perf_asc: 'perf',
salary_desc: 'salary',
salary_asc: 'salary',
sv_desc: 'sv',
task_desc: 'task',
}
const SORT_OPTIONS = [
{ value: 'perf_desc', text: '定档业绩最高' },
{ value: 'perf_asc', text: '定档业绩最低' },
{ value: 'salary_desc', text: '工资最高' },
{ value: 'salary_asc', text: '工资最低' },
{ value: 'sv_desc', text: '客源储值最高' },
{ value: 'task_desc', text: '任务完成最多' },
]
const SKILL_OPTIONS = [
{ value: 'all', text: '不限' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
]
const TIME_OPTIONS = [
{ value: 'month', text: '本月' },
{ value: 'quarter', text: '本季度' },
{ value: 'last_month', text: '上月' },
{ value: 'last_3m', text: '前3个月(不含本月)' },
{ value: 'last_quarter', text: '上季度' },
{ value: 'last_6m', text: '最近6个月(不含本月,不支持客源储值最高)' },
]
/** 等级 → 样式类映射 */
const LEVEL_CLASS: Record<string, string> = {
'星级': 'level--star',
'高级': 'level--high',
'中级': 'level--mid',
'初级': 'level--low',
}
/** 技能 → 样式类映射 */
const SKILL_CLASS: Record<string, string> = {
'🎱': 'skill--chinese',
'斯': 'skill--snooker',
'🀄': 'skill--mahjong',
'🎤': 'skill--karaoke',
}
interface CoachItem {
id: string
name: string
initial: string
avatarGradient: string
level: string
levelClass: string
skills: Array<{ text: string; cls: string }>
topCustomers: string[]
// 定档业绩维度
perfHours: string
perfHoursBefore?: string
perfGap?: string
perfReached: boolean
// 工资维度
salary: string
salaryPerfHours: string
salaryPerfBefore?: string
// 客源储值维度
svAmount: string
svCustomerCount: string
svConsume: string
// 任务维度
taskRecall: string
taskCallback: string
}
/** Mock 数据(忠于 H5 原型 6 位助教) */
const MOCK_COACHES: CoachItem[] = [
{
id: 'c1', name: '小燕', initial: '小',
avatarGradient: 'avatar--blue',
level: '星级', levelClass: LEVEL_CLASS['星级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💖 王先生', '💖 李女士', '💛 赵总'],
perfHours: '86.2h', perfHoursBefore: '92.0h', perfGap: '距升档 13.8h', perfReached: false,
salary: '¥12,680', salaryPerfHours: '86.2h', salaryPerfBefore: '92.0h',
svAmount: '¥45,200', svCustomerCount: '18', svConsume: '¥8,600',
taskRecall: '18', taskCallback: '14',
},
{
id: 'c2', name: '泡芙', initial: '泡',
avatarGradient: 'avatar--green',
level: '高级', levelClass: LEVEL_CLASS['高级'],
skills: [{ text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 陈先生', '💛 刘女士', '💛 黄总'],
perfHours: '72.5h', perfHoursBefore: '78.0h', perfGap: '距升档 7.5h', perfReached: false,
salary: '¥10,200', salaryPerfHours: '72.5h', salaryPerfBefore: '78.0h',
svAmount: '¥38,600', svCustomerCount: '15', svConsume: '¥6,200',
taskRecall: '15', taskCallback: '13',
},
{
id: 'c3', name: 'Amy', initial: 'A',
avatarGradient: 'avatar--pink',
level: '星级', levelClass: LEVEL_CLASS['星级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }, { text: '斯', cls: SKILL_CLASS['斯'] }],
topCustomers: ['💖 张先生', '💛 周女士', '💛 吴总'],
perfHours: '68.0h', perfHoursBefore: '72.5h', perfGap: '距升档 32.0h', perfReached: false,
salary: '¥9,800', salaryPerfHours: '68.0h', salaryPerfBefore: '72.5h',
svAmount: '¥32,100', svCustomerCount: '14', svConsume: '¥5,800',
taskRecall: '12', taskCallback: '13',
},
{
id: 'c4', name: 'Mia', initial: 'M',
avatarGradient: 'avatar--amber',
level: '中级', levelClass: LEVEL_CLASS['中级'],
skills: [{ text: '🀄', cls: SKILL_CLASS['🀄'] }],
topCustomers: ['💛 赵先生', '💛 吴女士', '💛 孙总'],
perfHours: '55.0h', perfGap: '距升档 5.0h', perfReached: false,
salary: '¥7,500', salaryPerfHours: '55.0h',
svAmount: '¥28,500', svCustomerCount: '12', svConsume: '¥4,100',
taskRecall: '10', taskCallback: '10',
},
{
id: 'c5', name: '糖糖', initial: '糖',
avatarGradient: 'avatar--purple',
level: '初级', levelClass: LEVEL_CLASS['初级'],
skills: [{ text: '🎱', cls: SKILL_CLASS['🎱'] }],
topCustomers: ['💛 钱先生', '💛 孙女士', '💛 周总'],
perfHours: '42.0h', perfHoursBefore: '45.0h', perfReached: true,
salary: '¥6,200', salaryPerfHours: '42.0h', salaryPerfBefore: '45.0h',
svAmount: '¥22,000', svCustomerCount: '10', svConsume: '¥3,500',
taskRecall: '8', taskCallback: '10',
},
{
id: 'c6', name: '露露', initial: '露',
avatarGradient: 'avatar--cyan',
level: '中级', levelClass: LEVEL_CLASS['中级'],
skills: [{ text: '🎤', cls: SKILL_CLASS['🎤'] }],
topCustomers: ['💛 郑先生', '💛 冯女士', '💛 陈总'],
perfHours: '38.0h', perfGap: '距升档 22.0h', perfReached: false,
salary: '¥5,100', salaryPerfHours: '38.0h',
svAmount: '¥18,300', svCustomerCount: '9', svConsume: '¥2,800',
taskRecall: '6', taskCallback: '9',
},
]
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
activeTab: 'coach' as 'finance' | 'customer' | 'coach',
selectedSort: 'perf_desc',
sortOptions: SORT_OPTIONS,
selectedSkill: 'all',
skillOptions: SKILL_OPTIONS,
selectedTime: 'month',
timeOptions: TIME_OPTIONS,
/** 当前维度类型,控制卡片模板 */
dimType: 'perf' as DimType,
coaches: [] as CoachItem[],
allCoaches: [] as CoachItem[],
/** 筛选栏可见性(滚动隐藏/显示) */
filterBarVisible: true,
},
_lastScrollTop: 0,
_scrollAcc: 0,
_scrollDir: null as 'up' | 'down' | null,
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 滚动隐藏/显示筛选栏 */
onPageScroll(e: { scrollTop: number }) {
const y = e.scrollTop
const delta = y - this._lastScrollTop
this._lastScrollTop = y
if (y <= 8) {
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
this._scrollAcc = 0
this._scrollDir = null
return
}
if (Math.abs(delta) <= 2) return
const dir = delta > 0 ? 'down' : 'up'
if (dir !== this._scrollDir) {
this._scrollDir = dir
this._scrollAcc = 0
}
this._scrollAcc += Math.abs(delta)
const threshold = dir === 'up' ? 12 : 24
if (this._scrollAcc < threshold) return
const visible = dir === 'up'
if (this.data.filterBarVisible !== visible) {
this.setData({ filterBarVisible: visible })
}
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
const data = MOCK_COACHES
if (!data || data.length === 0) {
this.setData({ pageState: 'empty' })
return
}
this.setData({ allCoaches: data, coaches: data, pageState: 'normal' })
}, 400)
},
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' })
}
},
onSortChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const val = e.detail.value
this.setData({
selectedSort: val,
dimType: SORT_TO_DIM[val] || 'perf',
})
},
onSkillChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedSkill: e.detail.value })
},
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedTime: e.detail.value })
},
onCoachTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/coach-detail/coach-detail?id=' + id })
},
})

View File

@@ -0,0 +1,153 @@
<!-- 助教看板页 — 忠于 H5 原型结构 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="70rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无助教数据" />
</view>
<!-- 正常态 -->
<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>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
<view class="filter-bar-inner">
<view class="filter-item filter-item--wide">
<filter-dropdown
label="定档业绩最高"
options="{{sortOptions}}"
value="{{selectedSort}}"
bind:change="onSortChange"
/>
</view>
<view class="filter-item">
<filter-dropdown
label="不限"
options="{{skillOptions}}"
value="{{selectedSkill}}"
bind:change="onSkillChange"
/>
</view>
<view class="filter-item">
<filter-dropdown
label="本月"
options="{{timeOptions}}"
value="{{selectedTime}}"
bind:change="onTimeChange"
/>
</view>
</view>
</view>
<!-- 助教列表 -->
<view class="coach-list">
<view
class="coach-card"
wx:for="{{coaches}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCoachTap"
>
<view class="card-row">
<!-- 头像 -->
<view class="card-avatar {{item.avatarGradient}}">
<text class="avatar-text">{{item.initial}}</text>
</view>
<!-- 信息区 -->
<view class="card-info">
<!-- 第一行:姓名 + 等级 + 技能 + 右侧指标 -->
<view class="card-name-row">
<text class="card-name">{{item.name}}</text>
<text class="level-tag {{item.levelClass}}">{{item.level}}</text>
<text
class="skill-tag {{skill.cls}}"
wx:for="{{item.skills}}"
wx:for-item="skill"
wx:key="text"
>{{skill.text}}</text>
<!-- 定档业绩维度 -->
<view class="card-right" wx:if="{{dimType === 'perf'}}">
<text class="right-text">定档 <text class="right-highlight">{{item.perfHours}}</text></text>
<text class="right-sub" wx:if="{{item.perfHoursBefore}}">折前 <text class="right-sub-val">{{item.perfHoursBefore}}</text></text>
</view>
<!-- 工资维度 -->
<view class="card-right" wx:elif="{{dimType === 'salary'}}">
<text class="salary-tag">预估</text>
<text class="salary-amount">{{item.salary}}</text>
</view>
<!-- 客源储值维度 -->
<view class="card-right" wx:elif="{{dimType === 'sv'}}">
<text class="right-sub">储值</text>
<text class="salary-amount">{{item.svAmount}}</text>
</view>
<!-- 任务维度 -->
<view class="card-right" wx:elif="{{dimType === 'task'}}">
<text class="right-text">召回 <text class="right-highlight">{{item.taskRecall}}</text></text>
</view>
</view>
<!-- 第二行:客户列表 + 右侧补充 -->
<view class="card-bottom-row">
<view class="customer-list">
<block wx:for="{{item.topCustomers}}" wx:for-item="cust" wx:for-index="custIdx" wx:key="*this">
<text class="customer-item" wx:if="{{dimType !== 'sv' || custIdx < 2}}">{{cust}}</text>
</block>
</view>
<!-- 定档业绩:距升档/已达标 -->
<text class="bottom-right bottom-right--warning" wx:if="{{dimType === 'perf' && !item.perfReached}}">{{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.salaryPerfHours}}</text></text>
<text class="bottom-sub" wx:if="{{item.salaryPerfBefore}}">折前 <text class="bottom-sub-val">{{item.salaryPerfBefore}}</text></text>
</view>
<!-- 客源储值:客户数 | 消耗 -->
<view class="bottom-right-group" wx:elif="{{dimType === 'sv'}}">
<text class="bottom-sub">客户 <text class="bottom-perf-val">{{item.svCustomerCount}}</text>人</text>
<text class="bottom-divider">|</text>
<text class="bottom-sub">消耗 <text class="bottom-perf-val">{{item.svConsume}}</text></text>
</view>
<!-- 任务:回访数 -->
<text class="bottom-sub" wx:elif="{{dimType === 'task'}}">回访 <text class="bottom-perf-val">{{item.taskCallback}}</text></text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区(为自定义导航栏留空间) -->
<view class="safe-bottom"></view>
</block>
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 — 在导航栏上方 -->
<ai-float-button bottom="{{220}}" />
<dev-fab />

View File

@@ -0,0 +1,330 @@
/* 助教看板页 — H5 px × 2 × 0.875 精确转换 */
/* ===== 三态 ===== */
.page-loading,
.page-empty {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
/* ===== 看板 Tab py-3=12px→22rpx, text-sm=14px→26rpx(视觉校准+2) ===== */
.board-tabs {
display: flex;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
position: sticky;
top: 0;
z-index: 20;
}
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
}
.board-tab--active {
color: #0052d9;
font-weight: 600;
}
/* w-24px→42rpx, h-3px→5rpxH5 实际渲染偏细) */
.board-tab--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 42rpx;
height: 5rpx;
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 3rpx;
}
/* ===== 筛选区域 px-4=16px→28rpx, py-2=8px→14rpx ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 70rpx;
z-index: 15;
border-bottom: 2rpx solid #eeeeee;
transition: transform 220ms ease, opacity 220ms ease;
}
.filter-bar--hidden {
opacity: 0;
transform: translateY(-110%);
pointer-events: none;
}
/* p-1.5=6px→10rpx, gap-2=8px→14rpx, rounded-lg=8px→14rpx */
.filter-bar-inner {
display: flex;
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 14rpx;
padding: 10rpx;
border: 2rpx solid #eeeeee;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.filter-item {
flex: 1;
min-width: 0;
}
.filter-item--wide {
flex: 1.8;
}
/* ===== 助教列表 p-4=16px→28rpx, space-y-3=12px→20rpx ===== */
.coach-list {
padding: 24rpx 28rpx;
}
/* ===== 助教卡片 p-4=16px→28rpx, rounded-2xl=16px→28rpx ===== */
.coach-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.coach-card:active {
opacity: 0.96;
transform: scale(0.98);
}
/* gap-3=12px→20rpx视觉校准紧凑 */
.card-row {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
/* ===== 头像 w-11 h-11=44px→78rpx, text-base=16px→28rpx ===== */
.card-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.avatar-text {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
/* 头像渐变色(忠于 H5 原型 6 种) */
.avatar--blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar--green { background: linear-gradient(135deg, #4ade80, #10b981); }
.avatar--pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar--amber { background: linear-gradient(135deg, #fbbf24, #f97316); }
.avatar--purple { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.avatar--cyan { background: linear-gradient(135deg, #22d3ee, #14b8a6); }
/* ===== 信息区 ===== */
.card-info {
flex: 1;
min-width: 0;
}
/* gap-1.5=6px→10rpx */
.card-name-row {
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: nowrap;
}
/* text-base=16px→28rpx */
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #242424;
flex-shrink: 0;
}
/* ===== 等级标签 px-1.5=6px→10rpx, py-0.5=2px→4rpx, text-xs=12px→22rpx ===== */
.level-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
flex-shrink: 0;
font-weight: 500;
}
.level--star {
background: linear-gradient(to right, #fbbf24, #f97316);
color: #ffffff;
}
.level--high {
background: linear-gradient(to right, #a78bfa, #8b5cf6);
color: #ffffff;
}
.level--mid {
background: linear-gradient(to right, #60a5fa, #6366f1);
color: #ffffff;
}
.level--low {
background: linear-gradient(to right, #9ca3af, #6b7280);
color: #ffffff;
}
/* ===== 技能标签 ===== */
.skill-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
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; }
/* ===== 卡片右侧指标ml-auto 推到右边) ===== */
.card-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10rpx;
flex-shrink: 0;
white-space: nowrap;
}
/* text-xs=12px→22rpx — "定档"标签文字,普通粗细 */
.right-text {
font-size: 22rpx;
color: #8b8b8b;
font-weight: 400;
}
/* text-sm=14px→24rpx — 数值加粗 */
.right-highlight {
font-size: 24rpx;
font-weight: 700;
color: #0052d9;
}
/* "折前"更淡更细 */
.right-sub {
font-size: 20rpx;
color: #c5c5c5;
font-weight: 400;
}
.right-sub-val {
color: #8b8b8b;
font-weight: 400;
}
/* 工资维度 */
.salary-tag {
padding: 4rpx 10rpx;
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
font-size: 22rpx;
border-radius: 6rpx;
}
/* text-lg=18px→32rpx — 储值维度缩小避免挤压客户列表 */
.salary-amount {
font-size: 28rpx;
font-weight: 700;
color: #242424;
}
/* ===== 第二行 mt-1.5=6px→12rpx, text-xs=12px→22rpx ===== */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
/* gap-2=8px→12rpx */
.customer-list {
display: flex;
align-items: center;
gap: 12rpx;
overflow: hidden;
flex: 1;
min-width: 0;
}
.customer-item {
font-size: 22rpx;
color: #a6a6a6;
white-space: nowrap;
}
.bottom-right {
font-size: 22rpx;
font-weight: 500;
flex-shrink: 0;
}
.bottom-right--warning {
color: #ed7b2f;
}
.bottom-right--success {
color: #00a870;
}
.bottom-right-group {
display: flex;
align-items: center;
gap: 10rpx;
flex-shrink: 0;
}
.bottom-perf {
font-size: 22rpx;
color: #4b4b4b;
font-weight: 600;
}
.bottom-perf-val {
font-weight: 700;
color: #4b4b4b;
}
.bottom-sub {
font-size: 22rpx;
color: #8b8b8b;
}
.bottom-sub-val {
color: #8b8b8b;
}
.bottom-divider {
font-size: 22rpx;
color: #c5c5c5;
}
/* ===== 底部安全区(自定义导航栏高度 100rpx + safe-area ===== */
.safe-bottom {
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "客户看板",
"enablePullDownRefresh": true,
"usingComponents": {
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"heart-icon": "/components/heart-icon/heart-icon",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"board-tab-bar": "/components/board-tab-bar/board-tab-bar",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,300 @@
// 客户看板页 — 8 个维度查看前 100 名客户
// TODO: 联调时替换 mock 数据为真实 API 调用
export {}
/** 维度类型 → 卡片模板映射 */
type DimType = 'recall' | 'potential' | 'balance' | 'recharge' | 'recent' | 'spend60' | 'freq60' | 'loyal'
const DIMENSION_TO_DIM: Record<string, DimType> = {
recall: 'recall',
potential: 'potential',
balance: 'balance',
recharge: 'recharge',
recent: 'recent',
spend60: 'spend60',
freq60: 'freq60',
loyal: 'loyal',
}
const DIMENSION_OPTIONS = [
{ value: 'recall', text: '最应召回' },
{ value: 'potential', text: '最大消费潜力' },
{ value: 'balance', text: '最高余额' },
{ value: 'recharge', text: '最近充值' },
{ value: 'recent', text: '最近到店' },
{ value: 'spend60', text: '最高消费 近60天' },
{ value: 'freq60', text: '最频繁 近60天' },
{ value: 'loyal', text: '最专一 近60天' },
]
const PROJECT_OPTIONS = [
{ value: 'all', text: '全部' },
{ value: 'chinese', text: '🎱 中式/追分' },
{ value: 'snooker', text: '斯诺克' },
{ value: 'mahjong', text: '🀄 麻将/棋牌' },
{ value: 'karaoke', text: '🎤 团建/K歌' },
]
interface AssistantInfo {
name: string
cls: string // assistant--assignee / assistant--abandoned / assistant--normal
heartScore: number // 0-10heart-icon 组件用
badge?: string // 跟 / 弃
badgeCls?: string // assistant-badge--follow / assistant-badge--drop
}
interface CustomerItem {
id: string
name: string
initial: string
avatarCls: string
// 召回维度
idealDays: number
elapsedDays: number
overdueDays: number
visits30d: number
balance: string
recallIndex: string
// 消费潜力维度
potentialTags: Array<{ text: string; theme: string }>
spend30d: string
avgVisits: string
avgSpend: string
// 余额维度
lastVisit: string
monthlyConsume: string
availableMonths: string
// 充值维度
lastRecharge: string
rechargeAmount: string
recharges60d: string
currentBalance: string
// 消费60天
spend60d: string
visits60d: string
highSpendTag: boolean
// 频率60天
avgInterval: string
weeklyVisits: Array<{ val: number; pct: number }>
// 专一度
intimacy: string
coachName: string
coachRatio: string
topCoachName: string
topCoachHeart: number
topCoachScore: string
coachDetails: Array<{
name: string
cls: string
heartScore: number
badge?: string
avgDuration: string
serviceCount: string
coachSpend: string
relationIdx: number
}>
// 最近到店
visitFreq: string
daysAgo: number
// 助教
assistants: AssistantInfo[]
}
/** Mock 数据(忠于 H5 原型 3 位客户) */
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',
potentialTags: [
{ text: '高频', theme: 'primary' },
{ text: '高客单', theme: 'warning' },
],
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',
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 },
],
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,
assistants: [
{ name: '小燕', cls: 'assistant--assignee', heartScore: 9.2, badge: '跟', badgeCls: 'assistant-badge--follow' },
{ name: '泡芙', cls: 'assistant--normal', heartScore: 6.5 },
],
},
{
id: 'u2', name: '李女士', initial: '李', avatarCls: 'avatar--pink',
idealDays: 10, elapsedDays: 22, overdueDays: 12,
visits30d: 1, balance: '¥8,200', recallIndex: '8.5',
potentialTags: [
{ text: '高余额', theme: 'success' },
{ text: '低频', theme: 'gray' },
],
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: 'Amy', topCoachHeart: 8.5, topCoachScore: '8.5',
coachDetails: [
{ name: 'Amy', 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 },
],
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: 'Amy', coachRatio: '62%',
visitFreq: '2.1次/月',
daysAgo: 12,
assistants: [
{ name: 'Amy', 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: 'Amy', 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' },
],
},
]
Page({
data: {
pageState: 'loading' as 'loading' | 'empty' | 'normal',
activeTab: 'customer' as 'finance' | 'customer' | 'coach',
selectedDimension: 'recall',
dimensionOptions: DIMENSION_OPTIONS,
selectedProject: 'all',
projectOptions: PROJECT_OPTIONS,
/** 当前维度类型,控制卡片模板 */
dimType: 'recall' as DimType,
customers: [] as CustomerItem[],
allCustomers: [] as CustomerItem[],
totalCount: 0,
/** 筛选栏可见性 */
filterBarVisible: true,
},
_lastScrollTop: 0,
_scrollAcc: 0,
_scrollDir: null as 'up' | 'down' | null,
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 滚动隐藏/显示筛选栏 */
onPageScroll(e: { scrollTop: number }) {
const y = e.scrollTop
const delta = y - this._lastScrollTop
this._lastScrollTop = y
if (y <= 8) {
if (!this.data.filterBarVisible) this.setData({ filterBarVisible: true })
this._scrollAcc = 0
this._scrollDir = null
return
}
if (Math.abs(delta) <= 2) return
const dir = delta > 0 ? 'down' : 'up'
if (dir !== this._scrollDir) {
this._scrollDir = dir
this._scrollAcc = 0
}
this._scrollAcc += Math.abs(delta)
const threshold = dir === 'up' ? 12 : 24
if (this._scrollAcc < threshold) return
const visible = dir === 'up'
if (this.data.filterBarVisible !== visible) {
this.setData({ filterBarVisible: visible })
}
this._scrollAcc = 0
},
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
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',
})
}, 400)
},
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' })
}
},
onDimensionChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const val = e.detail.value
this.setData({
selectedDimension: val,
dimType: DIMENSION_TO_DIM[val] || 'recall',
})
},
onProjectChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedProject: e.detail.value })
},
onCustomerTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/customer-detail/customer-detail?id=' + id })
},
})

View File

@@ -0,0 +1,301 @@
<!-- 客户看板页 — 忠于 H5 原型8 维度卡片模板 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="70rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无客户数据" />
</view>
<!-- 正常态 -->
<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>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-bar {{filterBarVisible ? '' : 'filter-bar--hidden'}}">
<view class="filter-bar-inner">
<view class="filter-item filter-item--wide">
<filter-dropdown
label="最应召回"
options="{{dimensionOptions}}"
value="{{selectedDimension}}"
bind:change="onDimensionChange"
/>
</view>
<view class="filter-item">
<filter-dropdown
label="全部"
options="{{projectOptions}}"
value="{{selectedProject}}"
bind:change="onProjectChange"
/>
</view>
</view>
</view>
<!-- 列表头部 -->
<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>
</view>
<!-- 客户列表 -->
<view class="customer-list">
<view
class="customer-card"
wx:for="{{customers}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCustomerTap"
>
<!-- ===== 卡片头部:头像 + 姓名 + 右侧指标 ===== -->
<view class="card-header">
<view class="card-avatar {{item.avatarCls}}">
<text class="avatar-text">{{item.initial}}</text>
</view>
<text class="card-name" wx:if="{{dimType !== 'freq60'}}">{{item.name}}</text>
<!-- 最频繁:姓名 + 下方小字垂直排列 -->
<view class="card-name-group" wx:if="{{dimType === 'freq60'}}">
<text class="card-name">{{item.name}}</text>
<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="margin-left: 16rpx; white-space: nowrap;">60天消费 <text class="mid-dark">{{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>
<view class="overdue-tag {{item.overdueDays > 7 ? 'overdue-tag--danger' : 'overdue-tag--warn'}}">
<text>超期 {{item.overdueDays}}天</text>
</view>
</view>
<!-- 最大消费潜力:频率/客单/余额标签 -->
<view class="header-metrics" wx:elif="{{dimType === 'potential'}}">
<view class="potential-tag potential-tag--{{tag.theme}}" wx:for="{{item.potentialTags}}" wx:for-item="tag" wx:key="text">
<text>{{tag.text}}</text>
</view>
</view>
<!-- 最高余额:最近到店/理想 -->
<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>
</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>
</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-unit">次</text>
</view>
<text class="freq-big-label">60天到店</text>
</view>
<!-- 最专一近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>
</view>
<!-- 最高消费近60天高消费标签 -->
<view class="header-metrics" wx:elif="{{dimType === 'spend60'}}">
<view class="potential-tag potential-tag--warning" wx:if="{{item.highSpendTag}}">
<text>高消费</text>
</view>
</view>
<!-- 最近到店:右上角大字 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-unit">天前到店</text>
</view>
</view>
</view>
<!-- ===== 卡片中间行:维度特定数据 ===== -->
<!-- 最应召回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>
</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--warning grid-val--lg">{{item.spend30d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">月均到店</text>
<text class="grid-val">{{item.avgVisits}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">余额</text>
<text class="grid-val grid-val--success">{{item.balance}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
</view>
</view>
<!-- 最高余额3 列网格 -->
<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>
</view>
<view class="grid-cell">
<text class="grid-label">月均消耗</text>
<text class="grid-val">{{item.monthlyConsume}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">可用</text>
<text class="grid-val grid-val--success">{{item.availableMonths}}</text>
</view>
</view>
<!-- 最近充值4 列网格 -->
<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>
</view>
<view class="grid-cell">
<text class="grid-label">充值</text>
<text class="grid-val grid-val--success">{{item.rechargeAmount}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">60天充值</text>
<text class="grid-val">{{item.recharges60d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">当前余额</text>
<text class="grid-val grid-val--warning">{{item.currentBalance}}</text>
</view>
</view>
<!-- 最高消费近60天3 列网格 -->
<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>
</view>
<view class="grid-cell">
<text class="grid-label">到店次数</text>
<text class="grid-val">{{item.visits60d}}</text>
</view>
<view class="grid-cell">
<text class="grid-label">次均消费</text>
<text class="grid-val">{{item.avgSpend}}</text>
</view>
</view>
<!-- 最频繁近60天无中间行数据已在头部 -->
<!-- 最频繁迷你柱状图8 周) -->
<view class="mini-chart" wx:if="{{dimType === 'freq60'}}">
<view class="mini-chart-header">
<text class="mini-chart-label">8周前</text>
<text class="mini-chart-label">本周</text>
</view>
<view class="mini-chart-bars">
<view class="mini-bar-col" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">
<view class="mini-bar" style="height:{{wv.pct}}%;opacity:{{0.2 + wv.pct * 0.006}}"></view>
</view>
</view>
<view class="mini-chart-nums">
<text class="mini-chart-num {{wIdx === item.weeklyVisits.length - 1 ? 'mini-chart-num--active' : ''}}" wx:for="{{item.weeklyVisits}}" wx:for-item="wv" wx:for-index="wIdx" wx:key="wIdx">{{wv.val}}</text>
</view>
</view>
<!-- 最专一近60天助教服务明细表 -->
<view class="loyal-table" wx:elif="{{dimType === 'loyal'}}">
<!-- 表头 -->
<view class="loyal-row loyal-row--header">
<text class="loyal-col loyal-col--name">助教</text>
<text class="loyal-col">次均时长</text>
<text class="loyal-col">服务次数</text>
<text class="loyal-col">助教消费</text>
<text class="loyal-col">关系指数</text>
</view>
<!-- 数据行 -->
<view class="loyal-row" wx:for="{{item.coachDetails}}" wx:for-item="cd" wx:key="name">
<view class="loyal-col loyal-col--name">
<heart-icon score="{{cd.heartScore}}" />
<text class="loyal-coach-name {{cd.cls}}">{{cd.name}}</text>
<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>
</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">次均消费 <text class="mid-dark">{{item.avgSpend}}</text></text>
</view>
<!-- ===== 卡片底部:助教行(最专一维度不显示,因为助教信息在表格中) ===== -->
<view class="card-assistant-row" wx:if="{{item.assistants && item.assistants.length > 0 && dimType !== 'loyal'}}">
<text class="assistant-label">助教:</text>
<block wx:for="{{item.assistants}}" wx:for-item="ast" wx:for-index="astIdx" wx:key="name">
<text class="assistant-sep" wx:if="{{astIdx > 0}}">|</text>
<view class="assistant-tag">
<heart-icon score="{{ast.heartScore}}" />
<text class="assistant-name {{ast.cls}}">{{ast.name}}</text>
<text class="assistant-badge assistant-badge--follow" wx:if="{{ast.badge === '跟'}}">跟</text>
<text class="assistant-badge assistant-badge--drop" wx:elif="{{ast.badge === '弃'}}">弃</text>
</view>
</block>
</view>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</block>
<!-- 自定义底部导航栏 -->
<board-tab-bar active="board" />
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{220}}" />
<dev-fab />

View File

@@ -0,0 +1,637 @@
/* 客户看板页 — 忠于 H5 原型87.5% 缩放 */
/* ===== 三态 ===== */
.page-loading,
.page-empty {
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
}
/* ===== 看板 Tab对齐 board-coach 规范) ===== */
.board-tabs {
display: flex;
background: #ffffff;
border-bottom: 2rpx solid #eeeeee;
position: sticky;
top: 0;
z-index: 20;
}
.board-tab {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 26rpx;
font-weight: 500;
color: #8b8b8b;
position: relative;
}
.board-tab--active {
color: #0052d9;
font-weight: 600;
}
.board-tab--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 42rpx;
height: 5rpx;
background: linear-gradient(90deg, #0052d9, #5b9cf8);
border-radius: 3rpx;
}
/* ===== 筛选区域(对齐 board-coach 规范) ===== */
.filter-bar {
background: #f3f3f3;
padding: 14rpx 28rpx;
position: sticky;
top: 70rpx;
z-index: 15;
border-bottom: 2rpx solid #eeeeee;
transition: transform 220ms ease, opacity 220ms ease;
}
.filter-bar--hidden {
opacity: 0;
transform: translateY(-110%);
pointer-events: none;
}
.filter-bar-inner {
display: flex;
align-items: center;
gap: 14rpx;
background: #ffffff;
border-radius: 14rpx;
padding: 10rpx;
border: 2rpx solid #eeeeee;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.filter-item {
flex: 1;
min-width: 0;
}
.filter-item--wide {
flex: 1.8;
}
/* ===== 列表头部 ===== */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 28rpx 12rpx;
}
.list-header-left {
display: flex;
align-items: center;
gap: 8rpx;
}
.list-header-title {
font-size: 28rpx;
font-weight: 600;
color: #242424;
}
.list-header-sub {
font-size: 24rpx;
color: #a6a6a6;
}
.list-header-count {
font-size: 24rpx;
color: #c5c5c5;
}
/* ===== 客户列表 ===== */
.customer-list {
padding: 0 28rpx 24rpx;
margin-top: 4rpx;
}
/* ===== 客户卡片 ===== */
.customer-card {
background: #ffffff;
border-radius: 28rpx;
padding: 30rpx 28rpx 26rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.customer-card:active {
opacity: 0.96;
transform: scale(0.98);
}
/* ===== 卡片头部 ===== */
.card-header {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 6rpx;
}
.card-avatar {
width: 66rpx;
height: 66rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
color: #ffffff;
font-size: 26rpx;
font-weight: 600;
}
/* 头像渐变色 */
.avatar--amber { background: linear-gradient(135deg, #fbbf24, #f97316); }
.avatar--pink { background: linear-gradient(135deg, #f472b6, #f43f5e); }
.avatar--blue { background: linear-gradient(135deg, #60a5fa, #6366f1); }
.avatar--green { background: linear-gradient(135deg, #4ade80, #10b981); }
.avatar--purple { background: linear-gradient(135deg, #a78bfa, #8b5cf6); }
.avatar--cyan { background: linear-gradient(135deg, #22d3ee, #14b8a6); }
.avatar--rose { background: linear-gradient(135deg, #fb7185, #e11d48); }
.avatar--indigo { background: linear-gradient(135deg, #818cf8, #4f46e5); }
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #242424;
flex-shrink: 0;
}
/* 最频繁:姓名+小字垂直排列 */
.card-name-group {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.card-name-sub {
display: flex;
align-items: center;
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
}
.card-header-spacer {
flex: 1;
}
/* 头部右侧指标区 */
.header-metrics {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
/* 最频繁维度:右上角大字到店次数 */
.header-metrics--freq {
flex-direction: column;
align-items: flex-end;
gap: 0;
}
.freq-big-num {
display: flex;
align-items: baseline;
}
.freq-big-val {
font-size: 36rpx;
font-weight: 700;
color: #0052d9;
line-height: 1;
}
.freq-big-unit {
font-size: 22rpx;
font-weight: 400;
color: #a6a6a6;
margin-left: 2rpx;
}
.freq-big-label {
font-size: 20rpx;
color: #a6a6a6;
margin-top: -2rpx;
}
/* 最近到店维度:右上角大字 X天前到店 */
.header-metrics--recent {
gap: 4rpx;
}
.recent-big-num {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.recent-big-val {
font-size: 40rpx;
font-weight: 700;
color: #00a870;
line-height: 1;
}
.recent-big-unit {
font-size: 22rpx;
font-weight: 400;
color: #a6a6a6;
}
.metric-gray {
font-size: 22rpx;
color: #a6a6a6;
}
.metric-dark {
color: #393939;
font-weight: 600;
}
.metric-error {
color: #e34d59;
font-weight: 700;
}
/* 超期标签 */
.overdue-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
font-weight: 700;
border-radius: 6rpx;
}
.overdue-tag--danger {
background: rgba(227, 77, 89, 0.1);
color: #e34d59;
}
.overdue-tag--warn {
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
}
/* 消费潜力标签 */
.potential-tag {
padding: 4rpx 10rpx;
font-size: 22rpx;
border-radius: 6rpx;
}
.potential-tag--primary {
background: rgba(0, 82, 217, 0.1);
color: #0052d9;
}
.potential-tag--warning {
background: rgba(237, 123, 47, 0.1);
color: #ed7b2f;
}
.potential-tag--success {
background: rgba(0, 168, 112, 0.1);
color: #00a870;
}
.potential-tag--gray {
background: #eeeeee;
color: #777777;
}
/* ===== 卡片中间行flex 布局,左对齐名字位置) ===== */
.card-mid-row {
display: flex;
align-items: center;
padding: 6rpx 0 4rpx 80rpx;
}
.mid-text {
font-size: 24rpx;
color: #c5c5c5;
}
.mid-dark {
color: #393939;
font-weight: 600;
}
.mid-primary {
color: #0052d9;
font-weight: 500;
}
.mid-primary-bold {
color: #0052d9;
font-weight: 700;
}
.mid-ml {
margin-left: 20rpx;
}
.mid-right {
margin-left: auto;
}
.mid-error {
color: #e34d59;
font-weight: 700;
}
/* ===== 网格布局 ===== */
.card-grid {
display: grid;
gap: 12rpx;
padding: 6rpx 0 4rpx 80rpx;
}
.card-grid--3 {
grid-template-columns: repeat(3, 1fr);
text-align: center;
}
.card-grid--4 {
grid-template-columns: repeat(4, 1fr);
text-align: center;
}
.grid-cell {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.grid-label {
font-size: 18rpx;
color: #a6a6a6;
}
.grid-val {
font-size: 24rpx;
font-weight: 600;
color: #393939;
}
.grid-val--success {
color: #00a870;
}
.grid-val--warning {
color: #ed7b2f;
}
.grid-val--lg {
font-size: 28rpx;
font-weight: 700;
}
/* ===== 迷你柱状图(最频繁维度) ===== */
.mini-chart {
padding: 8rpx 0 4rpx 80rpx;
}
.mini-chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 4rpx;
}
.mini-chart-label {
font-size: 18rpx;
color: #c5c5c5;
}
.mini-chart-bars {
display: flex;
align-items: flex-end;
gap: 6rpx;
height: 48rpx;
}
.mini-bar-col {
flex: 1;
display: flex;
align-items: flex-end;
height: 100%;
}
.mini-bar {
width: 100%;
background: rgba(0, 82, 217, 0.3);
border-radius: 4rpx 4rpx 0 0;
min-height: 4rpx;
}
.mini-chart-nums {
display: flex;
gap: 6rpx;
margin-top: 4rpx;
}
.mini-chart-num {
flex: 1;
text-align: center;
font-size: 18rpx;
color: #c5c5c5;
}
.mini-chart-num--active {
color: #0052d9;
font-weight: 700;
}
/* ===== 助教行 ===== */
.card-assistant-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 10rpx;
margin-left: 80rpx;
padding-top: 10rpx;
border-top: 2rpx solid rgba(0, 0, 0, 0.04);
}
.assistant-label {
font-size: 22rpx;
color: #a6a6a6;
flex-shrink: 0;
}
.assistant-tag {
display: flex;
align-items: center;
gap: 4rpx;
}
.assistant-heart {
width: 24rpx;
height: 24rpx;
}
.assistant-name {
font-size: 22rpx;
font-weight: 500;
}
.assistant-name.assistant--assignee {
color: #e34d59;
font-weight: 700;
}
.assistant-name.assistant--abandoned {
color: #a6a6a6;
}
.assistant-name.assistant--normal {
color: #242424;
}
.assistant-sep {
font-size: 20rpx;
color: #c5c5c5;
margin: 0 6rpx;
}
/* 跟/弃 badge — 忠于 H5 原型:白字 + 渐变背景 + 阴影 */
.assistant-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28rpx;
height: 24rpx;
padding: 0 8rpx;
border-radius: 10rpx;
font-size: 18rpx;
line-height: 1;
font-weight: 700;
letter-spacing: 0.5rpx;
margin-left: 4rpx;
color: #ffffff;
}
.assistant-badge--follow {
background: linear-gradient(135deg, #e34d59 0%, #f26a76 100%);
border: 1rpx solid rgba(227, 77, 89, 0.28);
box-shadow: 0 2rpx 6rpx rgba(227, 77, 89, 0.28);
}
.assistant-badge--drop {
background: linear-gradient(135deg, #d4d4d4 0%, #b6b6b6 100%);
border: 1rpx solid rgba(120, 120, 120, 0.18);
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.14);
}
/* ===== 最专一维度:助教服务明细表 ===== */
.loyal-table {
padding: 6rpx 0 4rpx 80rpx;
border-left: 4rpx solid #eeeeee;
margin-left: 80rpx;
padding-left: 14rpx;
margin-top: 4rpx;
}
.loyal-row {
display: flex;
align-items: center;
gap: 4rpx;
padding: 6rpx 0;
}
.loyal-row--header {
padding-bottom: 8rpx;
}
.loyal-row--header .loyal-col {
font-size: 20rpx;
color: #c5c5c5;
}
.loyal-col {
flex: 1;
text-align: right;
font-size: 24rpx;
}
.loyal-col--name {
width: 140rpx;
flex: none;
text-align: left;
display: flex;
align-items: center;
gap: 4rpx;
}
.loyal-coach-name {
font-size: 22rpx;
font-weight: 500;
}
.loyal-coach-name.assistant--assignee {
color: #e34d59;
font-weight: 700;
}
.loyal-coach-name.assistant--abandoned {
color: #a6a6a6;
}
.loyal-coach-name.assistant--normal {
color: #242424;
}
.loyal-val {
font-weight: 600;
color: #393939;
}
.loyal-val--primary {
font-weight: 700;
color: #0052d9;
}
.loyal-val--gray {
color: #8b8b8b;
}
/* 最专一头部右侧 */
.header-metrics--loyal {
gap: 6rpx;
}
.loyal-top-name {
font-size: 24rpx;
font-weight: 600;
color: #e34d59;
}
.loyal-top-score {
font-size: 24rpx;
font-weight: 700;
color: #0052d9;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: 200rpx;
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "看板",
"enablePullDownRefresh": true,
"usingComponents": {
"metric-card": "/components/metric-card/metric-card",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty"
}
}

View File

@@ -0,0 +1,345 @@
// 财务看板页 — 忠于 H5 原型结构,内联 mock 数据
// TODO: 联调时替换 mock 数据为真实 API 调用
/** 目录板块定义 */
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仅供经营参考非财务属性。',
},
}
Page({
data: {
pageState: 'normal' as 'loading' | 'empty' | 'normal',
/** 时间筛选 */
selectedTime: 'month',
timeOptions: [
{ value: 'month', text: '本月' },
{ value: 'lastMonth', text: '上月' },
{ value: 'week', text: '本周' },
{ value: 'lastWeek', text: '上周' },
{ value: 'quarter3', text: '前3个月 不含本月' },
{ value: 'quarter', text: '本季度' },
{ value: 'lastQuarter', text: '上季度' },
{ value: 'half6', text: '最近6个月不含本月' },
],
/** 区域筛选 */
selectedArea: 'all',
areaOptions: [
{ value: 'all', text: '全部区域' },
{ value: 'hall', text: '大厅' },
{ value: 'hallA', text: 'A区' },
{ value: 'hallB', text: 'B区' },
{ value: 'hallC', text: 'C区' },
{ value: 'mahjong', text: '麻将房' },
{ value: 'teamBuilding', text: '团建房' },
],
/** 环比开关 */
compareEnabled: false,
/** 目录导航 */
tocVisible: false,
tocItems: [
{ emoji: '📈', title: '经营一览', sectionId: 'section-overview' },
{ emoji: '💳', title: '预收资产', sectionId: 'section-recharge' },
{ emoji: '💰', title: '应计收入确认', sectionId: 'section-revenue' },
{ emoji: '🧾', title: '现金流入', sectionId: 'section-cashflow' },
{ emoji: '📤', title: '现金流出', sectionId: 'section-expense' },
{ emoji: '🎱', title: '助教分析', sectionId: 'section-coach' },
] as TocItem[],
currentSectionIndex: 0,
scrollIntoView: '',
/** 提示弹窗 */
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%',
},
/** 预收资产 */
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%',
giftRows: [
{
label: '新增', total: '¥108,600', totalCompare: '9.8%',
wine: '¥43,200', wineCompare: '11.2%',
table: '¥54,100', tableCompare: '8.5%',
coupon: '¥11,300', couponCompare: '6.3%',
},
{
label: '消费', total: '¥75,800', totalCompare: '7.2%',
wine: '¥32,100', wineCompare: '8.1%',
table: '¥32,800', tableCompare: '6.5%',
coupon: '¥10,900', couponCompare: '5.8%',
},
{
label: '余额', total: '¥243,900', totalCompare: '4.5%',
wine: '¥118,500', wineCompare: '5.2%',
table: '¥109,200', tableCompare: '3.8%',
coupon: '¥16,200', couponCompare: '2.5%',
},
],
allCardBalance: '¥586,500',
allCardBalanceCompare: '6.2%',
},
/** 应计收入确认 */
revenue: {
structureRows: [
{ name: '开台与包厢', amount: '¥358,600', discount: '-¥45,200', booked: '¥313,400', bookedCompare: '9.2%' },
{ name: 'A区', amount: '¥118,200', discount: '-¥11,600', booked: '¥106,600', bookedCompare: '12.1%', isSub: true },
{ name: 'B区', amount: '¥95,800', discount: '-¥11,200', booked: '¥84,600', bookedCompare: '8.5%', isSub: true },
{ name: 'C区', amount: '¥72,600', discount: '-¥11,100', booked: '¥61,500', bookedCompare: '6.3%', isSub: true },
{ name: '团建区', amount: '¥48,200', discount: '-¥6,800', booked: '¥41,400', bookedCompare: '5.8%', isSub: true },
{ name: '麻将区', amount: '¥23,800', discount: '-¥4,500', booked: '¥19,300', bookedCompare: '-2.1%', isSub: true },
{ name: '助教', desc: '基础课', amount: '¥232,500', discount: '-', booked: '¥232,500', bookedCompare: '15.3%' },
{ name: '助教', desc: '激励课', amount: '¥112,800', discount: '-', booked: '¥112,800', bookedCompare: '8.2%' },
{ 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: '-¥45,200', compare: '3.1%' },
{ name: '赠送卡抵扣', value: '-¥42,016', compare: '2.5%' },
{ name: '团购差价', value: '-¥26,120', compare: '5.2%' },
],
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%' },
],
},
/** 现金流入 */
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%',
},
/** 现金流出 */
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%',
},
/** 助教分析 */
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 },
],
},
incentive: {
totalPay: '¥112,800',
totalPayCompare: '8.2%',
totalShare: '¥33,840',
totalShareCompare: '8.2%',
avgHourly: '¥15/h',
avgHourlyCompare: '2.1%',
},
},
},
onLoad() {
// mock 数据已内联,直接显示
},
onPullDownRefresh() {
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
/** 看板 Tab 切换 */
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' })
}
},
/** 时间筛选变更 */
onTimeChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedTime: e.detail.value })
},
/** 区域筛选变更 */
onAreaChange(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ selectedArea: e.detail.value })
},
/** 环比开关切换 */
toggleCompare() {
this.setData({ compareEnabled: !this.data.compareEnabled })
},
/** 目录导航开关 */
toggleToc() {
this.setData({ tocVisible: !this.data.tocVisible })
},
closeToc() {
this.setData({ tocVisible: false })
},
/** 目录项点击 → 滚动到对应板块 */
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,
scrollIntoView: sectionId,
})
}
},
/** 帮助图标点击 → 弹出说明 */
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,
})
}
},
/** 关闭提示弹窗 */
closeTip() {
this.setData({ tipVisible: false })
},
})

View File

@@ -0,0 +1,734 @@
<!-- 财务看板页 — 忠于 H5 原型结构 -->
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空状态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无财务数据" />
</view>
<!-- 正常态 -->
<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>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-bar">
<view class="filter-bar-inner">
<!-- 目录按钮 -->
<view class="toc-btn" bindtap="toggleToc">
<t-icon name="view-list" size="40rpx" color="#ffffff" />
</view>
<!-- 时间筛选 -->
<view class="filter-item">
<filter-dropdown
label="本月"
options="{{timeOptions}}"
value="{{selectedTime}}"
bind:change="onTimeChange"
/>
</view>
<!-- 区域筛选 -->
<view class="filter-item">
<filter-dropdown
label="全部区域"
options="{{areaOptions}}"
value="{{selectedArea}}"
bind:change="onAreaChange"
/>
</view>
<!-- 环比开关 -->
<view class="compare-switch" bindtap="toggleCompare">
<text class="compare-label">环比</text>
<view class="compare-toggle {{compareEnabled ? 'compare-toggle--active' : ''}}">
<view class="compare-toggle-dot"></view>
</view>
</view>
</view>
</view>
<!-- 滚动内容区 -->
<scroll-view
class="board-content"
scroll-y
scroll-into-view="{{scrollIntoView}}"
scroll-with-animation
>
<!-- ===== 板块 1: 经营一览(深色) ===== -->
<view id="section-overview" class="card-section section-dark">
<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-desc-dark">快速了解收入与现金流的整体健康度</text>
</view>
</view>
<!-- 收入概览 -->
<view class="sub-section-label">
<text class="sub-label-text">收入概览</text>
<text class="sub-label-desc">记账口径收入与优惠</text>
</view>
<view class="overview-grid-3">
<view class="overview-cell">
<view class="cell-label-row">
<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>
</view>
</view>
<view class="overview-cell">
<view class="cell-label-row">
<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>
</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>
</view>
</view>
</view>
<!-- 成交/确认收入 -->
<view class="confirmed-row">
<view class="confirmed-left">
<text class="confirmed-label">成交/确认收入</text>
<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>
</view>
</view>
</view>
<view class="section-divider-light"></view>
<!-- 现金流水概览 -->
<view class="sub-section-label">
<text class="sub-label-text">现金流水概览</text>
<text class="sub-label-desc">往期为已结算 本期为截至当前的发生额</text>
</view>
<view class="overview-grid-2">
<view class="overview-cell-bg">
<view class="cell-label-row">
<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>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<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>
</view>
</view>
<view class="overview-cell-bg">
<view class="cell-label-row">
<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>
</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>
</view>
</view>
</view>
<!-- AI 洞察 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">🤖</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>
<text class="ai-insight-line"><text class="ai-insight-dim">建议关注:</text>充值高但消耗低,会员活跃度需提升</text>
</view>
</view>
</view>
<!-- ===== 板块 2: 预收资产 ===== -->
<view id="section-recharge" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">💳</text>
<view class="card-header-text">
<text class="card-header-title-light">预收资产</text>
<text class="card-header-desc-light">会员卡充值与余额 掌握资金沉淀</text>
</view>
</view>
<!-- 储值卡统计 -->
<view class="section-body">
<text class="card-section-title">储值卡统计</text>
<view class="table-bordered">
<!-- 行1储值卡充值实收 -->
<view class="table-row table-row--highlight">
<view class="table-row-left">
<text class="table-row-label-bold">储值卡充值实收</text>
<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>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.actualCompare}}</text>
</view>
</view>
</view>
<!-- 行2首充/续费/消耗 三列 -->
<view class="table-row-grid3">
<view class="grid-cell">
<view class="cell-label-row-sm">
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.firstChargeCompare}}</text>
</view>
</view>
<view class="grid-cell">
<view class="cell-label-row-sm">
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.renewChargeCompare}}</text>
</view>
</view>
<view class="grid-cell">
<view class="cell-label-row-sm">
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{recharge.consumedCompare}}</text>
</view>
</view>
</view>
<!-- 行3储值卡总余额 -->
<view class="table-row table-row--footer">
<view class="table-row-left">
<text class="table-row-label-bold">储值卡总余额</text>
<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>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.cardBalanceCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 赠送卡统计详情 -->
<text class="card-section-title" style="margin-top: 28rpx;">赠送卡统计详情</text>
<view class="table-bordered">
<!-- 表头 -->
<view class="gift-table-header">
<text class="gift-col gift-col--name">类型</text>
<text class="gift-col">酒水卡</text>
<text class="gift-col">台费卡</text>
<text class="gift-col">抵用券</text>
</view>
<!-- 新增行 -->
<view class="gift-table-row" wx:for="{{recharge.giftRows}}" wx:key="label">
<view class="gift-col gift-col--name">
<text class="gift-row-label">{{item.label}}</text>
<text class="gift-row-total">{{item.total}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.totalCompare}}</text>
</view>
</view>
<view class="gift-col">
<text class="gift-col-val">{{item.wine}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.wineCompare}}</text>
</view>
</view>
<view class="gift-col">
<text class="gift-col-val">{{item.table}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.tableCompare}}</text>
</view>
</view>
<view class="gift-col">
<text class="gift-col-val">{{item.coupon}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.couponCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 全类别会员卡余额合计 -->
<view class="total-balance-row">
<view class="total-balance-left">
<text class="total-balance-label">全类别会员卡余额合计</text>
<view class="help-icon-dark" data-key="allCardBalance" bindtap="onHelpTap">?</view>
<text class="total-balance-note">仅经营参考,非财务属性</text>
</view>
<view class="total-balance-right">
<text class="total-balance-value">{{recharge.allCardBalance}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{recharge.allCardBalanceCompare}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- ===== 板块 3: 应计收入确认 ===== -->
<view id="section-revenue" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">💰</text>
<view class="card-header-text">
<text class="card-header-title-light">【记账】应计收入确认</text>
<text class="card-header-desc-light">从发生额到入账收入的全流程</text>
</view>
</view>
<view class="section-body">
<!-- 收入结构 -->
<view class="sub-title-row">
<text class="sub-title-text">收入结构</text>
<text class="sub-title-desc">按业务查看各项应计收入的构成</text>
</view>
<view class="table-bordered">
<!-- 表头 -->
<view class="rev-table-header">
<text class="rev-col rev-col--name">项目</text>
<text class="rev-col">发生额</text>
<text class="rev-col">优惠</text>
<text class="rev-col">入账</text>
</view>
<!-- 数据行 -->
<block wx:for="{{revenue.structureRows}}" wx:key="name">
<view class="rev-table-row {{item.isSub ? 'rev-table-row--sub' : ''}}">
<view class="rev-col rev-col--name">
<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>
<view class="rev-col">
<text class="rev-val {{item.isSub ? '' : 'rev-val--bold'}}">{{item.booked}}</text>
<view class="compare-row" wx:if="{{compareEnabled && item.bookedCompare}}">
<text class="compare-text-up-xs">↑{{item.bookedCompare}}</text>
</view>
</view>
</view>
</block>
</view>
<!-- 收入确认(损益链) -->
<view class="sub-title-row" style="margin-top: 28rpx;">
<text class="sub-title-text">收入确认</text>
<text class="sub-title-desc">从正价到收款方式的损益链</text>
</view>
<view class="table-bordered">
<!-- 项目正价 标题 -->
<view class="flow-header">
<text class="flow-header-title">项目正价</text>
<text class="flow-header-desc">即标价测算</text>
</view>
<!-- 正价明细(左侧竖线) -->
<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>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 发生额合计 -->
<view class="flow-total-row">
<view class="flow-total-left">
<text class="flow-total-label">发生额</text>
<text class="flow-total-desc">即上列正价合计</text>
</view>
<view class="flow-total-right">
<text class="flow-total-value">{{revenue.totalOccurrence}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{revenue.totalOccurrenceCompare}}</text>
</view>
</view>
</view>
<!-- 优惠扣减 -->
<view class="flow-header flow-header--deduct">
<text class="flow-header-title">优惠扣减</text>
</view>
<view class="flow-detail-list">
<view class="flow-detail-item" wx:for="{{revenue.discountItems}}" wx:key="name">
<text class="flow-detail-name">{{item.name}}</text>
<view class="flow-detail-right">
<text class="flow-detail-val flow-detail-val--red">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-down-xs">↓{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 成交收入 -->
<view class="flow-total-row flow-total-row--accent">
<view class="flow-total-left">
<text class="flow-total-label">成交收入</text>
<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>
</view>
</view>
</view>
<!-- 收款渠道 -->
<view class="flow-header">
<text class="flow-header-title">收款渠道明细</text>
</view>
<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>
</view>
<view class="flow-detail-right">
<text class="flow-detail-val">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- ===== 板块 4: 现金流入 ===== -->
<view id="section-cashflow" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">🧾</text>
<view class="card-header-text">
<text class="card-header-title-light">【现金流水】流入</text>
<text class="card-header-desc-light">实际到账的资金来源明细</text>
</view>
</view>
<view class="section-body">
<!-- 消费收入 -->
<text class="flow-group-label">消费收入</text>
<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>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{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>
</view>
</view>
</view>
</view>
<!-- 充值收入 -->
<text class="flow-group-label" style="margin-top: 20rpx;">充值收入</text>
<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>
</view>
<view class="flow-item-right">
<text class="flow-item-value">{{item.value}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
</view>
<!-- 合计 -->
<view class="flow-sum-row">
<text class="flow-sum-label">现金流入合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{cashflow.total}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{cashflow.totalCompare}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- ===== 板块 5: 现金流出 ===== -->
<view id="section-expense" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">📤</text>
<view class="card-header-text">
<text class="card-header-title-light">【现金流水】流出</text>
<text class="card-header-desc-light">清晰呈现各类开销与结构</text>
</view>
</view>
<view class="section-body">
<!-- 进货与运营 3列 -->
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 固定支出 2×2 -->
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.isFlat ? '' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 助教薪资 2×2 -->
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.isDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.isDown ? '↓' : '↑'}}{{item.compare}}</text>
</view>
</view>
</view>
<!-- 平台服务费 3列 -->
<text class="expense-group-label">平台服务费</text>
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{item.compare}}</text>
</view>
</view>
</view>
<!-- 支出合计 -->
<view class="flow-sum-row">
<text class="flow-sum-label">支出合计</text>
<view class="flow-sum-right">
<text class="flow-sum-value">{{expense.total}}</text>
<view class="compare-row-inline" wx:if="{{compareEnabled}}">
<text class="compare-text-up-sm">↑{{expense.totalCompare}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- ===== 板块 6: 助教分析 ===== -->
<view id="section-coach" class="card-section">
<view class="card-header-light">
<text class="card-header-emoji">🎱</text>
<view class="card-header-text">
<text class="card-header-title-light">助教分析</text>
<text class="card-header-desc-light">全部助教服务收入与分成的平均值</text>
</view>
</view>
<view class="section-body">
<!-- 基础课 -->
<text class="card-section-title">助教 <text class="card-section-title-sub">(基础课)</text></text>
<view class="table-bordered">
<view class="coach-fin-header">
<text class="coach-fin-col coach-fin-col--name">级别</text>
<text class="coach-fin-col">客户支付</text>
<text class="coach-fin-col">球房抽成</text>
<text class="coach-fin-col">小时平均</text>
</view>
<!-- 合计行 -->
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalPayCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.basic.totalShare}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.totalShareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.basic.avgHourly}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.basic.avgHourlyCompare}}</text>
</view>
</view>
</view>
<!-- 明细行 -->
<view class="coach-fin-row" wx:for="{{coachAnalysis.basic.rows}}" wx:key="level">
<text class="coach-fin-col coach-fin-col--name">{{item.level}}</text>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.pay}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.payDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.payDown ? '↓' : '↑'}}{{item.payCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val">{{item.share}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.shareDown ? 'compare-text-down-xs' : 'compare-text-up-xs'}}">{{item.shareDown ? '↓' : '↑'}}{{item.shareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{item.hourly}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="{{item.hourlyFlat ? 'compare-text-flat-xs' : 'compare-text-up-xs'}}">{{item.hourlyFlat ? '' : '↑'}}{{item.hourlyCompare}}</text>
</view>
</view>
</view>
</view>
<!-- 激励课 -->
<text class="card-section-title" style="margin-top: 28rpx;">助教 <text class="card-section-title-sub">(激励课)</text></text>
<view class="table-bordered">
<view class="coach-fin-header">
<text class="coach-fin-col coach-fin-col--name">级别</text>
<text class="coach-fin-col">客户支付</text>
<text class="coach-fin-col">球房抽成</text>
<text class="coach-fin-col">小时平均</text>
</view>
<view class="coach-fin-row">
<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>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalPayCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-bold">{{coachAnalysis.incentive.totalShare}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.totalShareCompare}}</text>
</view>
</view>
<view class="coach-fin-col">
<text class="coach-fin-val-sm">{{coachAnalysis.incentive.avgHourly}}</text>
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up-xs">↑{{coachAnalysis.incentive.avgHourlyCompare}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom"></view>
</scroll-view>
<!-- ===== 目录导航遮罩 ===== -->
<view class="toc-overlay" wx:if="{{tocVisible}}" catchtap="closeToc"></view>
<!-- ===== 目录导航面板 ===== -->
<view class="toc-panel {{tocVisible ? 'toc-panel--show' : ''}}">
<view class="toc-header">
<text class="toc-header-text">📊 财务看板导航</text>
</view>
<view class="toc-list">
<view
class="toc-item {{currentSectionIndex === index ? 'toc-item--active' : ''}}"
wx:for="{{tocItems}}"
wx:key="sectionId"
data-index="{{index}}"
bindtap="onTocItemTap"
>
<text class="toc-item-emoji">{{item.emoji}}</text>
<text class="toc-item-text">{{item.title}}</text>
</view>
</view>
</view>
<!-- ===== 指标说明弹窗 ===== -->
<view class="tip-overlay" wx:if="{{tipVisible}}" catchtap="closeTip"></view>
<view class="tip-toast {{tipVisible ? 'tip-toast--show' : ''}}">
<view class="tip-toast-header">
<text class="tip-toast-title">{{tipTitle}}</text>
<view class="tip-toast-close" bindtap="closeTip">
<t-icon name="close" size="36rpx" color="#8b8b8b" />
</view>
</view>
<text class="tip-toast-content">{{tipContent}}</text>
</view>
</block>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{200}}" />
<dev-fab />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "对话历史",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty"
}
}

View File

@@ -0,0 +1,76 @@
import { mockChatHistory } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 带展示标签的对话历史项 */
interface ChatHistoryDisplay {
id: string
title: string
lastMessage: string
timestamp: string
customerName?: string
/** 格式化后的时间标签 */
timeLabel: string
}
Page({
data: {
/** 页面状态loading / empty / normal */
pageState: 'loading' as 'loading' | 'empty' | 'normal',
/** 对话历史列表 */
list: [] as ChatHistoryDisplay[],
},
onLoad() {
this.loadData()
},
/** 加载数据 */
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const sorted = sortByTimestamp(mockChatHistory)
const list: ChatHistoryDisplay[] = sorted.map((item) => ({
...item,
timeLabel: this.formatTime(item.timestamp),
}))
this.setData({
list,
pageState: list.length === 0 ? 'empty' : 'normal',
})
}, 400)
},
/** 格式化时间为相对标签 */
formatTime(timestamp: string): string {
const now = new Date()
const target = new Date(timestamp)
const diffMs = now.getTime() - target.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHour = Math.floor(diffMs / 3600000)
const diffDay = Math.floor(diffMs / 86400000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
if (diffHour < 24) return `${diffHour}小时前`
if (diffDay < 7) return `${diffDay}天前`
const month = target.getMonth() + 1
const day = target.getDate()
return `${month}${day}`
},
/** 点击对话记录 → 跳转 chat 页面 */
onItemTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: '/pages/chat/chat?historyId=' + id })
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 600)
},
})

View File

@@ -0,0 +1,48 @@
<!-- 加载态 -->
<view class="page-loading" wx:if="{{pageState === 'loading'}}">
<t-loading theme="circular" size="80rpx" text="加载中..." />
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无对话记录" />
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- 对话列表 -->
<view class="chat-list">
<view
class="chat-item"
wx:for="{{list}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onItemTap"
>
<view class="chat-icon-box">
<t-icon name="chat" size="40rpx" color="#ffffff" />
</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>
</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>
</view>
</view>
<t-icon name="chevron-right" size="32rpx" color="#c5c5c5" />
</view>
</view>
<!-- 底部提示 -->
<view class="list-footer">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button bottom="{{120}}" />
</block>
<dev-fab />

Some files were not shown because too many files have changed in this diff Show More