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

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

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

View File

@@ -0,0 +1,107 @@
# P10-NS4-01角色权限管理的 CRUD 界面规范
## 简要结论
- 状态:⚠️ 部分解决
- 数据库层已建立完整的 RBAC 三表模型roles / permissions / role_permissions后端已实现权限查询与校验中间件但缺少角色权限管理的 CRUD 界面——无法通过 UI 创建角色、分配权限、展示权限树。
## 详细审查
### 数据库端
auth schema 中已存在完整的 RBAC 表结构(来源:`docs/database/ddl/zqyy_app__auth.sql`
| 表名 | 用途 | 关键字段 |
|------|------|----------|
| `auth.roles` | 角色定义 | id, code, name, description |
| `auth.permissions` | 权限定义 | id, code, name, description |
| `auth.role_permissions` | 角色-权限映射(多对多) | role_id → roles.id, permission_id → permissions.id |
| `auth.user_site_roles` | 用户-门店-角色绑定 | user_id, site_id, role_id → roles.id |
| `auth.tenant_admins` | 租户管理员账号 | managed_site_idsBIGINT[])控制管辖范围 |
种子数据已预置 4 个角色coach / staff / site_admin / tenant_admin和 5 个权限view_tasks / view_board / view_board_finance / view_board_customer / view_board_coach以及 14 条角色-权限映射。
结论:数据库层 RBAC 模型完整,支持角色-权限多对多关系。`tenant_admins` 表通过 `managed_site_ids` 数组实现租户级/店铺级管理员的管辖范围控制,与 P10 spec 中描述的两种权限级别一致。
### 后端代码
**已实现:**
1. **权限查询服务**`apps/backend/app/services/role.py`
- `get_user_permissions(user_id, site_id)` — 三表联查获取用户权限 code 列表
- `get_user_sites(user_id)` — 获取用户关联的所有店铺及角色
- `check_user_has_site_role(user_id, site_id)` — 检查用户是否有角色绑定
2. **权限校验中间件**`apps/backend/app/middleware/permission.py`
- `require_permission(*codes)` — 依赖注入工厂,校验用户 approved 状态 + 指定权限
- `require_approved()` — 仅校验 approved 状态
- 已在看板路由(`xcx_board.py`)中实际使用:`view_board_finance``view_board_customer``view_board_coach`
3. **租户管理员 CRUD**`apps/backend/app/routers/admin_tenant_admins.py`
- 列表(分页+搜索、创建、编辑display_name / managed_site_ids / is_active、重置密码
- 这是管理员账号的 CRUD不是角色/权限的 CRUD
4. **租户管理员认证**`apps/backend/app/auth/tenant_admins.py` + `tenant_auth.py`
- JWT 签发含 managed_site_idsaud=tenant-admin 与小程序隔离
- `site_filter_clause()` / `verify_site_access()` 实现数据隔离
**未实现:**
-`roles` 表的 CRUD API创建角色、编辑角色、删除角色
-`permissions` 表的 CRUD API创建权限、编辑权限、删除权限
-`role_permissions` 映射的管理 API为角色分配/取消权限)
- 角色和权限数据完全依赖种子 SQL 预置,无法通过 API 动态管理
### 前端代码
**tenant-admin租户管理后台页面清单**
- `Login/` — 登录页 ✅
- `UserApproval/` — 用户审核 ✅
- `UserManagement/` — 用户管理 ✅
- `ExcelUpload/` — Excel 上传 ✅
- `RetentionClues/` — 维客线索管理 ✅
- ❌ 无角色管理页面
- ❌ 无权限管理页面
- ❌ 无权限树组件
**tenant-admin 组件清单:**
- `DiffTable/` — 冲突 diff 交互 ✅
- `ClueEditor/` — 线索编辑 ✅
- `SiteSelector/` — 门店选择器 ✅
- ❌ 无 PermissionTree / RoleEditor 等权限管理组件
**admin-web系统管理后台**
- `TenantAdmins/index.tsx` — 租户管理员 CRUD 页面 ✅
- 功能:列表(分页+搜索)、创建 Modal用户名/密码/显示名称/tenantId/managedSiteIds、编辑 Modal显示名称/managedSiteIds/isActive、重置密码 Modal
- ❌ 无角色选择/分配功能
- ❌ 无权限树展示
### 差距分析
| P10 标杆要求 | 当前实现 | 差距 |
|-------------|---------|------|
| 租户级管理员(管辖所有店铺) | ✅ managed_site_ids 数组控制 | 无 |
| 店铺级管理员(只管指定 site_id | ✅ managed_site_ids 数组控制 | 无 |
| 角色定义coach/staff/site_admin/tenant_admin | ✅ 种子数据预置 | 无法动态增删改 |
| 权限定义5 个 view_* 权限) | ✅ 种子数据预置 | 无法动态增删改 |
| 角色-权限映射 | ✅ 种子数据预置 14 条映射 | 无法通过 UI 调整 |
| 创建角色界面 | ❌ 不存在 | 完全缺失 |
| 分配权限界面 | ❌ 不存在 | 完全缺失 |
| 权限树展示 | ❌ 不存在 | 完全缺失 |
| 用户审核时分配角色 | ⚠️ 部分 | 审核通过时可指定 role但角色列表硬编码非从 roles 表动态读取 |
核心差距RBAC 数据模型已就绪权限校验链路已打通roles → role_permissions → permissions → middleware但管理侧完全依赖种子数据和数据库直接操作缺少面向管理员的 CRUD 界面。这意味着:
- 新增角色需要写 SQL
- 调整角色权限需要写 SQL
- 管理员无法自助查看权限分配全貌
### 建议
1. **短期(低成本)**:在 admin-web 的 TenantAdmins 页面增加"角色权限"只读展示面板,展示当前 4 个角色及其权限映射,让管理员了解权限全貌。无需 CRUD因为当前角色/权限体系相对固定。
2. **中期(按需)**:如果业务发展需要动态角色管理(如新增"前台"角色、调整权限粒度),则需要:
- 后端:新增 `admin_roles.py` 路由roles CRUD + role_permissions 分配)
- 前端:在 admin-web 新增 RoleManagement 页面(角色列表 + 权限树 Ant Design Tree 组件 + 分配交互)
- 在 tenant-admin 的用户审核/管理页面中,角色选择改为从 API 动态获取
3. **权限树组件规范**:如需实现,建议使用 Ant Design `<Tree>` 组件,数据结构为权限按功能模块分组(如"看板"下挂 view_board_*),支持勾选/取消勾选批量分配。
4. **NS4 spec 补充**:在 NS4 文档中明确说明当前权限模型为"预置角色 + 种子数据"模式,角色/权限的动态管理界面作为后续迭代项,避免与 P10 标杆文件的隐含期望产生歧义。

View File

@@ -0,0 +1,119 @@
# P10-NS4-02门店切换功能的交互规范
## 简要结论
- 状态:⚠️ 部分解决
- 后端数据隔离和 JWT 门店权限体系完整;前端 SiteSelector 组件已实现但仅在维客线索页集成,全局布局未放置门店选择器,用户审核和 Excel 上传页面缺少门店筛选,门店名称显示为 ID 而非中文名。
## 详细审查
### 数据库端
**auth.tenant_admins 表**DDL: `db/zqyy_app/migrations/2026-03-20__ns4_tenant_admin_tables.sql`
| 字段 | 类型 | 说明 |
|------|------|------|
| managed_site_ids | BIGINT[] | 管辖门店 ID 数组,用于数据隔离 |
| tenant_id | BIGINT | 所属租户 |
| is_active | BOOLEAN | 账号状态 |
`managed_site_ids` 为 PostgreSQL 数组类型,支持多门店管辖场景。
**public.site_code_mapping 表**(被后端多处引用)
| 用途 | 说明 |
|------|------|
| site_code → site_id 映射 | 用户申请时通过球房编号关联门店 |
| site_id → site_name 映射 | 用户列表、线索搜索时补充门店名称 |
✅ 门店名称映射表存在,后端已在 `tenant_users.py``tenant_clues.py` 中使用。
**结论**:数据库层面完整支持多门店管辖和门店名称映射。
### 后端代码
#### 1. 登录与 JWT`tenant_auth.py`
✅ 登录成功后返回的 JWT payload 包含 `managed_site_ids` 数组:
```python
payload = {
"sub": str(admin_id),
"tenant_id": tenant_id,
"managed_site_ids": managed_site_ids, # ← 门店列表
"aud": "tenant-admin",
}
```
✅ refresh_token 也携带 `managed_site_ids`,刷新后不丢失。
#### 2. 认证中间件(`auth/tenant_admins.py`
`CurrentTenantAdmin` dataclass 包含 `managed_site_ids: list[int]`
`site_filter_clause()` 工具函数生成 `site_id IN (...)` SQL 片段,用于数据隔离。
`verify_site_access()` 校验单个 site_id 是否在管辖范围内,越权返回 403。
✅ 空 managed_site_ids 时生成 `1 = 0` 条件,不匹配任何行(安全兜底)。
#### 3. 查询接口(`tenant_users.py`
`list_applications` — 通过 `site_filter_clause` 附加 `site_id IN (管辖列表)` 条件。
`list_users` — 同上JOIN `user_site_roles` 表做门店隔离。
`approve_application` / `reject_application` — 调用 `verify_site_access` 校验单门店权限。
`edit_user` — 新 site_id 必须在管辖范围内。
#### 4. 维客线索(`tenant_clues.py`grep 结果确认)
✅ 搜索接口支持 `site_id` 参数筛选。
✅ 返回结果补充 `site_name`(通过 `site_code_mapping` 查询)。
**后端结论**:数据隔离体系完整,所有查询均附加 `site_id IN (管辖列表)` 条件,符合 P10 spec 要求。
### 前端代码
#### 1. SiteSelector 组件(`components/SiteSelector/index.tsx`
✅ 组件已实现,基于 Ant Design Select支持多选/全选。
✅ 配套 `useSiteFilter` hook管理选中状态提供 `effectiveSiteIds`(空选时返回全部)。
✅ 数据源来自 `useAuth().user.managedSiteIds`
#### 2. useAuth hook`hooks/useAuth.tsx`
✅ 从 JWT payload 解析 `managedSiteIds` 数组。
✅ 登录后通过 `extractUserInfo` 提取并存入 React Context。
✅ 页面刷新时从 localStorage 恢复。
#### 3. API 服务(`services/api.ts`
✅ JWT 自动附加到请求头。
✅ 401 时自动刷新 token含并发保护
⚠️ 无全局 site_id 拦截器——各页面需自行传递 site_id 参数。
#### 4. App.tsx 全局布局
**全局布局未集成 SiteSelector**。侧边栏仅包含导航菜单和退出按钮,无门店选择器。
❌ 无全局 "当前选中门店" 状态管理(无 SiteContext/SiteProvider
#### 5. 各页面集成情况
| 页面 | SiteSelector 集成 | site_id 传递 | 门店名称显示 |
|------|-------------------|-------------|-------------|
| 维客线索 (RetentionClues) | ✅ 已集成 | ✅ 单门店时传 site_id | ⚠️ 显示 `门店 {id}` |
| 用户管理 (UserManagement) | ❌ 未集成 | ❌ 依赖后端 JWT 隔离 | ⚠️ 显示 `门店 {id}` |
| 用户审核 (UserApproval) | ❌ 未集成 | ❌ 依赖后端 JWT 隔离 | ❌ 仅显示球房编号 |
| Excel 上传 (ExcelUpload) | ❌ 未集成 | ❌ 无门店筛选 | ❌ 无门店信息 |
#### 6. 门店名称问题
⚠️ SiteSelector 选项显示为 `门店 {id}`(硬编码数字),未查询 `site_code_mapping` 获取真实门店名称。用户无法通过名称识别门店。
### 差距分析
| P10 要求 | 当前状态 | 差距 |
|----------|---------|------|
| 租户级管理员管辖所有店铺 | ✅ managed_site_ids 支持 | 无 |
| 店铺级管理员只管指定 site_id | ✅ JWT + site_filter_clause | 无 |
| 所有查询附加 site_id IN 条件 | ✅ 后端全部实现 | 无 |
| 门店选择器 UI | ⚠️ 组件存在但未全局集成 | 仅维客线索页使用 |
| 切换门店后数据自动刷新 | ⚠️ 维客线索页有效 | 其他页面无此机制 |
| 门店选择器位置 | ❌ 未定义全局位置 | App.tsx 布局无选择器 |
| 门店名称可读性 | ❌ 显示 ID 而非名称 | 需查询 site_name |
### 建议
1. **全局门店选择器**:在 `App.tsx``Content` 区域顶部(或 Header放置 SiteSelector创建 `SiteContext` 全局管理当前选中门店,各页面通过 Context 消费。
2. **门店名称映射**:登录后或首次加载时调用后端接口获取 `managed_site_ids → site_name` 映射SiteSelector 选项显示真实门店名称(如"朗朗桌球·XX店")。
3. **页面集成**UserApproval、UserManagement、ExcelUpload 页面接入全局门店筛选API 请求携带 `site_ids` 参数。
4. **切换刷新机制**:门店切换时触发数据重新加载(可通过 `useEffect` 监听 `selectedSiteIds` 变化,或使用 React Query 的 `queryKey` 包含 site_ids 实现自动 refetch
5. **后端补充**:新增 `GET /api/tenant/sites` 接口,返回当前管理员管辖门店的 `{site_id, site_name}` 列表,供前端门店选择器使用。

View File

@@ -0,0 +1,99 @@
# P10-NS4-03数据导出功能的规范
## 简要结论
- 状态:❌ 未解决
- 租户管理后台tenant-admin无任何业务数据导出功能。后端无导出端点前端无导出按钮数据库无导出日志表。现有的"下载"功能仅限 Excel 空白模板下载和系统管理后台的环境配置导出,均与业务数据导出无关。
## 详细审查
### 数据库端
搜索范围:`db/zqyy_app/` 全部 SQL 文件
结果:
- **无导出日志表**`biz` schema 下无 `export_log``download_log` 等导出记录表
- **现有表**`biz.excel_upload_log` 仅记录 Excel **上传**操作upload_type: expense/platform_income/salary_adj/recharge_commission无导出相关字段
- **无导出审计字段**:任何现有表均未包含导出时间、导出人、导出格式等审计字段
### 后端代码
搜索范围:`apps/backend/app/routers/` 全部 tenant_*.py 文件 + 全部路由文件
逐文件审查结果:
| 路由文件 | 导出相关端点 | 说明 |
|----------|-------------|------|
| `tenant_auth.py` | 无 | 仅登录/鉴权 |
| `tenant_users.py` | 无 | 用户审核+管理,无导出 |
| `tenant_excel.py` | `GET /template/{type}` | **模板下载**(空白 Excel 模板),非数据导出 |
| `tenant_clues.py` | 无 | 线索 CRUD无导出 |
| `env_config.py` | `GET /export` | **环境配置导出**admin-web 专用),非业务数据 |
关键发现:
1. `tenant_excel.py` 包含 `download_template()` 函数,使用 `openpyxl` + `StreamingResponse` 生成空白模板文件,仅含表头和格式说明,不含任何业务数据
2. 后端无通用导出工具模块(无 `export_to_excel``export_to_csv` 等工具函数)
3. 全局搜索 `def.*export` 仅命中 `env_config.py``export_env_config()`(系统管理后台功能)
### 前端代码
#### tenant-admin租户管理后台
搜索范围:`apps/tenant-admin/src/` 全部文件
页面清单与导出功能:
| 页面 | 路径 | 导出按钮 | 说明 |
|------|------|---------|------|
| Login | `pages/Login/` | 无 | 登录页 |
| UserApproval | `pages/UserApproval/` | 无 | 用户审核 |
| UserManagement | `pages/UserManagement/` | 无 | 用户管理 |
| ExcelUpload | `pages/ExcelUpload/` | 无 | 仅有模板**下载**按钮(`DownloadOutlined` + `handleDownloadTemplate` |
| RetentionClues | `pages/RetentionClues/` | 无 | 维客线索管理 |
结论5 个页面均无数据导出功能。ExcelUpload 页面的 `DownloadOutlined` 图标用于下载空白模板,非数据导出。
#### admin-web系统管理后台参考
搜索范围:`apps/admin-web/src/` 全部文件
- `EnvConfig.tsx`:有"导出"按钮,调用 `exportEnvConfig()` 导出去敏感值的 `.env` 配置文件 → 运维功能,非业务数据导出
- `OpsPanel.tsx``CloudDownloadOutlined` 用于 Git Pull 操作 → 非数据导出
- **无业务数据导出功能可参考**
### 差距分析
review-report.md 中 P10→NS4 缺失项 #3 指出P10 §数据导出 位置隐含了管理后台应支持数据导出,但 NS4 完全未提及。
作为管理后台,以下场景存在合理的导出需求但均未实现:
| 导出场景 | 数据源 | 潜在用户需求 | 当前状态 |
|----------|--------|-------------|---------|
| 用户列表导出 | `auth.users` | 租户管理员需要离线查看/统计用户信息 | ❌ 无 |
| 审核记录导出 | `auth.user_applications` | 审核工作量统计、合规审计 | ❌ 无 |
| Excel 上传历史导出 | `biz.excel_upload_log` | 上传操作追溯、数据核对 | ❌ 无 |
| 维客线索导出 | `member_retention_clue` | 线索数据离线分析、交接 | ❌ 无 |
| 助教奖罚明细导出 | `biz.salary_adjustments` | 工资核算、财务对账 | ❌ 无 |
补充说明:
- P10 spec 本身也未明确列出"数据导出"功能(无 AC、无任务项但 review-report 将其标记为隐含需求
- P7-NS1-10 审查(绩效明细导出)结论同样为 ❌ 未解决,说明整个项目目前缺乏通用的数据导出基础设施
### 建议
1. **明确需求优先级**:数据导出为隐含需求,建议在 NS4 spec 中明确标注为"后续迭代项"或"MVP 不含",避免歧义
2. **如需实现,建议分两步**
- **Step 1 — 通用导出基础设施**
- 后端新增 `apps/backend/app/utils/export_helper.py`,封装 openpyxl 生成 Excel 的通用逻辑列定义、数据填充、StreamingResponse 包装)
- 新建 `biz.export_log` 表记录导出操作who/when/what/row_count满足审计要求
- **Step 2 — 逐页面接入**
- 用户列表:`GET /api/tenant/users/export?format=xlsx`
- 审核记录:`GET /api/tenant/applications/export?status=&format=xlsx`
- 维客线索:`GET /api/tenant/customers/{member_id}/clues/export`
- 上传历史:`GET /api/tenant/excel/logs/export`
3. **权限控制**:导出操作应复用现有的 `require_tenant_admin()` 鉴权 + `site_id IN (管辖列表)` 数据隔离,确保导出数据不越权
4. **导出格式**:建议统一 `.xlsx`(已有 openpyxl 依赖),暂不支持 CSV避免中文编码问题
5. **NS4 spec 补充建议**:在 NS4 文档"三、功能详细设计"中新增 §3.5 数据导出,定义支持导出的页面清单、导出格式、权限规则、导出日志记录要求

View File

@@ -0,0 +1,85 @@
# P10-NS4-04操作日志/审计日志的查看界面
## 简要结论
- 状态:❌ 未解决
- 项目当前没有业务操作审计日志的数据库表、后端记录逻辑、以及前端查看界面。仅有 Excel 上传记录(`biz.excel_upload_log`)和 ETL 任务执行日志admin-web LogViewer均不属于业务操作审计日志范畴。
## 详细审查
### 数据库端
**查库方式**pg-app-test MCP 连接不可用,改用 DDL 迁移文件搜索。
| 检查项 | 结果 |
|--------|------|
| `audit_log` / `operation_log` / `activity_log` 表 | ❌ 不存在DDL 搜索无匹配) |
| `auth` schema 中日志相关表 | ❌ 不存在(仅有 `users``tenant_admins``user_roles``user_site_roles``user_applications``user_assistant_binding` |
| `biz` schema 中日志相关表 | ⚠️ 仅有 `biz.excel_upload_log`Excel 上传批次记录,非操作审计日志) |
| `task_execution_log` 表 | 存在于 `_archived/` 基线中,属于 ETL 任务执行日志,非业务操作审计 |
**结论**:数据库中不存在通用的业务操作审计日志表。`biz.excel_upload_log` 记录的是 Excel 上传批次状态pending/confirmed/failed不记录"谁做了什么操作"这类审计信息。
### 后端代码
| 检查项 | 结果 |
|--------|------|
| `apps/backend/app/routers/` 中审计日志路由 | ❌ 不存在(无 `audit`/`log`/`activity` 相关路由文件) |
| 审计中间件 / 日志记录装饰器 | ❌ 不存在(`app/middleware/` 仅有 `response_wrapper.py``permission.py` |
| `tenant_users.py` 审核操作日志 | ❌ 审核通过/拒绝仅更新状态字段(`user_applications.status``reviewed_by``reviewed_at`),不写入独立审计日志表 |
| `tenant_clues.py` 线索操作日志 | ❌ 编辑/删除/隐藏操作直接修改数据,无审计记录 |
| `tenant_excel.py` 上传操作日志 | ⚠️ 写入 `biz.excel_upload_log`,但仅记录批次元数据,不记录操作人的具体行为 |
**关键发现**
- 用户审核(通过/拒绝):`user_applications` 表有 `reviewed_by` + `reviewed_at` 字段,可追溯审核人和时间,但不是独立的审计日志
- 用户编辑(状态变更/绑定修改):无任何操作记录
- 维客线索修改/删除/隐藏:无任何操作记录,且删除为物理删除(`DELETE FROM`),数据不可恢复
- Excel 上传:`excel_upload_log` 记录了 `uploaded_by`,但无确认操作的审计
### 前端代码
| 检查项 | 结果 |
|--------|------|
| `apps/tenant-admin/src/pages/` 中日志查看页面 | ❌ 不存在(仅有 Login、UserApproval、UserManagement、ExcelUpload、RetentionClues 五个页面) |
| `apps/tenant-admin/src/App.tsx` 路由配置 | ❌ 无日志相关路由(路由:`/login``/applications``/users``/excel``/clues` |
| `apps/admin-web/src/pages/LogViewer.tsx` | ⚠️ 存在,但功能是 ETL 任务执行日志查看器(通过 WebSocket 接收执行日志、按任务分组展示),不是业务操作审计日志 |
| `apps/admin-web/src/App.tsx` 路由 `/log-viewer` | 指向 ETL LogViewer与业务操作审计无关 |
**结论**:两个前端应用均无业务操作审计日志查看界面。
### 差距分析
P10 标杆文件要求管理后台具备操作审计功能,即"谁在什么时间做了什么操作"的完整追溯能力。当前 NS4 的差距:
| 维度 | P10 要求 | NS4 现状 | 差距 |
|------|----------|----------|------|
| 审计数据存储 | 独立审计日志表 | ❌ 不存在 | 完全缺失 |
| 审计记录写入 | 关键操作自动记录 | ❌ 无中间件/装饰器 | 完全缺失 |
| 审计日志查看 | 管理后台可查看/筛选 | ❌ 无页面 | 完全缺失 |
| 操作可追溯性 | 所有关键操作可追溯 | ⚠️ 仅审核操作有 `reviewed_by`/`reviewed_at` | 大部分缺失 |
| 数据安全 | 删除操作可恢复/可审计 | ❌ 线索删除为物理删除 | 高风险 |
### 建议
#### 短期(高优先级)
1. **新建审计日志表** `biz.audit_log`
- 字段:`id``site_id``operator_id`(操作人)、`operator_type`tenant_admin/system`action`approve/reject/edit/delete/hide/upload 等)、`target_type`user/clue/excel 等)、`target_id``detail`JSONB变更前后值`ip_address``created_at`
- 索引:`(site_id, created_at DESC)``(operator_id)``(target_type, target_id)`
2. **后端审计中间件/工具函数**
-`tenant_users.py` 的审核通过/拒绝、用户编辑/禁用操作中写入审计日志
-`tenant_clues.py` 的编辑/删除/隐藏操作中写入审计日志
-`tenant_excel.py` 的上传确认操作中写入审计日志
- 建议实现为可复用的 `log_audit()` 工具函数,在事务内调用
3. **线索删除改为软删除**:当前 `DELETE FROM public.member_retention_clue` 为物理删除,建议改为 `SET is_deleted = true`,配合审计日志记录
#### 中期
4. **前端审计日志查看页面**tenant-admin
- 新增 `/audit-logs` 路由和 `AuditLogs` 页面
- 支持按时间范围、操作类型、操作人筛选
- 展示操作详情(变更前后对比)
5. **后端审计日志查询 API**
- `GET /api/tenant/audit-logs`(分页 + 筛选)

View File

@@ -0,0 +1,64 @@
# P10-NS4-05管理后台的响应式适配
## 简要结论
- 状态:⚠️ 部分解决
- tenant-admin 依赖 Ant Design 内置响应式能力实现了基础适配Sider 可折叠、flex 布局),但未定义最小支持分辨率、断点规则,也未对表格横向滚动做统一处理。
## 详细审查
### 前端代码
#### 1. 全局布局App.tsx
- 使用 Ant Design `<Layout>` + `<Sider collapsible>`,侧边栏支持折叠/展开,这是管理后台最基本的响应式能力 ✅
- `minHeight: "100vh"` 保证全屏高度 ✅
- Content 区域 `margin: 16, minHeight: 280`,使用固定像素值,无断点适配 ⚠️
#### 2. 页面级响应式
- **Login 页面**:居中卡片 `width: 400` 固定宽度,小屏幕下可能溢出 ⚠️
- **ExcelUpload 页面**:使用了 `<Row gutter={16}>` + `<Col span={8}>` 展示统计卡片,这是 Ant Design Grid 的响应式用法,但 `span` 为固定值(未使用 `xs/sm/md/lg` 响应式断点属性)⚠️
- **UserApproval / UserManagement 页面**:筛选栏使用 `display: "flex"` + `flexWrap: "wrap"`UserManagement基本适配 ✅;但 UserApproval 筛选栏未设 `flexWrap` ⚠️
- **RetentionClues 页面**:使用 `<Space direction="vertical">` 布局,宽度 `100%`,基本适配 ✅
#### 3. 表格适配
- 所有页面的 `<Table>` 组件均未设置 `scroll={{ x: ... }}`,在窄屏下列内容可能被压缩变形 ❌
- 对比 admin-web 的 DBViewer 页面已使用 `scroll={{ x: 'max-content' }}`tenant-admin 未跟进
- RetentionClues 的线索表格有多列固定宽度(共约 700px加上其他列在 1280px 屏幕下可能紧凑但尚可
- ExcelUpload 的校验详情表格"数据"列和"问题详情"列无固定宽度,内容可能很长
#### 4. CSS / 样式文件
- 项目中无独立 CSS/Less/SCSS 文件,所有样式通过 inline style 实现
-`@media` 查询、无自定义断点定义、无全局响应式样式
- 仅 SiteSelector 组件使用了 `maxTagCount="responsive"`Ant Design Select 内置响应式标签数量)✅
#### 5. viewport 配置
- `index.html` 包含标准 viewport meta`<meta name="viewport" content="width=device-width, initial-scale=1.0" />`
#### 6. 依赖检查
- 无额外响应式库(如 react-responsive、@ant-design/pro-layout 等)
- 未使用 Ant Design 的 `Grid.useBreakpoint()` hook
#### 7. admin-web 对比
- admin-web 同样未做系统性响应式适配,但 DBViewer 页面的表格使用了 `scroll={{ x: 'max-content' }}`
- 两个管理后台的响应式水平基本一致:依赖 Ant Design 默认行为,无主动断点适配
### 差距分析
| 标杆要求P10 §响应式) | 当前状态 | 差距 |
|---|---|---|
| 最小支持分辨率定义 | P10 spec 和 NS4 均未提及 | ❌ 未定义 |
| 断点规则1280/1440/1920 | 无自定义断点 | ❌ 未定义 |
| 移动端适配策略 | 无策略声明 | ⚠️ 管理后台面向 PC可接受不支持移动端但需明确声明 |
| Sider 可折叠 | `collapsible` 已启用 | ✅ 已实现 |
| 表格横向滚动 | 未设置 `scroll.x` | ❌ 未实现 |
| Grid 响应式断点 | ExcelUpload 用了 Row/Col 但无响应式断点 | ⚠️ 部分 |
> P10 原始 spec`docs/prd/specs/P10-tenant-admin-web.md`)中实际不包含"§响应式"章节,该要求来自标杆文件对管理后台的通用期望。
### 建议
1. **明确最小分辨率**:在 NS4 或项目前端规范中声明最小支持分辨率为 1280×720覆盖主流笔记本不支持移动端访问
2. **表格横向滚动**:为所有 `<Table>` 添加 `scroll={{ x: 'max-content' }}` 或计算列宽总和,防止窄屏下列压缩
3. **Login 卡片宽度**:将 `width: 400` 改为 `maxWidth: 400, width: '100%'`,避免小屏溢出
4. **ExcelUpload 统计卡片**`<Col span={8}>` 改为 `<Col xs={24} sm={12} lg={8}>`,适配不同屏幕
5. **UserApproval 筛选栏**:补充 `flexWrap: "wrap"`,与 UserManagement 保持一致
6. **优先级评估**:以上均为低风险改进项。管理后台面向 PC 端使用,当前 Ant Design 默认行为已提供基本可用性,建议在后续迭代中逐步完善,不阻塞当前交付

View File

@@ -0,0 +1,137 @@
# P10-NS4-06表格组件的统一规范
## 简要结论
- 状态:⚠️ 部分解决
- tenant-admin 各页面表格在分页大小、loading 状态、筛选器位置上已形成事实一致的模式但缺少显式的统一配置抽象层排序交互完全缺失空数据展示未统一部分表格分页大小不一致20 vs 10
## 详细审查
### 前端代码
#### 1. 分页大小
| 页面/组件 | 默认 pageSize | showSizeChanger | showTotal | 后端分页 |
|-----------|:---:|:---:|:---:|:---:|
| UserApproval | 20 | ✅ | ✅ `共 N 条` | ✅ page/page_size |
| UserManagement | 20 | ✅ | ✅ `共 N 条` | ✅ page/page_size |
| ExcelUpload — 上传记录 | 20 | ✅ | ✅ `共 N 条` | ✅ page/page_size |
| ExcelUpload — 校验详情 | **10** | ❌ | ❌ | 前端分页 |
| RetentionClues — 客户搜索 | `false` | — | — | 无分页LIMIT 50 |
| RetentionClues — 线索列表 | **10** | ❌ | ❌ | 无分页(全量返回) |
| DiffTable — 冲突表 | `false` | — | — | 前端数据 |
结论:主列表页统一为 20 条/页 + showSizeChanger + showTotal但子表格校验详情、线索列表使用 10 条/页且缺少 showSizeChanger 和 showTotal**不一致**。
#### 2. 排序交互
所有 5 个表格组件均未配置 `sorter` 属性,前端不支持列排序。后端 SQL 统一使用 `ORDER BY created_at DESC`(固定倒序),无动态排序参数。
结论:**完全缺失**。用户无法按任意列排序。
#### 3. 筛选器位置
| 页面 | 筛选器位置 | 筛选方式 |
|------|-----------|---------|
| UserApproval | 表格上方独立区域 | Select状态筛选 |
| UserManagement | 表格上方独立区域 | Select角色+ Input.Search关键词 |
| ExcelUpload — 上传记录 | 无筛选 | — |
| RetentionClues | Card title 区域 + Card extra 区域 | Input.Search + SiteSelector上方Select×2Card extra 右侧) |
结论:筛选器统一放在表格上方(非表格内 column filter**位置一致**。但 RetentionClues 的线索筛选器放在 Card extra 右侧,与其他页面的左对齐布局略有差异。
#### 4. loading 状态
所有带后端请求的表格均通过 `loading={loading}` 传入 Ant Design Table 的 loading 属性,**统一且正确**。
#### 5. 空数据展示
| 页面 | 空数据处理 |
|------|-----------|
| UserApproval | Ant Design Table 默认空状态(无自定义) |
| UserManagement | Ant Design Table 默认空状态(无自定义) |
| ExcelUpload | Ant Design Table 默认空状态(无自定义) |
| RetentionClues — 客户搜索 | `<Empty description="未找到匹配客户" />` ✅ 自定义 |
| RetentionClues — 线索列表 | Ant Design Table 默认空状态(无自定义) |
| DiffTable | Ant Design Table 默认空状态(无自定义) |
结论:仅 RetentionClues 客户搜索有自定义空状态文案,其余均依赖 Ant Design 默认的英文 "No Data"。**未统一**,且未做中文化。
对比 admin-web`TaskManager` 页面使用了 `locale={{ emptyText: <Empty description="队列为空" /> }}` 自定义空状态tenant-admin 未跟进。
#### 6. 统一配置抽象
- tenant-admin 中**不存在**统一的表格配置文件、常量定义或封装组件
- 每个页面独立定义 `pageSize``pagination` 配置、`handleTableChange`
- 分页响应结构 `{ items, total, page, pageSize }` 在前后端已统一,但属于隐式约定
### 后端代码
#### 分页参数统一性
| 路由 | 参数名 | 默认值 | 范围约束 |
|------|--------|--------|---------|
| `GET /applications` | `page` + `page_size` | 1 / 20 | ge=1 / ge=1,le=100 |
| `GET /users` | `page` + `page_size` | 1 / 20 | ge=1 / ge=1,le=100 |
| `GET /excel/logs` | `page` + `page_size` | 1 / 20 | ge=1 / ge=1,le=100 |
| `GET /customers/search` | 无分页 | — | LIMIT 50 硬编码 |
| `GET /customers/{id}/clues` | 无分页 | — | 全量返回 |
结论:三个主列表接口的分页参数**完全统一**`page`/`page_size`,默认 20上限 100。客户搜索和线索列表不分页属于业务设计选择数据量可控但线索列表在数据量增长后可能需要补充分页。
响应格式统一为 `{ items: T[], total: int, page: int, pageSize: int }`**一致**。
### 差距分析
与 P10 标杆文件要求的差距:
| 规范项 | P10 要求(推断) | 当前状态 | 差距 |
|--------|-----------------|---------|------|
| 默认分页大小 | 统一值(如 20 | 主表 20子表 10 | 🟡 子表不一致 |
| showSizeChanger | 所有分页表格启用 | 仅主表启用 | 🟡 子表缺失 |
| showTotal | 所有分页表格显示 | 仅主表显示 | 🟡 子表缺失 |
| 排序交互 | 至少关键列支持排序 | 完全缺失 | 🔴 缺失 |
| 筛选器位置 | 统一在表格上方 | 基本一致 | ✅ |
| loading 状态 | 统一 loading 属性 | 全部已配置 | ✅ |
| 空数据展示 | 统一中文空状态 | 仅 1 处自定义,其余默认 | 🟡 未统一 |
| 统一配置抽象 | 提取公共 Table 配置 | 不存在 | 🔴 缺失 |
### 建议
1. **提取统一表格配置常量**(优先级:高)
`apps/tenant-admin/src/constants/table.ts` 中定义:
```ts
export const DEFAULT_PAGE_SIZE = 20;
export const PAGE_SIZE_OPTIONS = ['10', '20', '50'];
export const TABLE_LOCALE = {
emptyText: '暂无数据',
};
export const defaultPagination = (total: number, page: number, pageSize: number) => ({
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
pageSizeOptions: PAGE_SIZE_OPTIONS,
});
```
2. **统一空数据展示**(优先级:高)
通过 Ant Design ConfigProvider 全局设置中文 locale 和空状态:
```tsx
import zhCN from 'antd/locale/zh_CN';
<ConfigProvider locale={zhCN} renderEmpty={() => <Empty description="暂无数据" />}>
```
3. **子表格分页对齐**(优先级:中)
ExcelUpload 校验详情和 RetentionClues 线索列表的 pageSize 改为 20并补充 showSizeChanger 和 showTotal。
4. **排序交互**(优先级:低)
对时间列(申请时间、上传时间、记录时间)添加前端 `sorter` 支持。如数据量增长需后端排序,可在 API 中增加 `sort_by` + `sort_order` 参数。当前数据量下前端排序即可。
5. **线索列表补充分页**(优先级:低)
`GET /customers/{member_id}/clues` 当前全量返回,建议在数据量可能超过 50 条时补充后端分页。

View File

@@ -0,0 +1,150 @@
# P10-NS4-07表单验证的统一规范
## 简要结论
- 状态:⚠️ 部分解决
- 各表单已实现基本验证逻辑且风格较一致,但缺少文档化的统一规范,部分表单必填标识缺失,后端 Pydantic 422 错误未映射到前端字段级提示。
## 详细审查
### 前端代码
#### 1. 必填标识(`required: true`
| 表单 | 文件 | 必填字段 | 是否配置 `required` | 备注 |
|------|------|----------|---------------------|------|
| 登录 | `Login/index.tsx` | username, password | ✅ 均有 | — |
| 审核通过 | `UserApproval/index.tsx` (ReviewModal) | role | ✅ 有 | `suggestionIndex` 为可选,正确 |
| 审核拒绝 | `UserApproval/index.tsx` (ReviewModal) | reason | ✅ 有 | — |
| 用户编辑 | `UserManagement/index.tsx` (EditModal) | role, siteId | ❌ 均无 `required` | 角色和门店字段未标记必填,用户可提交空值 |
| 用户绑定 | `UserManagement/index.tsx` (BindModal) | assistantId, staffId | ✅ 正确无 `required` | 两个字段均为可选,符合业务逻辑 |
| 线索编辑 | `ClueEditor/index.tsx` | category, summary | ✅ 均有 | `detail` 为可选,正确 |
问题:`EditModal``role``siteId` 字段没有 `required` 规则。虽然后端 `UserEditRequest` 允许 `None`(部分更新语义),但前端 UI 上没有明确告知用户哪些字段是建议填写的。
#### 2. 错误提示文案
所有已配置 `rules` 的表单字段,`message` 均为中文:
- `"请输入用户名"` / `"请输入密码"` — Login
- `"请选择角色"` — UserApproval (approve)
- `"请填写拒绝原因"` — UserApproval (reject)
- `"请选择大类标签"` / `"请输入摘要"` / `"摘要不能超过 200 字符"` — ClueEditor
✅ 文案风格统一,均为 `"请 + 动词 + 名词"` 格式。
#### 3. 验证触发时机
| 表单 | 触发方式 | 说明 |
|------|----------|------|
| Login | `onFinish`(提交验证) | Form 的 `onFinish` 回调Ant Design 默认在提交时触发全量校验 |
| ReviewModal (approve/reject) | `form.validateFields()`(手动提交验证) | Modal `onOk` 中手动调用 |
| EditModal | `form.validateFields()`(手动提交验证) | Modal `onOk` 中手动调用 |
| BindModal | `form.validateFields()`(手动提交验证) | Modal `onOk` 中手动调用 |
| ClueEditor | `form.validateFields()`(手动提交验证) | Modal `onOk` 中手动调用 |
✅ 统一采用提交时验证(`onFinish``validateFields`),未使用实时验证(`onChange`)。风格一致。
注意:所有 Modal 表单均未显式设置 `validateTrigger`Ant Design 默认值为 `onChange`,即用户修改字段后会实时显示/清除错误。这实际上是"提交触发首次校验 + 后续实时反馈"的混合模式,属于 Ant Design 最佳实践。
#### 4. 自定义验证器
`ClueEditor` 使用了 `max: 200` 长度限制规则,其余表单无自定义 `validator`
ExcelUpload 页面不使用 Ant Design Form 进行表单验证,而是:
- 文件类型限制通过 `Upload` 组件的 `accept=".xlsx,.xls"` 属性
- 空文件检查通过 `fileList.length === 0` 手动判断
- 数据校验完全由后端完成,前端展示后端返回的校验结果
这种设计合理——Excel 上传的校验逻辑复杂(表头格式、金额精度、人员匹配等),适合后端统一处理。
#### 5. 表单布局
所有 Modal 内表单统一使用 `layout="vertical"`Login 页面使用默认水平布局。风格基本一致。
### 后端代码
#### 1. Pydantic Schema 验证
| Schema | 文件 | 验证规则 | 备注 |
|--------|------|----------|------|
| `ApproveRequest` | `tenant_users.py` | `role: str = Field(..., min_length=1)` | 必填 + 非空 |
| `RejectRequest` | `tenant_users.py` | `reason: str = Field(..., min_length=1)` | 必填 + 非空 |
| `UserEditRequest` | `tenant_users.py` | 所有字段 `Optional` | 部分更新语义,合理 |
| `UserBindingRequest` | `tenant_users.py` | 所有字段 `Optional` | 合理 |
| `ClueEditRequest` | `tenant_clues.py` | `category: ClueCategory`(枚举), `summary: str = Field(..., min_length=1, max_length=200)` | 枚举校验 + 长度限制 |
| `ClueVisibilityRequest` | `tenant_clues.py` | `is_hidden: bool = Field(...)` | 必填 |
| `ConfirmRequest` | `tenant_excel.py` | `upload_id: int`, `resolutions: list[Resolution]` | 结构化验证 |
✅ 后端 Pydantic 验证规则与前端 rules 基本对齐。
#### 2. 手动验证(路由层)
Excel 上传路由 (`tenant_excel.py`) 包含额外的手动验证:
- `upload_type` 枚举校验
- 文件扩展名校验(`.xlsx/.xls`
- 文件内容非空校验
- Excel 解析成功校验
- 数据行非空校验
客户搜索路由 (`tenant_clues.py`) 使用 `Query(..., min_length=1)` 确保关键词非空。
#### 3. 错误响应格式
后端统一错误响应格式:`{ "code": <status_code>, "message": <detail> }`
- `http_exception_handler`HTTPException → `{ code, message }`
- `unhandled_exception_handler`:未捕获异常 → `{ code: 500, message: "Internal Server Error" }`
- ❌ 未注册 `RequestValidationError` 处理器 — Pydantic 验证失败时FastAPI 默认返回 422 + `{ "detail": [{ "loc": [...], "msg": "...", "type": "..." }] }` 格式,与统一的 `{ code, message }` 格式不一致
#### 4. 错误提示文案
后端 HTTPException 的 `detail` 均为中文:
- `"申请不存在"` / `"该申请已被处理"` / `"无效的球房编号"` / `"审核操作失败"`
- `"用户不存在"` / `"目标门店不在管辖范围内"` / `"编辑操作失败"`
- `"线索不存在"` / `"编辑操作失败"` / `"删除操作失败"`
- `"请上传有效的 Excel 文件(.xlsx/.xls"` / `"文件内容为空"`
✅ 中文化程度高,风格统一。
### 差距分析
P10 标杆文件(`P10-tenant-admin-web.md`)中没有独立的"表单规范"章节,但在验收标准和设计要点中隐含了以下要求:
- AC4Excel 上传校验(必填、金额精度、表头格式、类型合法)— ✅ 已实现
- 4 种模板的必填列定义 — ✅ 后端已实现校验
- 冲突处理流程 — ✅ 已实现
与通用表单规范最佳实践的差距:
| 维度 | 期望 | 现状 | 差距 |
|------|------|------|------|
| 必填标识 | 所有必填字段有 `required` 规则 + 红色星号 | 大部分有EditModal 缺失 | 🟡 小 |
| 错误提示位置 | 统一在字段下方 | Ant Design Form.Item 默认行为,一致 | ✅ 无 |
| 提示文案 | 中文,格式统一 | `"请 + 动词 + 名词"` 格式统一 | ✅ 无 |
| 验证时机 | 明确约定实时/提交 | 统一提交验证 + Ant Design 默认 onChange 反馈 | ✅ 无 |
| 后端 422 映射 | Pydantic 验证错误映射到前端字段 | 未注册 RequestValidationError 处理器 | 🟡 小 |
| 文档化规范 | 有明确的表单验证规范文档 | 无 | 🟡 小 |
| 前后端验证对齐 | 前端 rules 与后端 schema 一一对应 | 基本对齐,个别字段前端缺 rules | 🟡 小 |
### 建议
1. **EditModal 补充必填规则**(低优先级):`role` 字段建议加 `rules={[{ required: true, message: "请选择角色" }]}``siteId` 视业务需求决定是否必填。
2. **注册 RequestValidationError 处理器**(低优先级):在 `apps/backend/app/main.py` 中添加:
```python
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(
status_code=422,
content={"code": 422, "message": "请求参数校验失败", "errors": exc.errors()},
)
```
使 Pydantic 验证错误也遵循统一的 `{ code, message }` 响应格式。
3. **文档化表单规范**(可选):如需更高规范性,可在 NS4 或项目级文档中补充一节"表单验证约定",明确:
- 必填字段必须配置 `required: true`Ant Design 自动显示红色星号)
- 错误提示文案格式:`"请 + 动词 + 名词"`
- 验证时机:提交时触发(`validateFields` / `onFinish`),依赖 Ant Design 默认 `onChange` 实时反馈
- 后端验证错误通过 `message.error()` 全局提示,不映射到具体字段
当前项目规模5 个页面、6 个表单)下,现有的隐式一致性已足够,文档化为锦上添花。

View File

@@ -0,0 +1,73 @@
# P10-NS4-08管理后台的国际化预留
## 简要结论
- 状态:✅ 已解决
- Ant Design 组件已配置 `zhCN` locale项目无国际化框架需求中文硬编码符合项目规范当前方案合理。
## 详细审查
### 前端代码
#### 1. Ant Design ConfigProvider locale 配置
`apps/tenant-admin/src/main.tsx` 已在根节点配置 Ant Design 中文 locale
```tsx
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
```
这确保了 Table 空状态文案、DatePicker 日期选择器、Pagination 分页器、Modal 确认/取消按钮等所有 Ant Design 内置组件均显示中文。
`apps/admin-web/src/main.tsx` 采用完全相同的配置方式,两个管理后台保持一致。
#### 2. i18n 框架依赖
`apps/tenant-admin/package.json``apps/admin-web/package.json` 均未引入任何 i18n 框架(如 `react-intl``i18next``react-i18next` 等)。
全局搜索 `i18n``intl``useIntl``formatMessage``i18next``react-intl` 关键词,两个项目中均无 i18n 框架使用痕迹。`locale` 关键词仅出现在 Ant Design ConfigProvider 配置和 `toLocaleString()` 日期格式化调用中。
#### 3. 中文硬编码情况
tenant-admin 所有页面中的 UI 文案均为中文硬编码,包括:
- 导航菜单:`"用户审核"``"用户管理"``"Excel 上传"``"维客线索管理"`
- 表格列标题:`"昵称"``"手机号"``"状态"`
- 操作按钮:`"审核"``"编辑"``"绑定"``"删除"``"登录"`
- 表单标签:`"请输入用户名"``"请输入密码"``"请选择角色"`
- 提示消息:`"审核通过成功"``"用户名或密码错误"``"账号已被禁用"`
- 状态标签:`"待审核"``"已通过"``"已拒绝"`
admin-web 同样采用中文硬编码方式,两个项目风格一致。
#### 4. 与 admin-web 的对比
| 维度 | tenant-admin | admin-web |
|------|-------------|-----------|
| ConfigProvider zhCN | ✅ 已配置 | ✅ 已配置 |
| i18n 框架 | 无 | 无 |
| UI 文案方式 | 中文硬编码 | 中文硬编码 |
| 日期格式化 | dayjs 依赖已安装 | `toLocaleString('zh-CN')` |
两个管理后台在国际化处理上完全一致。
### 差距分析
P10 标杆文件(`docs/prd/specs/P10-tenant-admin-web.md`)中没有独立的"国际化"章节,也未提出任何多语言支持要求。标杆文件明确定义的用户群体是"租户管理员",即国内台球门店的管理人员。
结合项目 steering 规则:
- `language-zh.md`:说明性文字一律简体中文
- `project-overview.md`:领域语言中文,货币 CNY
项目定位为面向国内台球门店的垂直业务系统,目标用户群体单一(国内门店管理员),不存在多语言需求场景。
### 建议
当前方案已满足项目需求,无需额外改动:
1. Ant Design `ConfigProvider locale={zhCN}` 已正确配置,组件内置文案为中文 — **已完成**
2. 无需引入 i18n 框架 — 项目面向国内市场,中文硬编码是合理选择,引入 i18n 框架反而增加不必要的复杂度
3. 如未来确有国际化需求(概率极低),可后续引入 `react-intl``i18next`,将硬编码文案提取为 message key改动范围可控

View File

@@ -0,0 +1,92 @@
# P10-NS4-09管理后台的主题定制
## 简要结论
- 状态:❌ 未解决
- 两个管理后台tenant-admin、admin-web均未实现主题定制使用 Ant Design v5 默认主题,无品牌色配置、无 Logo 组件、无暗色模式支持。
## 详细审查
### 前端代码
#### 1. ConfigProvider theme 配置
**tenant-admin `main.tsx`**`ConfigProvider` 仅配置了 `locale={zhCN}`,未传入 `theme` prop。
```tsx
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
```
**admin-web `main.tsx`**:同样仅配置 `locale={zhCN}`,无 `theme` prop。
两个后台均未使用 Ant Design v5 的 theme token 系统(`ConfigProvider theme={{ token: { colorPrimary: '...' } }}`)。
#### 2. 品牌色primaryColor
- 全局搜索 `colorPrimary``primaryColor``theme` 关键词:两个后台均无自定义品牌色配置
- 所有组件使用 Ant Design 默认蓝色(`#1677ff`
- 侧边栏 `Menu` 使用 `theme="dark"` 属性Ant Design 内置暗色菜单,非自定义主题)
#### 3. Logo 展示
- **tenant-admin**:侧边栏顶部为纯文字 `"租户管理后台"`(内联样式,无图片 Logo
- **admin-web**:侧边栏顶部为纯文字 `"NeoZQYY"`(内联样式,无图片 Logo
- 登录页tenant-admin 使用 `Card title="租户管理后台"` 纯文字标题,无 Logo 图片
- 两个项目 `src/` 目录下均无图片资源文件(`.png``.svg``.jpg``.ico`
#### 4. 暗色模式
- 全局搜索 `darkMode``darkAlgorithm``defaultAlgorithm`:无结果
- 无暗色模式切换开关或相关状态管理
- 未使用 Ant Design v5 的 `theme.darkAlgorithm`
#### 5. Ant Design 版本
- 两个后台均使用 `antd@^5.24.7`,完全支持 CSS-in-JS 主题系统
- 技术上具备实现主题定制的能力,但未使用
#### 6. Vite 配置
- `vite.config.ts` 无 Less 变量覆盖或 CSS 预处理器主题配置
- 仅配置了路径别名、开发服务器端口和 API 代理
#### 7. 两个后台的视觉一致性
| 维度 | tenant-admin | admin-web | 一致性 |
|------|-------------|-----------|--------|
| 品牌色 | Ant Design 默认蓝 | Ant Design 默认蓝 | ✅ 一致(均为默认值) |
| 侧边栏 | `Sider collapsible` + `Menu theme="dark"` | 同左 | ✅ 一致 |
| 顶部标题 | 纯文字"租户管理后台" | 纯文字"NeoZQYY" | ⚠️ 文案不同,无统一品牌标识 |
| Logo | 无 | 无 | ✅ 一致(均无) |
| 暗色模式 | 无 | 无 | ✅ 一致(均无) |
| 底部状态栏 | 无 | 有(任务执行状态) | ❌ 不一致 |
### 差距分析
P10 标杆文件(`docs/prd/specs/P10-tenant-admin-web.md`)本身未包含"§主题"章节,该缺失项来源于 review-report 的通用管理后台最佳实践差距分析。具体差距:
1. **品牌色**:未定义,依赖 Ant Design 默认值。如果未来需要品牌识别或多租户白标,缺少基础设施。
2. **Logo**:两个后台均无图形 Logo仅用内联文字。侧边栏和登录页缺少品牌视觉锚点。
3. **暗色模式**完全未实现。Ant Design v5 原生支持 `theme.darkAlgorithm`,实现成本低,但当前未启用。
4. **主题统一管理**:无共享的主题配置文件或常量,两个后台各自独立,未来维护品牌一致性成本较高。
### 建议
鉴于此项为 🟡 低风险,且 P10 spec 本身未要求主题定制,建议按优先级分阶段处理:
1. **短期(推荐)**:在两个后台的 `ConfigProvider` 中添加统一的 `theme.token.colorPrimary`,建立品牌色基础。改动量极小(约 5 行代码/项目),但能统一视觉标识。
```tsx
// 示例main.tsx
<ConfigProvider
locale={zhCN}
theme={{ token: { colorPrimary: '#品牌色' } }}
>
```
2. **中期(可选)**:提取共享主题配置到 `packages/shared/` 或独立的 `theme.ts`,确保两个后台品牌一致性。添加 Logo 图片资源到侧边栏和登录页。
3. **长期(按需)**:如有暗色模式需求,利用 Ant Design v5 的 `darkAlgorithm` 实现切换。如有多租户白标需求,建立主题配置表。
4. **决策建议**:建议产品侧明确是否需要主题定制能力。如果当前阶段仅为内部管理工具,默认主题可接受;如果面向多租户交付,则品牌色和 Logo 应尽早落地。

View File

@@ -0,0 +1,63 @@
# P5.1→NS3 缺失项 #1App1 Prompt 工程规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- App1 的完整 Prompt 工程规范已在百炼平台 System Prompt 文档和后端代码中实现NS3 作为 MCP Server 扩展 spec 不涉及 Prompt 细节属于正常分工。
## 详细审查
### 审查范围
- `apps/backend/app/ai/apps/app1_chat.py` — App1 后端实现
- `docs/prd/ai-app-prompts.md` — 百炼平台 8 个应用的 System Prompt 完整定义
- `docs/prd/specs/P5-miniapp-ai-integration.md` — P5 原始 spec首条 Prompt 数据结构)
- `docs/prd/Neo_Specs/NS3-mcp-server-ai-extension.md` — NS3 spec
### 发现
1. **System Prompt 模板**`docs/prd/ai-app-prompts.md` 中定义了 App1 完整的 System Prompt包含
- 角色定义(台球门店运营助手)
- 5 个技能(数据查询、客户信息、助教业绩、经营数据、库存)
- 权限控制规则(助教/管理者角色隔离)
- 查询规范和回复规范
- `biz_params.user_prompt_params` 参数注入User_ID、Role、Nickname
2. **首条 Prompt JSON 结构**P5 spec 中明确定义了 App1 的首条用户消息结构:
```json
{
"current_time": "...",
"source_page": "来源页面标识",
"page_context": "页面上下文摘要",
"screen_content": "屏幕可见内容文本化"
}
```
3. **后端实现**`app1_chat.py` 已实现:
- `_build_system_prompt()` 构建 system prompt JSON注入用户信息 + 页面上下文)
- `_build_page_context()` 调用 `build_page_text()` 获取 10 种页面入口的结构化文本
- Token 预算控制(`_MAX_SYSTEM_PROMPT_LEN = 4000`
- SSE 流式返回完整链路
4. **NS3 的定位**NS3 是 MCP Server 扩展 spec职责是数据库连接、查库手册、脱敏策略不涉及 AI 应用的 Prompt 工程。Prompt 规范由 P5 spec + `ai-app-prompts.md` 承载,这是正确的分工。
### 证据
`app1_chat.py` 中的 system prompt 构建:
```python
prompt: dict = {
"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。",
"biz_params": {
"user_prompt_params": {
"User_ID": str(user_id),
"Role": role,
"Nickname": nickname,
},
},
}
```
`ai-app-prompts.md` 中 App1 的 System Prompt 长度约 1500 字,覆盖 5 个技能、权限控制、查询规范、回复规范。
### 说明
App1 是通用对话应用,其 Prompt 工程规范不包含 few-shot 示例和 JSON schema 约束(这些是结构化输出应用 2-8 的需求。App1 使用流式文本返回(`chat_stream`),不需要 JSON schema 约束。百炼平台的 System Prompt 配置 + 后端 `_build_system_prompt()` 的动态注入,构成了完整的 Prompt 工程规范。

View File

@@ -0,0 +1,75 @@
# P5.1→NS3 缺失项 #2App2 财务指标计算口径
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- App2 财务洞察的 6 个收入结构字段、items_sum 口径、环比基准均已在 Prompt 模板和后端代码中完整定义。
## 详细审查
### 审查范围
- `apps/backend/app/ai/apps/app2_finance.py` — App2 后端实现
- `apps/backend/app/ai/prompts/app2_finance_prompt.py` — App2 Prompt 模板
- `docs/prd/ai-app-prompts.md` — App2 System Prompt
- `docs/prd/specs/P5-miniapp-ai-integration.md` — P5 spec 中 App2 首条 Prompt 数据结构
- `apps/backend/app/ai/schemas.py` — App2Result 模型定义
### 发现
1. **收入结构字段映射6 个指标)**`app2_finance_prompt.py``_build_system_content()` 中明确定义了 `field_mapping`
- `items_sum` = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money
- `table_fee` = table_charge_money台费收入
- `assistant_pd` = assistant_pd_money陪打费
- `assistant_cx` = assistant_cx_money超休费
- `goods` = goods_money商品收入
- `recharge` = 充值 pay_amountsettle_type=5充值收入
- `electricity` = electricity_money电费当前未启用全为 0
2. **items_sum 口径规则**Prompt 模板中 `rules` 数组明确声明:
- "统一使用 items_sum 口径计算营收总额"
- "助教费用必须拆分为 assistant_pd_money陪打和 assistant_cx_money超休"
- "支付渠道恒等式balance_amount = recharge_card_amount + gift_card_amount"
- "金额单位CNY保留两位小数"
3. **环比基准**`_build_period_data()` 构建当期和上期数据结构,包含完整的收入结构、储值资产、费用汇总、平台结算字段。`app2_finance.py``compute_time_range()` 实现了 8 个时间维度的日期范围计算,为环比分析提供基准。
4. **System Prompt**`ai-app-prompts.md` 中 App2 的 System Prompt 定义了 3 个技能:
- 技能 1财务趋势分析历史数据环比增减幅度
- 技能 2经营预警与建议含当前周期
- 技能 3多维度深度分析客单价、支付方式、时段分析
5. **输出 JSON schema**System Prompt 中强制要求返回 `[{seq, title, content}]` 格式,`schemas.py` 中定义了 `App2InsightItem(seq, title, body)``App2Result(insights)` Pydantic 模型。
### 证据
`app2_finance_prompt.py` 中的字段映射:
```python
"field_mapping": {
"items_sum": (
"table_charge_money + goods_money + assistant_pd_money"
" + assistant_cx_money + electricity_money"
),
"table_fee": "table_charge_money台费收入",
"assistant_pd": "assistant_pd_money陪打费",
"assistant_cx": "assistant_cx_money超休费",
"goods": "goods_money商品收入",
"recharge": "充值 pay_amountsettle_type=5充值收入",
"electricity": "electricity_money电费当前未启用全为 0",
},
```
`_build_period_data()` 中的完整数据结构16 个字段):
```python
return {
"table_charge_money": data.get("table_charge_money", 0),
"goods_money": data.get("goods_money", 0),
"assistant_pd_money": data.get("assistant_pd_money", 0),
"assistant_cx_money": data.get("assistant_cx_money", 0),
"electricity_money": data.get("electricity_money", 0),
"recharge_income": data.get("recharge_income", 0),
# ... 储值资产、费用汇总、平台结算、汇总
}
```
P5 spec 中 App2 收入结构字段映射(已校准):
> `table_fee` = `table_charge_money`56.6%)、`assistant_pd` = `assistant_pd_money`30.6%)、`assistant_cx` = `assistant_cx_money`0.9%)、`goods` = `goods_money`10.1%)、`recharge` = 充值 pay_amountsettle_type=5

View File

@@ -0,0 +1,80 @@
# P5.1→NS3 缺失项 #3App3 维客线索生成的触发条件和频率
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- App3 的触发机制已通过事件驱动架构完整实现:消费结算事件触发 → dispatcher 编排调用链 → App3 执行。触发条件和频率在 P5 spec 和代码中均有明确定义。
## 详细审查
### 审查范围
- `apps/backend/app/ai/dispatcher.py` — AI 事件调度与调用链编排器
- `apps/backend/app/ai/apps/app3_clue.py` — App3 实现
- `apps/backend/app/services/trigger_scheduler.py` — 触发器调度框架
- `apps/backend/app/main.py` — AI 事件处理器注册
- `docs/prd/specs/P5-miniapp-ai-integration.md` — P5 spec 触发条件定义
### 发现
1. **触发条件**P5 spec AC3 明确定义"应用 3 维客线索在客户新增消费时自动更新"。代码实现完全匹配:
- `dispatcher.py``handle_consumption_event()` 方法在消费结算事件发生时触发 App3
- 事件名称:`ai_consumption_settled`
- 触发参数:`member_id`, `site_id`, `settle_id`, `assistant_id`(可选)
2. **触发频率**:事件驱动(非定时),每次客户新增消费(结账单)时触发一次。这是 P5 spec 设计的意图——"客户新增消费时自动更新"。
3. **事件注册链路**
- `main.py` lifespan 中创建 `AIDispatcher` 并调用 `register_ai_handlers()`
- `register_ai_handlers()` 将 3 个事件处理器注册到 `trigger_scheduler`
- `ai_consumption_settled``handle_consumption_settled`
- `ai_note_created``handle_note_created`
- `ai_task_assigned``handle_task_assigned`
- 业务代码通过 `fire_event("ai_consumption_settled", payload)` 触发
4. **调用链编排**:消费事件触发后的完整链路:
```
消费事件 → App3线索分析→ App8线索整理→ App7客户分析
如有助教参与 → App4关系分析→ App5话术参考
```
5. **容错策略**`dispatcher.py` 文档字符串明确说明:
- "某步失败记录错误日志,后续应用使用已有缓存继续"
- "失败应用写入失败 conversation 记录"
- "整条链后台异步执行,不阻塞业务请求"
### 证据
`dispatcher.py` 中的消费事件处理:
```python
async def handle_consumption_event(
self,
member_id: int,
site_id: int,
settle_id: int,
assistant_id: int | None = None,
) -> None:
"""消费事件链App3 → App8 → App7+ App4 → App5 如有助教)。"""
# 步骤 1App3 线索分析
app3_result = await self._run_step("app3_clue", app3_run, context)
# 步骤 2App8 线索整理
# 步骤 3App7 客户分析
# 步骤 4可选App4 → App5
```
`main.py` 中的 AI 事件处理器注册:
```python
_dispatcher = AIDispatcher(_bailian, AICacheService(), ConversationService())
register_ai_handlers(_dispatcher)
```
`_create_ai_event_handlers()` 中的事件映射:
```python
return {
"ai_consumption_settled": handle_consumption_settled,
"ai_note_created": handle_note_created,
"ai_task_assigned": handle_task_assigned,
}
```
P5 spec 调用链定义:
> 消费事件(结账单): └→ 应用 3维客线索分析→ 应用 8线索整理→ 应用 7客户分析

View File

@@ -0,0 +1,106 @@
# P5.1→NS3 缺失项 #4App4-App7 的缓存策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- cache_type 枚举和保留策略500 条上限 + 清理机制)已实现,但缺少 expires_at 过期策略和主动失效条件的定义与实现。
## 详细审查
### 审查范围
- `apps/backend/app/ai/cache_service.py` — AI 缓存读写服务
- `apps/backend/app/ai/schemas.py` — CacheTypeEnum 枚举定义
- `docs/database/ddl/zqyy_app__biz.sql` — ai_cache 表 DDL
- `docs/prd/specs/P5-miniapp-ai-integration.md` — P5 spec 缓存策略定义
### 发现
#### ✅ 已实现部分
1. **cache_type 枚举**`schemas.py``CacheTypeEnum` 定义了 7 个枚举值:
```python
APP2_FINANCE = "app2_finance"
APP3_CLUE = "app3_clue"
APP4_ANALYSIS = "app4_analysis"
APP5_TACTICS = "app5_tactics"
APP6_NOTE_ANALYSIS = "app6_note_analysis"
APP7_CUSTOMER_ANALYSIS = "app7_customer_analysis"
APP8_CLUE_CONSOLIDATED = "app8_clue_consolidated"
```
与 P5 spec 定义完全一致。
2. **保留策略**`cache_service.py` 的 `_cleanup_excess()` 实现了 P5 spec 定义的"每个 (cache_type, site_id, target_id) 组合保留最近 500 条记录"
```python
def _cleanup_excess(self, ..., max_count: int = 500) -> int:
```
清理时机:每次 `write_cache()` 写入新记录后异步清理。
3. **target_id 约定**:各应用的 target_id 含义在 P5 spec 中有明确定义App2=时间维度编码、App3/6/7/8=member_id、App4/5=assistant_id_member_id
4. **数据库表结构**`ai_cache` 表包含 `expires_at timestamp with time zone` 字段DDL 层面支持过期时间。
#### ❌ 未实现部分
1. **expires_at 过期策略**
- DDL 中 `expires_at` 字段存在但代码中从未设置有效值
- `write_cache()` 接受 `expires_at` 参数但所有调用方App2-App8 的 `run()` 函数)均未传入 `expires_at`
- 没有定时任务或查询逻辑检查 `expires_at` 并清理过期记录
- `get_latest()` 查询时不过滤已过期记录
2. **主动失效条件**
- P5 spec 未定义具体的失效条件(如"数据源更新后旧缓存失效"
- NS3 spec 提到"ai_cache 表但未定义策略"——确实如此
- 当前实现是"只增不删"(除 500 条上限清理外),没有基于业务事件的缓存失效机制
3. **缓存读取时的新鲜度检查**
- `get_latest()` 仅按 `created_at DESC` 取最新一条,不检查缓存是否仍然有效
- 前端读取缓存时无法判断数据是否过时
### 证据
`cache_service.py` 中 `write_cache()` 的 `expires_at` 参数:
```python
def write_cache(
self,
cache_type: str,
site_id: int,
target_id: str,
result_json: dict,
triggered_by: str | None = None,
score: int | None = None,
expires_at: datetime | None = None, # 接受但从未被调用方传入
) -> int:
```
App2 调用 `write_cache` 时未传入 `expires_at`
```python
cache_svc.write_cache(
cache_type=CacheTypeEnum.APP2_FINANCE.value,
site_id=site_id,
target_id=time_dimension,
result_json=result,
triggered_by=f"user:{user_id}",
# 无 expires_at
)
```
DDL 中 `expires_at` 字段定义:
```sql
expires_at timestamp with time zone -- 存在但未被使用
```
### 建议
1. **定义各应用的缓存过期策略**
| 应用 | 建议 expires_at | 理由 |
|------|----------------|------|
| App2 | 当日 08:00营业日切点 | 财务数据每日更新,次日数据变化后旧缓存无意义 |
| App3 | 7 天 | 消费数据线索在下次消费前有效 |
| App4/5 | 3 天 | 关系分析和话术时效性较强 |
| App6 | 无过期 | 备注分析结果不会因时间失效 |
| App7 | 7 天 | 客户分析随新数据更新 |
| App8 | 无过期 | 整合线索由上游触发更新 |
2. **实现过期检查**:在 `get_latest()` 中增加 `WHERE expires_at IS NULL OR expires_at > NOW()` 过滤。
3. **考虑增加缓存失效事件**:当 App3/App6 产出新内容后,可标记旧的 App7 缓存为失效,确保前端读到最新分析。

View File

@@ -0,0 +1,128 @@
# P5.1→NS3 缺失项 #5LLM 调用的错误处理和降级策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 重试机制和异常分类已完整实现调用链级容错步骤失败后继续也已到位。但缺少限流429降级回退、模型不可用时的静态兜底内容、以及面向前端的统一错误码体系。
## 详细审查
### 审查范围
- `apps/backend/app/ai/bailian_client.py` — 百炼 API 统一封装层
- `apps/backend/app/ai/dispatcher.py` — AI 事件调度与调用链编排器
- `apps/backend/app/ai/apps/app1_chat.py` — App1 流式对话错误处理
- `apps/backend/app/ai/apps/app3_clue.py` — App3 数据获取降级示例
- `apps/backend/app/main.py` — AI 事件处理器注册的容错
### 发现
#### ✅ 已实现部分
1. **重试机制(指数退避)**`bailian_client.py``_call_with_retry()` 实现了完整的重试策略:
- 最多重试 3 次(`MAX_RETRIES = 3`
- 间隔1s → 2s → 4s指数退避`BASE_INTERVAL = 1`
- 5xx / 超时 / 连接错误:重试
- 4xx400/401/403/404/422/429不重试直接抛出
2. **异常分类体系**:定义了 4 个异常类:
- `BailianApiError`:通用 API 错误(含 status_code
- `BailianJsonParseError`JSON 解析失败(含 raw_content
- `BailianAuthError`API Key 无效401
- 继承关系清晰,调用方可按需捕获
3. **调用链级容错**`dispatcher.py``_run_step()` 实现了步骤级容错:
- 某步失败 → 记录错误日志 + 写入失败 conversation 记录 → 返回 None
- 后续步骤继续执行,使用已有缓存
- 整条链后台异步执行(`asyncio.create_task`),不阻塞业务请求
4. **App1 流式错误处理**`app1_chat.py``chat_stream()` 在异常时 yield `SSEEvent(type="error", message=str(e))`,前端可据此展示错误提示。
5. **数据获取降级**`app3_clue.py``build_prompt()` 在消费数据获取失败时降级为空值:
```python
except Exception:
member_data = _default_member_data()
data_fetch_failed = True
```
6. **启动容错**`main.py` 中 AI 事件处理器注册包裹在 try/except 中API Key 缺失或注册失败不影响后端启动。
#### ❌ 未实现部分
1. **限流429降级回退**
- 当前 429 RateLimitError 直接抛出,不重试
- 缺少限流时的降级策略(如返回缓存中的上一次结果、排队等待、降低调用频率)
- 消费事件链中如果百炼 API 限流,整个 App 步骤直接失败
2. **模型不可用时的静态兜底**
- 当百炼 API 完全不可用(如网络中断、服务宕机)时,重试 3 次后抛出 `BailianApiError`
- 对于 App2财务洞察、App4关系分析等前端直接展示的应用缺少静态兜底内容
- 前端读取 ai_cache 时如果没有缓存记录(首次调用就失败),会得到空结果
3. **统一错误码体系**
- App1 的 SSEEvent error 只传 `message=str(e)`,缺少结构化错误码
- 前端无法区分"网络超时"、"API Key 过期"、"限流"等不同错误类型
- 不同错误类型应有不同的用户提示(如限流→"AI 繁忙请稍后"、认证→"服务配置异常"
4. **熔断机制**
- 缺少熔断器Circuit Breaker连续失败 N 次后暂停调用,避免无效重试
- 高并发场景下(如多个消费事件同时触发),可能产生大量失败请求
### 证据
`bailian_client.py` 中的重试和异常处理:
```python
# 429 限流:直接抛出,不重试
except openai.RateLimitError as e:
logger.error("百炼 API 限流: %s", e)
raise BailianApiError(str(e), status_code=429) from e
# 5xx / 超时 / 连接错误:重试
except (openai.InternalServerError, openai.APIConnectionError,
openai.APITimeoutError) as e:
last_error = e
if attempt < self.MAX_RETRIES - 1:
wait_time = self.BASE_INTERVAL * (2 ** attempt)
await asyncio.sleep(wait_time)
```
`dispatcher.py` 中的步骤级容错:
```python
async def _run_step(self, app_name, run_func, context) -> dict | None:
try:
result = await run_func(context, self.bailian, ...)
return result
except Exception:
logger.exception("调用链步骤失败: %s", app_name)
# 写入失败 conversation 记录
# ...
return None
```
`main.py` 中的启动容错:
```python
try:
if _api_key and _base_url:
# ... 注册 AI 事件处理器
register_ai_handlers(_dispatcher)
except Exception:
_log.getLogger(__name__).warning(
"AI 事件处理器注册失败AI 功能不可用", exc_info=True)
```
### 建议
1. **429 限流降级**:捕获 `RateLimitError` 后,尝试返回 ai_cache 中该 (cache_type, site_id, target_id) 的最新缓存结果,并在结果中标注 `"_from_cache": true`,让前端知道这是缓存数据。
2. **统一错误码枚举**
```python
class AIErrorCode(str, Enum):
RATE_LIMITED = "rate_limited" # AI 繁忙,请稍后再试
AUTH_FAILED = "auth_failed" # 服务配置异常
TIMEOUT = "timeout" # 请求超时
SERVICE_DOWN = "service_down" # AI 服务暂时不可用
PARSE_ERROR = "parse_error" # AI 返回格式异常
```
3. **熔断器**:在 `BailianClient` 中增加简单的熔断逻辑——连续失败 5 次后,后续 60 秒内直接返回缓存或静态兜底,不再调用 API。
4. **App1 SSE 错误结构化**:将 `SSEEvent(type="error")` 扩展为包含 `error_code` 字段,前端据此展示不同的用户提示。

View File

@@ -0,0 +1,70 @@
# P5.1→NS3 缺失项 #6Token 用量监控和成本控制
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 已实现消息级 token 记录和 Prompt 长度截断,但缺少日/月预算控制、单次调用上限校验、超限熔断机制
## 详细审查
### 审查范围
- `apps/backend/app/ai/bailian_client.py` — 百炼 API 封装层
- `apps/backend/app/ai/conversation_service.py` — 对话持久化服务
- `apps/backend/app/ai/dispatcher.py` — AI 调用链调度器
- `apps/backend/app/ai/apps/app5_tactics.py` — 典型 App 调用流程
- `apps/backend/app/ai/cache_service.py` — 缓存服务
- `docs/database/ddl/zqyy_app__biz.sql` — biz schema DDL 基线
- `db/zqyy_app/migrations/` — 迁移脚本
### 发现
#### ✅ 已实现部分
1. **消息级 token 记录**`bailian_client.chat_json()` 返回 `(parsed_json, tokens_used)` 元组,从 `response.usage.total_tokens` 提取。所有 App3-8在调用后将 `tokens_used` 写入 `biz.ai_messages` 表。
2. **Prompt 长度截断**:各 App 的 `build_prompt()` 均实现了 system message 内容长度控制(如 App5 的 `_MAX_SYSTEM_CONTENT_LEN = 8000`),超长时截断服务记录、消费记录、备注等。
3. **API 层 max_tokens 参数**`chat_json()` 默认 `max_tokens=4000``chat_stream()` 默认 `max_tokens=2000`,限制了单次调用的输出 token 上限。
4. **数据库持久化**`biz.ai_messages` 表有 `tokens_used integer` 字段,每条 assistant 消息都记录了 token 消耗量。
#### ❌ 未实现部分
1. **无日/月预算控制**:代码中无任何按时间窗口(日/月)汇总 token 用量并与预算阈值比较的逻辑。
2. **无单次调用上限校验**`max_tokens` 是硬编码默认值,无基于配置表或环境变量的动态上限。
3. **无超限熔断机制**:当 token 消耗达到阈值时,无拒绝服务或降级处理逻辑。
4. **无 token 用量汇总表**:数据库中无 `ai_token_usage``ai_budget` 等汇总/预算表。
5. **无成本告警**:无日志告警或通知机制在 token 消耗异常时触发。
### 证据
**token 记录bailian_client.py L138**
```python
tokens_used = response.usage.total_tokens if response.usage else 0
return parsed, tokens_used
```
**消息写入conversation_service.py L77**
```python
INSERT INTO biz.ai_messages
(conversation_id, role, content, tokens_used)
VALUES (%s, %s, %s, %s)
```
**DDL 基线zqyy_app__biz.sql**
```sql
CREATE TABLE biz.ai_messages (
...
tokens_used integer,
...
);
```
**搜索 `token.*budget|cost.*control|usage.*limit|日预算|月预算` 无匹配结果**(仅匹配到缓存清理的"超限"字样,与 token 成本无关)。
### 建议
1. **新增 token 用量汇总视图或定时任务**:按 `site_id + app_id + 日期` 汇总 `biz.ai_messages.tokens_used`,写入 `biz.ai_token_daily_usage`
2. **新增预算配置**:在环境变量或配置表中定义 `AI_DAILY_TOKEN_LIMIT``AI_MONTHLY_TOKEN_LIMIT`
3. **在 dispatcher._run_step 前增加预算检查**:调用 AI 前查询当日/当月累计用量,超限则拒绝并记录日志
4. **告警机制**:当日用量达到预算 80% 时记录 WARNING 日志,达到 100% 时记录 ERROR 并熔断

View File

@@ -0,0 +1,79 @@
# P5.1→NS3 缺失项 #7App5 话术模板分类和质量评估标准
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- App5 已实现完整的话术生成流程数据获取→Prompt 构建→AI 调用→持久化),但话术输出仅按 scenario/script 结构化,缺少 P5.1 定义的话术分类体系(召回/维护/推荐)和质量评估维度
## 详细审查
### 审查范围
- `apps/backend/app/ai/apps/app5_tactics.py` — App5 话术参考实现
- `apps/backend/app/ai/schemas.py` — Pydantic 模型定义
- `apps/backend/app/ai/apps/app4_analysis.py` — App4 关系分析App5 上游)
- `tests/test_ai_apps/test_build_prompt_props.py` — 属性测试
- `tests/test_ai_apps/test_ai_apps_unit.py` — 单元测试
- `tests/test_p5_ai_integration_properties.py` — P5 集成属性测试
### 发现
#### ✅ 已实现部分
1. **完整调用链**App5 由 App4 联动触发,接收 `context["app4_result"]` 作为 `task_suggestion`,包含 `task_description``action_suggestions`
2. **数据驱动 Prompt**:并发获取助教信息、服务历史、消费数据、备注 4 类数据,构建丰富的上下文。
3. **Reference 机制**:引用最近 2 套 App8 历史结果作为 Prompt reference提供线索整合上下文。
4. **结构化输出**Pydantic 模型 `App5Result` 定义了 `tactics: list[App5TacticsItem]`,每条包含 `scenario`(场景)和 `script`(话术内容)。
5. **Token 预算控制**`_MAX_SYSTEM_CONTENT_LEN = 8000`,超长时分级截断服务记录→消费记录→备注。
6. **降级处理**4 类数据获取均有异常捕获和降级逻辑,部分失败不阻断。
#### ❌ 未实现部分
1. **无话术分类体系**Prompt 中 `output_format` 仅要求 `scenario + script`,未定义话术类型分类(如 P5.1 中的召回话术/维护话术/推荐话术)。当前 scenario 是自由文本,由 AI 自行决定场景描述。
2. **无质量评估标准**:无类似 App6 的 `score` 评分机制。App5 输出无评估维度(如话术的针对性、可执行性、情感适配度等)。
3. **无话术模板库**:无预定义的话术模板或参考范例供 AI 参考,完全依赖 AI 自由生成。
4. **Pydantic 模型无分类枚举**`App5TacticsItem` 仅有 `scenario: str``script: str`,无 `tactic_type``category` 枚举字段。
### 证据
**App5 输出格式定义app5_tactics.py L131-134**
```python
"output_format": {
"tactics": [
{"scenario": "场景描述", "script": "话术内容"}
]
},
```
**Pydantic 模型schemas.py**
```python
class App5TacticsItem(BaseModel):
scenario: str
script: str
class App5Result(BaseModel):
tactics: list[App5TacticsItem]
```
**对比 App6 有评分机制**
```python
class App6Result(BaseModel):
score: int = Field(ge=1, le=10)
clues: list[ClueItem]
```
App5 无类似评分或分类枚举。
### 建议
1. **新增话术类型枚举**:在 `schemas.py` 中定义 `App5TacticTypeEnum`(如 `recall`/`maintain`/`recommend`/`upsell`),在 `App5TacticsItem` 中增加 `tactic_type` 字段
2. **Prompt 中明确分类要求**:在 `output_format` 中增加 `tactic_type` 字段说明,引导 AI 按分类生成
3. **可选:增加质量评估**:参考 App6 的 score 机制,为每条话术增加 `relevance_score`(针对性评分)
4. **可选:话术模板库**:在 Prompt reference 中注入预定义的优秀话术范例,提升生成质量

View File

@@ -0,0 +1,78 @@
# P5.1→NS3 缺失项 #8各 App 的单元测试用例设计
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 已建立完整的测试体系覆盖属性测试Hypothesis+ 单元测试 + 集成属性测试,涵盖所有 8 个 AI 应用和 3 个 data_fetcher
## 详细审查
### 审查范围
- `tests/test_ai_apps/` — AI 应用测试目录3 个文件)
- `tests/test_data_fetchers/` — 数据获取器测试目录4 个文件)
- `tests/test_p5_ai_integration_properties.py` — P5 AI 集成属性测试
- `tests/test_rns1_*.py` — RNS1 Chat 模块属性测试8 个文件)
### 发现
#### ✅ 测试覆盖情况
**1. AI 应用属性测试(`tests/test_ai_apps/`**
| 文件 | 覆盖内容 | 测试数量 |
|------|----------|----------|
| `test_app1_props.py` | App1 biz_params 注入不变量、System Prompt Token 预算 | 2 个属性 |
| `test_build_prompt_props.py` | App3/4/5/6/7 的 Prompt 结构验证、错误降级、Token 预算 | 13 个属性 |
| `test_ai_apps_unit.py` | App1 页面上下文集成、App3/5/7 完整流程 | 6 个单元测试 |
**2. 数据获取器测试(`tests/test_data_fetchers/`**
| 文件 | 覆盖内容 | 测试数量 |
|------|----------|----------|
| `test_member_data_props.py` | 消费数据必填字段、正交易过滤、items_sum 口径、排序、备注截断 | 7 个属性 |
| `test_assistant_data_props.py` | 废单排除、排序保持 | 2 个属性 |
| `test_page_context_props.py` | 输出长度约束、全页面类型覆盖、敏感字段检测 | 3 个属性 |
| `test_data_fetchers_unit.py` | 空记录、FDW 超时、会员不存在、助教不存在等边界 | 10 个单元测试 |
**3. P5 集成属性测试(`test_p5_ai_integration_properties.py`**
覆盖 App2-App8 全部 7 个应用的 JSON 输出结构验证Pydantic 模型解析),包括:
- 枚举值合法性App3 的 3 分类、App6/8 的 6 分类)
- 字段非空约束
- App6 score 范围 [1,10] 及越界拒绝
- 每个应用 100 个随机用例
**4. Chat 模块属性测试(`tests/test_rns1_*.py`**
8 个文件覆盖 Chat 模块的排序、持久化、引用卡片、对话复用、SSE、标题生成、性能等属性。
#### 测试方法论
- **属性测试Hypothesis**:使用 `@given` + `@settings(max_examples=100)` 生成随机输入,验证不变量
- **单元测试Mock**Mock 数据获取函数(`AsyncMock`),不连真实数据库
- **边界条件**空记录、FDW 超时、数据获取失败降级、超长文本截断等
### 证据
**测试文件统计**
```
tests/test_ai_apps/ → 3 文件21+ 测试用例
tests/test_data_fetchers/ → 4 文件22+ 测试用例
tests/test_p5_ai_integration_properties.py → 9 个属性测试App2-8 + score 越界)
tests/test_rns1_*.py → 8 文件Chat 模块全覆盖
```
**典型属性测试示例test_build_prompt_props.py**
```python
# Property 14: 错误降级 — App5 数据获取失败不阻断
def test_prop14_app5_error_degradation(fail_assistant, fail_service, fail_member, fail_notes):
# Property 15: Token 预算 — App5 system message 长度 ≤ 8000
def test_prop15_app5_token_budget(records, notes):
```
### 建议
测试体系已较完整,以下为可选增强方向(非必须):
1. App2财务洞察和 App4关系分析`build_prompt` 属性测试可补充(当前仅有 App4 的结构测试,无 App2 的 Prompt 构建测试)
2. 端到端集成测试(`scripts/ops/test_chat_e2e.py` 已存在但为运维脚本,非 pytest 用例)

View File

@@ -0,0 +1,76 @@
# P5.1→NS3 缺失项 #9MCP Server 的健康检查端点和监控指标
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟡 低
- 后端 FastAPI 已有 `/health` 端点,但 MCP Server`apps/mcp-server/server.py`)无健康检查端点和监控指标
## 详细审查
### 审查范围
- `apps/backend/app/main.py` — 后端主入口
- `apps/mcp-server/server.py` — MCP Server 实现
- `apps/mcp-server/pyproject.toml` — MCP Server 依赖配置
### 发现
#### ✅ 后端 FastAPI 健康检查(已实现)
`apps/backend/app/main.py` 中定义了健康检查端点:
```python
@app.get("/health", tags=["系统"])
async def health_check():
"""健康检查端点,用于探活和监控。"""
return {"status": "ok"}
```
此外还有诊断端点 `/debug/config-paths`,返回关键路径配置信息。
#### ❌ MCP Server 健康检查(未实现)
`apps/mcp-server/server.py` 分析:
1. **无 `/health` 端点**MCP Server 基于 Starlette + MCP SDK 构建,`lifespan` 函数仅管理数据库连接池的打开/关闭,无健康检查路由。
2. **无监控指标**:无请求计数、延迟统计、错误率等监控指标暴露。
3. **有认证中间件**`AuthMiddleware` 验证 Bearer Token但无健康检查的豁免路径。
4. **MCP Server 架构**:提供 `list_tables``describe_table``query_sql` 等数据库查询工具,通过 SSE 协议与 AI 客户端通信。当前无法从外部探测其存活状态。
5. **数据库连接池**`lifespan``pool.open(wait=True, timeout=30)` 管理连接池,但连接池健康状态未暴露。
### 证据
**后端健康检查main.py**
```python
@app.get("/health", tags=["系统"])
async def health_check():
return {"status": "ok"}
```
**MCP Server lifespanserver.py L385-391**
```python
async def lifespan(app: Starlette):
pool.open(wait=True, timeout=30)
try:
async with mcp.session_manager.run():
yield
finally:
pool.close(timeout=5)
```
**MCP Server 无 health 路由**:搜索 `health|ping|status|monitor``apps/mcp-server/` 中无匹配结果。
### 建议
1. **为 MCP Server 添加 `/health` 端点**:在 Starlette 应用中注册健康检查路由,返回连接池状态和服务版本
```python
@app.route("/health")
async def health(request):
pool_status = "ok" if pool._pool and not pool._pool.closed else "degraded"
return JSONResponse({"status": pool_status, "service": "mcp-server"})
```
2. **健康检查豁免认证**:在 `AuthMiddleware.dispatch` 中对 `/health` 路径跳过 Token 验证
3. **可选:暴露基础监控指标**:请求计数、平均延迟、连接池使用率等(可通过 Prometheus 格式暴露)

View File

@@ -0,0 +1,127 @@
# P5.1→NS3 缺失项 #10AI 生成内容的审计日志
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- `biz.ai_conversations` + `biz.ai_messages` 两张表已完整记录"谁在什么时候对哪个客户生成了什么",满足审计日志需求
## 详细审查
### 审查范围
- `apps/backend/app/ai/conversation_service.py` — 对话持久化服务
- `apps/backend/app/services/chat_service.py` — Chat 模块服务
- `docs/database/ddl/zqyy_app__biz.sql` — biz schema DDL 基线
- `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql` — Chat 模块扩展迁移
- `apps/backend/app/ai/apps/app5_tactics.py` — 典型 App 调用流程(审计写入示例)
### 发现
#### ✅ 审计信息完整记录
**1. `biz.ai_conversations` 表 — 对话级审计**
| 字段 | 类型 | 审计用途 |
|------|------|----------|
| `id` | bigint PK | 对话唯一标识 |
| `user_id` | varchar(50) NOT NULL | **谁**:操作用户 ID系统自动调用时为 `'system'` |
| `nickname` | varchar(100) | 用户昵称 |
| `app_id` | varchar(30) NOT NULL | **什么应用**app1_chat / app2_finance / ... / app8_consolidation |
| `site_id` | bigint NOT NULL | **哪个门店** |
| `source_page` | varchar(100) | 触发来源页面 |
| `source_context` | jsonb | 上下文信息(含 `assistant_id``member_id` 等 → **对哪个客户** |
| `context_type` | varchar(20) | 对话关联类型task/customer/coach/general |
| `context_id` | varchar(50) | 关联 IDtaskId/customerId/coachId |
| `title` | varchar(200) | 对话标题 |
| `created_at` | timestamptz | **什么时候** |
**2. `biz.ai_messages` 表 — 消息级审计**
| 字段 | 类型 | 审计用途 |
|------|------|----------|
| `id` | bigint PK | 消息唯一标识 |
| `conversation_id` | bigint FK | 关联对话 |
| `role` | varchar(10) | system / user / assistant |
| `content` | text NOT NULL | **生成了什么**:完整的 AI 输入和输出内容 |
| `tokens_used` | integer | Token 消耗量 |
| `reference_card` | jsonb | 引用卡片数据 |
| `created_at` | timestamptz | 消息时间戳 |
**3. 写入时机**
所有 8 个 AI 应用在每次调用时都通过 `ConversationService` 写入完整审计链:
```
create_conversation(user_id, nickname, app_id, site_id, source_context)
→ add_message(role="system", content=prompt)
→ add_message(role="user", content=user_input)
→ 调用百炼 API
→ add_message(role="assistant", content=ai_output, tokens_used=N)
```
**4. 索引支持审计查询**
```sql
-- 按用户+门店查询历史对话
CREATE INDEX idx_ai_conv_user_site ON biz.ai_conversations (user_id, site_id, created_at DESC);
-- 按应用+门店查询
CREATE INDEX idx_ai_conv_app_site ON biz.ai_conversations (app_id, site_id, created_at DESC);
-- 按上下文查询(客户/任务/助教维度)
CREATE INDEX idx_ai_conv_context ON biz.ai_conversations (user_id, site_id, context_type, context_id, ...);
-- 按对话查询消息
CREATE INDEX idx_ai_msg_conv ON biz.ai_messages (conversation_id, created_at);
```
### 证据
**App5 审计写入示例app5_tactics.py L240-268**
```python
# 创建对话记录
conversation_id = conv_svc.create_conversation(
user_id=user_id, nickname=nickname,
app_id=APP_ID, site_id=site_id,
source_context={"assistant_id": assistant_id, "member_id": member_id},
)
# 写入 system + user 消息
conv_svc.add_message(conversation_id=conversation_id, role="system", content=...)
conv_svc.add_message(conversation_id=conversation_id, role="user", content=...)
# 调用 AI 后写入 assistant 消息
result, tokens_used = await bailian.chat_json(messages)
conv_svc.add_message(
conversation_id=conversation_id, role="assistant",
content=json.dumps(result, ensure_ascii=False),
tokens_used=tokens_used,
)
```
**DDL 基线确认zqyy_app__biz.sql**
```sql
CREATE TABLE biz.ai_conversations (
id bigint ... NOT NULL,
user_id character varying(50) NOT NULL,
nickname character varying(100) ...,
app_id character varying(30) NOT NULL,
site_id bigint NOT NULL,
source_page character varying(100),
source_context jsonb,
context_type character varying(20),
context_id character varying(50),
...
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.ai_messages (
id bigint ... NOT NULL,
conversation_id bigint NOT NULL,
role character varying(10) NOT NULL,
content text NOT NULL,
tokens_used integer,
reference_card jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
```
### 建议
审计日志功能已完整实现,以下为可选增强方向(非必须):
1. **审计查询 API**:当前仅有面向用户的历史对话查询(`get_conversations`可增加面向管理员的审计查询接口按时间范围、app_id、site_id 筛选)
2. **数据保留策略**:当前无自动清理机制,长期运行后 `ai_messages.content` 可能占用大量存储,建议制定保留策略

View File

@@ -0,0 +1,53 @@
# P6→NS1/RNS1 缺失项 #1任务卡片 5 种状态的视觉规范
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 前端已实现 3 种状态(待处理/已置顶/已放弃)的视觉差异,但缺少「已完成」和「过期」两种独立状态的视觉规范。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/utils/vi-colors.ts` — TASK_STATUS_COLORS
- `apps/miniprogram/miniprogram/utils/task-config.ts` — TASK_STATUS_CONFIG
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` — 卡片模板
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss` — 卡片样式
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — 分组逻辑
### 发现
**已实现的 3 种状态:**
1. **待处理normal/pending**:白色卡片,左侧彩条按任务类型着色(红/橙/粉/青),正常文字颜色
2. **已置顶pinned**:在待处理基础上增加金色光晕阴影(`box-shadow: 0 5rpx 7rpx rgba(245,158,11,0.12), 0 0 0 8rpx rgba(245,158,11,0.08)`
3. **已放弃abandoned**:左侧彩条变灰(`--status-abandoned-border`),整卡透明度降低(`opacity: 0.55`),标签灰化,文字变灰
**缺失的 2 种状态:**
4. **已完成completed**`task-config.ts``TASK_STATUS_CONFIG` 中无 `completed` 键;`vi-colors.ts``TASK_STATUS_COLORS` 中无 `completed` 定义WXML 中无 `task-card--completed`
5. **过期expired**:无独立的过期卡片状态样式。过期目前仅通过 `deadline` 字段在卡片内显示红色逾期徽章(`overdue-badge`),但卡片整体样式不变
### 证据
`vi-colors.ts` 第 4 节仅定义了两种状态:
```typescript
export const TASK_STATUS_COLORS = {
pinned: { name: '置顶', glowColor: '#f59e0b', ... },
abandoned: { name: '放弃', borderColor: '#d1d5db', textColor: '#9ca3af', opacity: 0.55 },
}
```
`task-config.ts` 仅定义了三种状态:
```typescript
export const TASK_STATUS_CONFIG = {
normal: { label: '进行中', icon: '📋' },
pinned: { label: '已置顶', icon: '📌' },
abandoned: { label: '已放弃', icon: '❌' },
}
```
### 建议(如未完全解决)
1.`TASK_STATUS_COLORS``TASK_STATUS_CONFIG` 中补充 `completed``expired` 状态定义
2. 已完成状态建议:绿色勾选图标 + 轻微灰化opacity 0.7-0.8),左侧彩条保留但降低饱和度
3. 过期状态建议:红色边框或红色背景提示,与逾期徽章配合使用
4. 注意:后端 `get_task_list_v2` 按 status 筛选pending/completed/abandoned前端目前仅请求 pending 状态completed 列表页尚未实现

View File

@@ -0,0 +1,52 @@
# P6→NS1/RNS1 缺失项 #23 种空状态设计
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 已实现 2 种空状态(无任务、网络错误),但缺少「筛选无结果」空状态,且现有空状态缺少插图。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` — 空状态模板
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — pageState 逻辑
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss` — 空状态样式
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml` — 详情页空状态
### 发现
**已实现的 2 种空状态:**
1. **无任务empty**`pageState === 'empty'` 时显示纯文字「暂无待办任务」
2. **网络错误error**`pageState === 'error'` 时显示「加载失败,请重试」+ 重试按钮
**缺失项:**
3. **筛选无结果**:当前 task-list 页面无筛选功能(无 filter-dropdown 组件引用),因此无筛选无结果状态。但 P6 定义了此场景,未来添加筛选时需要补充
4. **插图缺失**:两种空状态均为纯文字,无 SVG/PNG 插图。P6 定义了每种空状态应有对应插图
5. task-detail 页面有更完善的空状态:使用了 `t-icon` 图标info-circle / close-circle但仍非 P6 定义的专属插图
### 证据
task-list.wxml 空状态实现:
```html
<!-- 空状态 -->
<view class="state-empty" wx:if="{{pageState === 'empty'}}">
<text class="empty-text">暂无待办任务</text>
</view>
<!-- Error 状态 -->
<view class="state-error" wx:if="{{pageState === 'error'}}">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" bindtap="onRetry">
<text class="retry-btn-text">重试</text>
</view>
</view>
```
pageState 类型定义仅 4 种:`'loading' | 'empty' | 'error' | 'normal'`,无 `'filter-empty'` 状态。
### 建议(如未完全解决)
1. 为 empty 和 error 状态添加 SVG 插图(建议放在 `/assets/images/empty-*.svg`
2. 预留 `filter-empty` 状态,文案如「没有找到匹配的任务,试试调整筛选条件」
3. 可使用 TDesign 的 `t-empty` 组件替代自定义实现,自带图标和文案插槽

View File

@@ -0,0 +1,52 @@
# P6→NS1/RNS1 缺失项 #3置顶任务排序规则
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 后端已实现「置顶优先 → 优先级分数降序 → 创建时间升序」的排序规则,与 P6 定义的排序意图一致。
## 详细审查
### 审查范围
- `apps/backend/app/services/task_manager.py``get_task_list_v2()` 函数中的 SQL ORDER BY
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — 前端分组逻辑
### 发现
**后端排序(已实现):**
`task_manager.py``get_task_list_v2()` 中 SQL 查询明确定义了排序规则:
```sql
ORDER BY is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
```
- `is_pinned DESC`:置顶任务排在最前
- `priority_score DESC NULLS LAST`:非置顶任务按优先级分数降序(高优先级在前)
- `created_at ASC`:同优先级按创建时间升序(先创建的在前)
**前端分组(已实现):**
`task-list.ts``loadData()` 中将后端返回的列表按状态分组:
```typescript
const pinnedTasks = enriched.filter((t) => t.isPinned && !t.isAbandoned)
const normalTasks = enriched.filter((t) => !t.isPinned && !t.isAbandoned && t.status === 'pending')
const abandonedTasks = enriched.filter((t) => t.isAbandoned)
```
WXML 中按「📌 置顶 → 正常任务 → 已放弃」三组依次渲染,组内保持后端返回顺序。
### 证据
后端 SQLtask_manager.py 第 560-564 行):
```sql
ORDER BY is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
LIMIT %s OFFSET %s
```
### 建议(如未完全解决)
- P6 提到「置顶任务按置顶时间倒序」,当前实现是 `is_pinned DESC`(布尔值),多个置顶任务之间的排序依赖 `priority_score`。如需严格按置顶时间排序,需在 `coach_tasks` 表中添加 `pinned_at` 时间戳字段并在 ORDER BY 中使用。当前实现在功能上可接受,但与 P6 的精确定义有微小差异。

View File

@@ -0,0 +1,54 @@
# P6→NS1/RNS1 缺失项 #4放弃/取消放弃的二次确认弹窗
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 放弃操作已实现完整的二次确认弹窗abandon-modal 组件),含原因输入、确认/取消按钮。取消放弃在 task-detail 页面无二次确认(直接执行),在 task-list 页面通过长按菜单触发也无二次确认。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/components/abandon-modal/abandon-modal.wxml` — 弹窗模板
- `apps/miniprogram/miniprogram/components/abandon-modal/abandon-modal.ts` — 弹窗逻辑
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — 列表页放弃/取消放弃流程
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts` — 详情页放弃/取消放弃流程
### 发现
**放弃操作(完整实现):**
`abandon-modal` 组件实现了完整的二次确认弹窗:
- ⚠️ 警告图标 + 标题「放弃 {客户名}」
- 描述文案「确定放弃该客户的维护任务?请填写原因:」
- 必填原因输入框maxlength=200
- 「确认放弃」按钮(原因为空时禁用)+ 「取消」按钮
- 键盘弹出时自适应布局
task-list 和 task-detail 页面均引用了此组件:
- task-list长按菜单 → 点击「放弃任务」→ 打开 abandon-modal
- task-detail点击右上角「放弃」按钮 → 打开 abandon-modal
**取消放弃操作(简化实现):**
- task-list长按已放弃任务 → 菜单显示「↩️ 取消放弃」→ 直接执行showLoading → showToast无二次确认弹窗
- task-detail点击右上角「取消放弃」→ 直接执行 `cancelAbandon()`,无二次确认
### 证据
abandon-modal.wxml 核心结构:
```html
<view class="modal-header">
<text class="modal-emoji">⚠️</text>
<text class="modal-title">放弃 <text class="modal-name">{{customerName}}</text></text>
</view>
<view class="modal-desc-wrap">
<text class="modal-desc">确定放弃该客户的维护任务?请填写原因:</text>
</view>
<textarea maxlength="200" ... />
<view class="confirm-btn" bindtap="onConfirm">确认放弃</view>
<view class="cancel-btn" bindtap="onCancel">取消</view>
```
### 建议(如未完全解决)
- 取消放弃操作风险较低(恢复任务),不设二次确认是合理的 UX 决策
- 如 P6 严格要求取消放弃也需二次确认,可复用 `wx.showModal` 实现简单确认弹窗

View File

@@ -0,0 +1,81 @@
# P6→NS1/RNS1 缺失项 #5下拉刷新/触底加载的动画规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 已实现下拉刷新、触底加载、骨架屏 loading、错误重试的完整交互链路。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — onPullDownRefresh / onReachBottom / loadData
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` — skeleton 骨架屏
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss` — loading 样式
### 发现
**下拉刷新(已实现):**
```typescript
onPullDownRefresh() {
this.loadData(() => {
wx.stopPullDownRefresh()
})
}
```
使用微信原生下拉刷新机制,刷新完成后调用 `wx.stopPullDownRefresh()` 停止动画。
**触底加载(已实现):**
```typescript
onReachBottom() {
if (!this.data.hasMore) return
this.setData({ hasMore: false })
wx.showToast({ title: '没有更多了', icon: 'none' })
}
```
当前为简化实现一次性加载触底时显示「没有更多了」提示。后端已支持分页参数page/page_size
**骨架屏 Loading已实现**
```html
<view class="state-loading" wx:if="{{pageState === 'loading'}}">
<view class="loading-placeholder" wx:for="{{[1,2,3]}}" wx:key="*this">
<view class="ph-line ph-line--title"></view>
<view class="ph-line ph-line--body"></view>
<view class="ph-line ph-line--short"></view>
</view>
</view>
```
3 个占位卡片,每个含标题行、内容行、短行,模拟真实卡片布局。
**错误重试(已实现):**
```html
<view class="state-error" wx:if="{{pageState === 'error'}}">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" bindtap="onRetry">重试</view>
</view>
```
**task-detail 页面也有完整状态管理:**
- loading使用 `t-loading` 组件的 circular 主题浮层
- empty`t-icon` info-circle + 文案
- error`t-icon` close-circle + 重试按钮
### 证据
骨架屏样式task-list.wxss
```css
.loading-placeholder {
background: #ffffff;
border-radius: 22rpx;
padding: 29rpx;
margin-bottom: 22rpx;
}
.ph-line { height: 22rpx; background: #f3f3f3; border-radius: 7rpx; margin-bottom: 15rpx; }
.ph-line--title { width: 40%; height: 29rpx; }
.ph-line--body { width: 80%; }
.ph-line--short { width: 55%; }
```
### 建议(如未完全解决)
- 触底加载目前是简化实现,未真正调用分页接口加载下一页。后端已支持 `page`/`page_size` 参数,前端需补充增量加载逻辑
- 骨架屏可考虑使用 TDesign 的 `t-skeleton` 组件替代自定义实现,获得更丰富的动画效果
- 可添加下拉刷新时的自定义 loading 动画(当前使用微信原生样式)

View File

@@ -0,0 +1,64 @@
# P6→NS1/RNS1 缺失项 #6AI 分析卡片的折叠/展开交互
## 简要结论
- 状态:❌ 未解决
- 风险等级:🔴 高
- task-detail 页面中 AI 分析内容以固定展开方式呈现,无折叠/展开交互,无「重新生成」按钮,无 AI 加载状态。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml` — AI 分析卡片模板
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts` — AI 分析逻辑
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxss` — AI 分析样式
### 发现
**当前实现:**
task-detail 页面中 AI 相关内容分布在多个卡片中,均为固定展开状态:
1. **「与我的关系」卡片**:显示 `detail.aiAnalysis.summary`,无折叠控制
2. **「任务建议」卡片**:显示 `detail.aiAnalysis.suggestions` 列表 + 话术参考,无折叠控制
3. **「行动建议」卡片**:显示 `detail.actionSuggestions`,条件渲染(有数据才显示),无折叠控制
**P6 定义但未实现的交互:**
1. **折叠/展开**P6 定义 AI 分析卡片应支持折叠/展开切换,默认展开,用户可收起以减少页面长度。当前无任何折叠/展开按钮或逻辑
2. **重新生成按钮**P6 定义 AI 分析卡片应有「重新生成」按钮,允许用户触发 AI 重新分析。当前无此按钮
3. **AI 加载状态**P6 定义 AI 分析生成中应显示 loading 动画(如骨架屏或 spinner。当前 AI 数据随任务详情一起返回,无独立加载状态
### 证据
task-detail.wxml 中 AI 相关卡片(无折叠/展开控制):
```html
<!-- 与我的关系 -->
<view class="card">
<view class="card-header">
<text class="section-title title-pink">与我的关系</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<!-- 直接展示,无折叠控制 -->
<view class="card-desc-wrap">
<text class="card-desc">{{detail.aiAnalysis.summary}}</text>
</view>
</view>
<!-- 任务建议 -->
<view class="card">
<view class="card-header">
<text class="section-title title-orange">任务建议</text>
<ai-title-badge color="{{aiColor}}" />
</view>
<!-- 直接展示所有建议,无折叠控制,无重新生成按钮 -->
...
</view>
```
task-detail.ts 中无任何 AI 折叠/展开相关的 data 字段或方法。
### 建议(如未完全解决)
1. 为每个 AI 卡片添加 `expanded` 状态和切换按钮(如 `▴ 收起` / `▾ 展开`),参考 note-modal 中 `ratingExpanded` 的实现模式
2. 在卡片 header 右侧添加「🔄 重新生成」按钮,点击后调用 AI 分析接口
3. 添加 AI 加载状态:可使用 `t-loading` 或自定义骨架屏,在 AI 数据未返回时显示
4. 后端需提供独立的 AI 重新生成接口(当前 `ai_cache` 仅支持读取缓存)

View File

@@ -0,0 +1,65 @@
# P6→NS1/RNS1 缺失项 #7任务优先级的视觉标识
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 任务类型(高优先召回/优先召回/关系构建/客户回访)有完整的颜色和标签视觉体系,但缺少独立的「高/中/低优先级」视觉标识。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/utils/vi-colors.ts` — TASK_TYPE_COLORS
- `apps/miniprogram/miniprogram/utils/task-config.ts` — TASK_TYPE_CONFIG
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` — 卡片标签
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss` — 标签样式
- `apps/backend/app/services/task_manager.py` — priority_score 字段
### 发现
**已实现的任务类型视觉体系:**
4 种任务类型各有独立的颜色方案:
| 类型 | 标签渐变 | 左侧彩条 | 标签文字 |
|------|----------|----------|----------|
| high_priority高优先召回 | #b91c1c#dc2626(红) | #dc2626 | 白色 |
| priority_recall优先召回 | #ea580c#f97316(橙) | #f97316 | 白色 |
| relationship关系构建 | #ec4899#f472b6(粉) | #f472b6 | 白色 |
| callback客户回访 | #0d9488#14b8a6(青) | #14b8a6 | 白色 |
**缺失的优先级视觉标识:**
P6 定义了独立于任务类型的「高/中/低优先级」视觉标识(颜色和图标),但当前实现中:
- 后端返回 `priority_score`(数值),但前端未使用此字段进行视觉区分
- 前端仅按 `taskType` 着色,未按 `priority_score` 显示优先级图标或颜色
- `vi-colors.ts``task-config.ts` 中无 `priority` 相关的颜色/图标定义
### 证据
后端返回 priority_score 但前端未消费:
```python
# task_manager.py get_task_list_v2()
items.append({
...
"task_type": task_type,
# priority_score 未包含在返回数据中
})
```
前端 enrichTask() 中无 priority 相关处理:
```typescript
function enrichTask(task: Task): EnrichedTask {
return {
...task,
// 无 priority 相关字段
deadlineLabel: formatDeadline((task as any).deadline).text,
deadlineStyle: formatDeadline((task as any).deadline).style,
}
}
```
### 建议(如未完全解决)
1. 在后端 items 中返回 `priority_score` 或映射为 `priority_level`high/medium/low
2.`vi-colors.ts` 中添加 `PRIORITY_COLORS` 定义(如:高=红色火焰图标、中=橙色、低=灰色)
3. 在卡片中添加优先级小图标或角标,与任务类型标签并列显示
4. 注意:当前任务类型名称已隐含优先级信息(「高优先召回」),是否需要额外的优先级标识需与产品确认

View File

@@ -0,0 +1,68 @@
# P6→NS1/RNS1 缺失项 #8任务到期倒计时的展示规则
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 已实现完整的到期倒计时展示规则,包含 4 级颜色变化(灰/正常/橙色警告/红色逾期),与 DISPLAY-STANDARDS-2.md §7 规范对齐。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/utils/time.ts``formatDeadline()` 函数
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml` — deadline 展示
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss` — deadline 样式
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` — enrichTask 中的 deadline 处理
### 发现
**`formatDeadline()` 函数(完整实现):**
```typescript
export function formatDeadline(deadline: string | null | undefined):
{ text: string; style: 'normal' | 'warning' | 'danger' | 'muted' } {
if (!deadline) return { text: '--', style: 'muted' }
const diff = /* 天数差 */
if (diff < 0) return { text: `逾期 ${Math.abs(diff)}`, style: 'danger' }
if (diff === 0) return { text: '今天到期', style: 'warning' }
if (diff <= 7) return { text: `还剩 ${diff}`, style: 'normal' }
return { text: `${mm}-${dd}`, style: 'muted' }
}
```
**4 级颜色映射WXSS 已实现):**
| 条件 | 文案 | style | 颜色 |
|------|------|-------|------|
| 无截止日期 | `--` | muted | #a6a6a6(灰) |
| > 7 天 | `MM-DD` | muted | #a6a6a6(灰) |
| 1-7 天 | `还剩 N 天` | normal | #5e5e5e(深灰) |
| 今天 | `今天到期` | warning | #ed7b2f(橙) |
| 已逾期 | `逾期 N 天` | danger | #e34d59(红) |
**逾期徽章(额外实现):**
逾期任务在卡片第一行右侧显示红色逾期徽章(`overdue-badge`),与 deadline 行的红色文字形成双重提醒。
### 证据
WXSS 中的 deadline 颜色定义:
```css
.deadline-text--muted { color: #a6a6a6; }
.deadline-text--normal { color: #5e5e5e; }
.deadline-text--warning { color: #ed7b2f; }
.deadline-text--danger { color: #e34d59; font-weight: 600; }
```
WXML 中的 deadline 展示逻辑:
```html
<!-- 逾期徽章danger 级别显示在第一行) -->
<text class="overdue-badge" wx:if="{{item.deadlineStyle === 'danger'}}">{{item.deadlineLabel}}</text>
<!-- 非逾期的 deadline 显示在独立行 -->
<view class="card-row-deadline" wx:if="{{item.deadlineLabel && item.deadlineLabel !== '--' && item.deadlineStyle !== 'danger'}}">
<text class="deadline-text deadline-text--{{item.deadlineStyle}}">{{item.deadlineLabel}}</text>
</view>
```
### 建议(如未完全解决)
- 当前实现已覆盖 P6 定义的核心需求。如需更细粒度的颜色变化(如 3 天内黄色、1 天内橙色),可在 `formatDeadline()` 中增加判断分支

View File

@@ -0,0 +1,81 @@
# P6→NS1/RNS1 缺失项 #9备注输入框的字数限制和实时计数
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 已实现字数限制maxlength=500但缺少实时字数计数展示如「128/500」
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/components/note-modal/note-modal.wxml` — 备注弹窗模板
- `apps/miniprogram/miniprogram/components/note-modal/note-modal.ts` — 备注弹窗逻辑
- `apps/miniprogram/miniprogram/components/abandon-modal/abandon-modal.wxml` — 放弃弹窗(对比)
### 发现
**字数限制(已实现):**
note-modal 的 textarea 设置了 `maxlength="500"`
```html
<textarea
class="note-textarea"
placeholder="请输入备注内容..."
maxlength="500"
...
/>
```
abandon-modal 的 textarea 设置了 `maxlength="200"`
```html
<textarea
class="abandon-textarea"
placeholder="请输入放弃原因(必填)"
maxlength="200"
...
/>
```
**实时字数计数(未实现):**
两个弹窗均未显示当前输入字数和剩余字数。P6 定义了实时计数展示如输入框右下角显示「128/500」当前实现中
- `note-modal.ts``onContentInput` 仅更新 `content` 值,未计算或展示字数
- WXML 中无字数计数的 `<text>` 元素
- WXSS 中无字数计数的样式定义
### 证据
note-modal.ts 中的输入处理(无字数计数):
```typescript
onContentInput(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
this.setData({ content: e.detail.value })
}
```
note-modal.wxml 中 textarea 区域(无计数展示):
```html
<view class="textarea-section">
<textarea
class="note-textarea"
placeholder="请输入备注内容..."
value="{{content}}"
bindinput="onContentInput"
maxlength="500"
auto-height
...
/>
<!-- 此处缺少字数计数展示 -->
</view>
```
### 建议(如未完全解决)
1. 在 note-modal 的 textarea 下方添加字数计数:
```html
<text class="char-count">{{content.length}}/500</text>
```
2. 在 abandon-modal 的 textarea 下方添加字数计数:
```html
<text class="char-count">{{content.length}}/200</text>
```
3. 样式建议右对齐、小字号22rpx、灰色#a6a6a6接近限制时变橙/红色
4. 可在 `onContentInput` 中添加接近限制的提示逻辑(如剩余 20 字时变色)

View File

@@ -0,0 +1,43 @@
# P6→NS1/RNS1 缺失项 #10任务详情页各模块的折叠/展开默认状态
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟠 中
- 任务详情页所有模块(与我的关系、任务建议、维客线索、备注、服务记录)均为始终展开状态,无折叠/展开控制机制。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxss`
### 发现
1. task-detail.wxml 中所有 `.card` 区块(与我的关系、任务建议、维客线索、行动建议、备注、服务记录)均直接渲染,无 `wx:if` 条件控制折叠状态
2. task-detail.ts 的 `data` 中无任何 `collapsed`/`expanded`/`folded` 状态变量
3.`toggleSection`/`toggleCollapse` 等方法
4. 唯一的展开/收起逻辑是维客线索卡片的 `onToggleClue`(控制单条线索描述的展开),但这不是模块级折叠
### 证据
task-detail.wxml 中各模块均为直接渲染:
```xml
<!-- 与我的关系 -->
<view class="card">...</view>
<!-- 任务建议 -->
<view class="card">...</view>
<!-- 维客线索 -->
<view class="card">...</view>
<!-- 备注 -->
<view class="card">...</view>
<!-- 服务记录 -->
<view class="card">...</view>
```
无任何折叠/展开的 `wx:if``hidden` 控制。
task-detail.ts 中无折叠状态变量grep `collapsed|expanded|fold|toggleSection|toggleCollapse` 结果为空)。
### 建议
1. 为每个模块添加折叠状态变量(如 `sectionCollapsed: { relationship: false, suggestion: false, clues: false, notes: true, records: true }`
2.`.card-header` 上添加 `bindtap` 事件切换折叠状态
3. 建议默认展开前 3 个模块(关系、建议、线索),折叠后 2 个(备注、服务记录),减少首屏滚动长度
4. 添加折叠/展开的过渡动画(`max-height` + `transition`

View File

@@ -0,0 +1,51 @@
# P6→NS1/RNS1 缺失项 #11维客线索的展示样式
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟠 中(原始风险已消除)
- clue-card 组件已实现完整的 tag 颜色映射6 种 + 2 种别名)和卡片布局,样式覆盖 P6 定义的所有场景。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.wxml`
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.ts`
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.wxss`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`(调用处)
### 发现
1. clue-card 组件已实现,接受 `tag``category``emoji``title``source``content` 六个属性
2. wxss 中定义了 8 种 tag 颜色类,覆盖 VI 规范 2.1 六种客户标签配色:
- `clue-tag-primary`(客户基础 — 蓝色)
- `clue-tag-success`(消费习惯 — 绿色)
- `clue-tag-orange`(玩法偏好 — 橙色)
- `clue-tag-gold`(促销偏好 — 金色)
- `clue-tag-purple`(社交关系 — 紫色)
- `clue-tag-error`(重要反馈 — 红色)
- `clue-tag-pink`(社交关系别名)
- `clue-tag-warning`(促销偏好别名)
3. 卡片布局包含72rpx 方形 tag 图标 + 右侧内容区(标题+来源+描述)
4. task-detail.wxml 中通过 `<clue-card>` 组件渲染维客线索列表
### 证据
clue-card.wxss 中的颜色映射:
```css
/* VI 规范 2.1 六种客户标签配色 */
.clue-tag-primary { background: rgba(0, 82, 217, 0.10); color: #0052d9; }
.clue-tag-success { background: rgba(0, 168, 112, 0.10); color: #00a870; }
.clue-tag-orange { background: rgba(237, 123, 47, 0.12); color: #ed7b2f; }
.clue-tag-gold { background: rgba(251, 191, 36, 0.15); color: #d4920a; }
.clue-tag-purple { background: rgba(123, 97, 255, 0.10); color: #7b61ff; }
.clue-tag-error { background: rgba(227, 77, 89, 0.10); color: #e34d59; }
```
task-detail.wxml 调用处:
```xml
<clue-card wx:for="{{retentionClues}}" wx:key="index"
tag="{{item.tag}}" category="{{item.tagColor}}"
emoji="{{item.emoji}}" title="{{item.text}}"
source="{{item.source}}" content="{{item.desc || ''}}" />
```
### 建议
无需额外补充。

View File

@@ -0,0 +1,43 @@
# P6→NS1/RNS1 缺失项 #12任务列表页的搜索功能
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟠 中
- 前端无搜索组件,后端 TASK-1 API 不支持搜索参数,前端 services 层无搜索相关调用。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/backend/app/routers/xcx_tasks.py`
- `apps/backend/app/schemas/xcx_tasks.py`
- `apps/miniprogram/miniprogram/services/`grep 搜索)
### 发现
1. task-list.wxml 中无 `<t-search>` 或任何搜索输入组件
2. task-list.ts 中无搜索相关的 data 字段(如 `searchKeyword`)或方法(如 `onSearch`
3. 后端 `GET /api/xcx/tasks` 仅支持 `status``page``page_size` 三个查询参数,无 `keyword`/`search`/`query` 参数
4. services 目录下 grep `search|keyword|query` 结果为空
5. TaskListResponse schema 中无搜索相关字段
### 证据
后端路由签名:
```python
@router.get("", response_model=TaskListResponse)
async def get_tasks(
status: str = Query("pending", pattern="^(pending|completed|abandoned)$"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
):
```
`keyword``search` 参数。
task-list.wxml 中 banner 区域和任务列表区域之间无搜索栏。
### 建议
1. 前端:在 banner 下方、任务列表上方添加 `<t-search>` 组件,支持按客户名/手机号搜索
2. 后端:`GET /api/xcx/tasks` 添加可选参数 `keyword: str = Query(None)`,在 SQL 中对 `member_name``member_phone``ILIKE` 模糊匹配
3. 前端搜索应做防抖300ms避免频繁请求
4. 搜索结果为空时显示专用空状态("未找到匹配的客户"

View File

@@ -0,0 +1,51 @@
# P6→NS1/RNS1 缺失项 #13任务完成后的成功反馈动画
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 已有基础的 `wx.showToast` 反馈(备注保存、删除、放弃/取消放弃),但缺少 P6 定义的"任务完成"专属成功动画(如 Lottie 动画、全屏庆祝效果)。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxss`
### 发现
1. task-detail.ts 中存在多处 `wx.showToast` 调用,覆盖以下操作:
- 备注保存:`wx.showToast({ title: '备注已保存', icon: 'success' })`
- 备注删除:`wx.showToast({ title: '已删除', icon: 'success' })`
- 取消放弃:`wx.showToast({ title: '已取消放弃', icon: 'success' })`
- 放弃任务:`wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })`
- 手机号复制:`wx.showToast({ title: '手机号码已复制', icon: 'none' })`
2. 但无"任务完成"的专属操作入口和反馈动画
3. 无 Lottie 动画组件、无自定义成功动画 CSS、无全屏庆祝效果
4. 当前任务状态只有 `pending``abandoned`,缺少 `completed` 状态的处理流程
### 证据
task-detail.ts 中的 toast 调用(仅为操作反馈,非任务完成动画):
```typescript
// 备注保存
wx.showToast({ title: '备注已保存', icon: 'success' })
// 取消放弃
wx.showToast({ title: '已取消放弃', icon: 'success' })
// 放弃
wx.showToast({ title: '已放弃该客户的维护', icon: 'none' })
```
task-detail.wxml 底部操作栏只有"问问助手"和"备注"两个按钮,无"标记完成"按钮:
```xml
<view class="bottom-bar safe-area-bottom">
<view class="btn-ask" bindtap="onAskAssistant">...</view>
<view class="btn-note" bindtap="onAddNote">...</view>
</view>
```
### 建议
1. 在底部操作栏添加"标记完成"按钮(或在长按菜单中添加)
2. 任务完成后显示自定义成功动画(推荐方案):
- 方案 A全屏半透明遮罩 + CSS 动画(✓ 图标放大 + 文字淡入)
- 方案 B引入 Lottie 动画组件(`lottie-miniprogram`)播放庆祝动画
3. 动画播放完毕后自动返回任务列表页,并刷新列表数据
4. 后端需添加 `POST /api/xcx/tasks/{id}/complete` 接口

View File

@@ -0,0 +1,64 @@
# P6→NS1/RNS1 缺失项 #14网络异常时的离线提示和重试机制
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- request.ts 有基础的错误抛出和 401 自动刷新机制,页面级有错误状态和重试按钮,但缺少统一的网络异常拦截、离线检测提示和全局重试机制。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/utils/request.ts`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
### 发现
#### 已实现的部分
1. `request.ts``wxRequest``fail` 回调会 reject 错误(`statusCode: 0`),但未做网络类型判断
2. `request.ts` 实现了 401 自动刷新 token + 排队重试机制
3. task-list.wxml 有错误状态 UI + 重试按钮:
```xml
<view class="state-error" wx:if="{{pageState === 'error'}}">
<text class="error-text">加载失败,请重试</text>
<view class="retry-btn" bindtap="onRetry">重试</view>
</view>
```
4. task-detail.wxml 同样有错误状态 + 重试按钮
#### 缺失的部分
1. `request.ts` 中无 `wx.getNetworkType()` 离线检测
2. 无全局网络状态监听(`wx.onNetworkStatusChange`
3. 无统一的网络异常 toast 提示(如"网络连接失败,请检查网络"
4. 无请求超时配置wx.request 默认 60s
5. 无自动重试机制(非 401 场景的网络错误不会自动重试)
6. 无离线缓存策略(断网时无法展示上次加载的数据)
### 证据
request.ts 中 fail 回调仅简单 reject
```typescript
fail(err) {
reject({ statusCode: 0, data: err })
},
```
无网络类型判断、无离线提示、无重试逻辑。
task-list.ts 中 loadData 的 catch 仅设置错误状态:
```typescript
} catch {
this.setData({ pageState: 'error' })
}
```
无区分网络错误和服务端错误。
### 建议
1. `request.ts` 增强:
- 请求前调用 `wx.getNetworkType()` 检测网络状态,无网络时直接提示
- 添加请求超时配置(建议 15s
- 非 401 网络错误自动重试 1 次(指数退避)
- 统一的网络错误 toast`wx.showToast({ title: '网络连接失败', icon: 'none' })`
2. `app.ts` 中注册 `wx.onNetworkStatusChange` 全局监听,断网时显示顶部提示条
3. 页面级错误状态区分"网络错误"和"服务端错误",显示不同的提示文案
4. 可选:添加离线缓存(`wx.setStorageSync` 缓存上次成功的列表数据)

View File

@@ -0,0 +1,57 @@
# P6→NS1/RNS1 缺失项 #15任务卡片的长按/滑动操作
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟡 低
- 长按操作已完整实现(上下文菜单含置顶/备注/AI助手/放弃/取消放弃),但滑动操作(如滑动删除、滑动置顶)未实现。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxss`
### 发现
#### 已实现:长按操作
1. 所有任务卡片(置顶/一般/已放弃)均绑定了 `bindlongpress="onTaskLongPress"`
2. 长按后弹出上下文菜单(`.ctx-menu`),菜单项包括:
- 📌 置顶/取消置顶(`onCtxPin`
- 📝 备注(`onCtxNote`
- 🤖 问问AI助手`onCtxAI`
- 🗑️ 放弃任务(`onCtxAbandon`
- ↩️ 取消放弃(已放弃任务专属,`onCtxCancelAbandon`
3. 菜单定位跟随手指触摸位置,有边界检测防止溢出屏幕
#### 未实现:滑动操作
1.`bindtouchmove`/`bindtouchstart`/`bindtouchend` 事件
2. 无滑动删除swipe-to-deleteUI
3. 无滑动置顶交互
4.`<t-swipe-cell>` 组件使用
### 证据
task-list.wxml 中卡片事件绑定(有 longpress无 touchmove
```xml
<view class="task-card ..."
bindtap="onTaskTap" bindlongpress="onTaskLongPress">
```
task-list.ts 中长按处理完整实现:
```typescript
onTaskLongPress(e: WechatMiniprogram.TouchEvent) {
this._longPressed = true
// ... 获取目标任务、计算菜单位置、显示上下文菜单
this.setData({
contextMenuVisible: true,
contextMenuX: x, contextMenuY: y,
contextMenuTarget: target,
})
}
```
### 建议
1. 考虑是否真正需要滑动操作 — 长按菜单已覆盖所有操作场景,滑动操作可能增加交互复杂度
2. 如需实现,推荐使用 TDesign 的 `<t-swipe-cell>` 组件包裹任务卡片
3. 滑动操作建议仅暴露最常用的 1-2 个操作(如置顶、放弃),避免操作过载
4. 优先级较低,可作为后续体验优化迭代

View File

@@ -0,0 +1,58 @@
# P6→NS1/RNS1 缺失项 #16页面切换时的转场动画规范
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 无自定义转场动画配置完全依赖微信小程序默认的页面切换动画。router.ts 仅封装了 wx.navigateTo/switchTab/navigateBack无动画参数。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/utils/router.ts`
- `apps/miniprogram/miniprogram/app.json`window 配置)
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`(页面跳转调用)
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`(页面跳转调用)
### 发现
1. `router.ts` 仅封装了三个基础路由方法(`navigateTo``switchTab``navigateBack`),无 `routeType`/`animationType`/`animationDuration` 参数
2. `app.json``window` 配置仅设置了导航栏样式,无 `pageOrientation``animationType` 等动画配置
3. 页面跳转直接调用 `wx.navigateTo({ url: ... })`,未使用 `routeType` 参数
4. 无页面进入/退出的自定义 CSS 动画
5.`wx.navigateTo``routeType` 参数(微信基础库 2.29.2+ 支持)
### 证据
router.ts 完整内容(无动画配置):
```typescript
export function navigateTo(url: string): void {
wx.navigateTo({ url })
}
export function switchTab(url: string): void {
wx.switchTab({ url })
}
export function navigateBack(delta: number = 1): void {
wx.navigateBack({ delta })
}
```
app.json window 配置(无动画相关字段):
```json
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "球房运营助手",
"navigationBarBackgroundColor": "#ffffff"
}
```
task-list.ts 中直接调用 wx.navigateTo
```typescript
wx.navigateTo({
url: `${DETAIL_ROUTE}?id=${id}`,
fail: () => wx.showToast({ title: '页面跳转失败', icon: 'none' }),
})
```
### 建议
1. 微信小程序默认转场动画(从右滑入/滑出)已满足基本体验,此项优先级较低
2. 如需自定义,可在 `router.ts``navigateTo` 中添加 `routeType` 参数(需基础库 2.29.2+
3. 可选方案:页面 `onLoad` 时添加入场 CSS 动画opacity + translateY 渐入),提升视觉流畅感
4. 建议作为 P13前端打磨的后续迭代项

View File

@@ -0,0 +1,58 @@
# P6→NS1/RNS1 缺失项 #17任务列表的批量操作
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 前端无多选模式、无批量操作 UI后端无批量操作接口。所有操作均为单任务粒度。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`
- `apps/backend/app/routers/xcx_tasks.py`
### 发现
#### 前端
1. task-list.wxml 中无 checkbox/多选组件
2. task-list.ts 中无 `selectedTasks`/`isMultiSelect`/`batchMode` 等状态变量
3. 无"全选"/"批量标记完成"/"批量放弃"等操作按钮
4. 无编辑模式切换入口(如顶部"编辑"按钮)
#### 后端
1. `xcx_tasks.py` 中所有操作接口均为单任务粒度:
- `POST /{task_id}/pin`
- `POST /{task_id}/unpin`
- `POST /{task_id}/abandon`
- `POST /{task_id}/restore`
2. 无批量操作接口(如 `POST /batch/pin``POST /batch/abandon`
### 证据
后端路由清单(全部为单任务操作):
```python
@router.post("/{task_id}/pin") # 单个置顶
@router.post("/{task_id}/unpin") # 单个取消置顶
@router.post("/{task_id}/abandon") # 单个放弃
@router.post("/{task_id}/restore") # 单个恢复
```
task-list.ts data 中无批量相关字段:
```typescript
data: {
pageState: 'loading',
pinnedTasks: [],
normalTasks: [],
abandonedTasks: [],
taskCount: 0,
// ... 无 selectedTasks、batchMode 等
}
```
### 建议
1. 此功能优先级较低 — 当前任务列表规模(每日 10-30 条)下,单任务操作已满足需求
2. 如需实现,建议分步:
- 第一步:前端添加编辑模式(长按进入多选 → 底部浮出批量操作栏)
- 第二步:后端添加批量接口 `POST /api/xcx/tasks/batch` 接受 `task_ids` 数组 + `action` 枚举
3. 批量操作建议限制:单次最多选择 20 条,防止误操作
4. 建议在用户反馈确认需求后再实现,避免过度设计

View File

@@ -0,0 +1,62 @@
# P6→NS1/RNS1 缺失项 #18无障碍适配
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 项目自有页面task-list、task-detail 及自定义组件)中无任何 `aria-label``aria-role` 等无障碍属性。仅 TDesign 组件库内部自带了部分无障碍支持。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/task-list/task-list.wxml`
- `apps/miniprogram/miniprogram/pages/task-detail/task-detail.wxml`
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.wxml`
- `apps/miniprogram/miniprogram/components/` 下所有自定义组件
- `apps/miniprogram/miniprogram/miniprogram_npm/tdesign-miniprogram/`(对比参考)
### 发现
1. 全局 grep `aria-label|aria-role|aria-hidden|role=` 在项目自有 wxml 文件中结果为零
2. 仅 TDesign 组件库(`miniprogram_npm/tdesign-miniprogram/`)内部使用了无障碍属性:
- `swiper-nav.wxml``aria-role="button" aria-label="上一张/下一张"`
- `tabs.wxml``aria-role="tablist"`
- `upload.wxml``aria-role="presentation"` + 动态 `aria-label`
3. task-list.wxml 中的交互元素缺少无障碍标注:
- 任务卡片无 `aria-role="button"``aria-label`
- 重试按钮无 `aria-role="button"`
- 上下文菜单项无 `aria-label`
4. task-detail.wxml 中同样缺失:
- 底部操作栏按钮无 `aria-label`
- 手机号查看/复制按钮无 `aria-label`
- 话术复制按钮无 `aria-label`
### 证据
task-list.wxml 中任务卡片(无无障碍属性):
```xml
<view class="task-card ..."
hover-class="task-card--hover" hover-stay-time="100"
data-id="{{item.id}}" data-tasktype="{{item.taskType}}"
bindtap="onTaskTap" bindlongpress="onTaskLongPress">
```
task-detail.wxml 中底部操作栏(无无障碍属性):
```xml
<view class="btn-ask" bindtap="onAskAssistant" hover-class="btn-ask--hover">
<t-icon name="chat" size="36rpx" color="#ffffff" />
<text class="btn-text">问问助手</text>
</view>
```
对比 TDesign 组件(有无障碍属性):
```xml
<view ... aria-role="button" aria-label="上一张"/>
```
### 建议
1. 为所有可交互元素添加 `aria-role``aria-label`
- 任务卡片:`aria-role="button" aria-label="{{item.customerName}} {{item.taskTypeLabel}}"`
- 操作按钮:`aria-role="button" aria-label="问问助手"` / `aria-label="添加备注"`
- 上下文菜单项:`aria-role="menuitem" aria-label="置顶"`
2. 为非交互的装饰性元素添加 `aria-hidden="true"`(如 banner 背景图、调试面板)
3. 确保焦点顺序合理banner → 任务列表 → 底部操作栏
4. 建议创建一个无障碍适配 checklist在后续页面开发中统一执行
5. 优先级较低,可作为 P13 前端打磨的后续迭代

View File

@@ -0,0 +1,57 @@
# P7→NS1/RNS1 缺失项 #1营业日 08:00 分割点的完整处理规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- ETL 层已通过 `biz_date_sql_expr()` 统一实现 08:00 营业日分割DWS 表中的 biz_date/stat_date 字段已按此规则生成;后端查询使用 `create_time` 按自然月过滤(非 biz_date但因 DWS 层已预聚合,实际数据口径一致。
## 详细审查
### 审查范围
- `packages/shared/src/neozqyy_shared/datetime_utils.py``biz_date_sql_expr()` 函数
- `apps/etl/connectors/feiqiu/tasks/dws/assistant_customer_task.py` — DWS 客户统计 ETL
- `apps/etl/connectors/feiqiu/tasks/dws/member_visit_task.py` — 会员到店 ETL
- `apps/etl/connectors/feiqiu/tasks/dws/finance_discount_task.py` — 财务折扣 ETL
- `apps/backend/app/services/fdw_queries.py` — 后端 FDW 查询
- `apps/backend/app/services/performance_service.py` — 绩效服务
### 发现
1. **ETL 层已完整实现 08:00 分割**
- `biz_date_sql_expr(col, day_start_hour=8)` 生成 `DATE(col - INTERVAL '8 hours')` SQL 表达式
- 所有 DWS 任务assistant_customer_task、member_visit_task、finance_discount_task、goods_stock_daily_task、assistant_project_tag_task均调用此函数
- `cutoff` 值从配置 `app.business_day_start_hour` 读取,默认 8
2. **Python 层也有对应函数**
- `business_date(dt, day_start_hour=8)` — 将时间戳归属到营业日
- `business_month(dt, day_start_hour=8)` — 将时间戳归属到营业月
- `business_day_range(biz_date)` — 返回营业日精确时间戳范围 `[当天08:00, 次日08:00)`
3. **后端查询层**
- `get_service_records()` 使用 `create_time >= start_date AND create_time < end_date` 按自然月过滤
- `get_salary_calc()` 使用 `salary_month` 字段DWS 预聚合,已按营业日口径)
- 服务记录明细查询按自然月时间戳过滤,与 P7 定义的"当月1日 08:00 ~ 次月1日 08:00"存在微小差异(差 8 小时),但实际影响极小
### 证据
```python
# packages/shared/src/neozqyy_shared/datetime_utils.py
def biz_date_sql_expr(col: str, day_start_hour: int = 8) -> str:
return f"DATE({col} - INTERVAL '{day_start_hour} hours')"
# assistant_customer_task.py — DWS 层使用
cutoff = self.config.get("app.business_day_start_hour", 8)
biz_expr = biz_date_sql_expr("start_use_time", cutoff)
# → DATE(start_use_time - INTERVAL '8 hours') AS service_date
```
```python
# fdw_queries.py — 后端查询(按自然月,非 biz_date
start_date = f"{year}-{month:02d}-01"
end_date = f"{year}-{month + 1:02d}-01"
# WHERE sl.create_time >= start_date AND sl.create_time < end_date
```
### 建议(微调项)
- 后端 `get_service_records()` 的月份过滤可考虑使用 `business_month_range()` 生成 `[当月1日 08:00, 次月1日 08:00)` 范围,与 ETL 层 biz_date 口径完全对齐
- 当前差异仅影响每月 1 日 00:00-08:00 之间的少量记录归属,风险极低

View File

@@ -0,0 +1,63 @@
# P7→NS1/RNS1 缺失项 #2"预估"标记的判断逻辑
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 前端已实现"当月 = 预估"的判断逻辑并展示预估标签,但后端 `is_estimate` 字段硬编码为 `False`未实现真正的预估判断。当前方案是纯前端判断year/month == 当前年月),未考虑 ETL 数据更新延迟等场景。
## 详细审查
### 审查范围
- `apps/backend/app/services/fdw_queries.py``get_service_records()``is_estimate` 字段
- `apps/backend/app/schemas/xcx_performance.py` — PERF-1/PERF-2 响应 schema
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 预估标签展示
- `apps/miniprogram/miniprogram/pages/performance/performance.ts``isCurrentMonth` 判断
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 预估标签展示
### 发现
1. **后端 `is_estimate` 硬编码为 `False`**
- `fdw_queries.get_service_records()` 第 405 行注释明确写道:`# is_estimate 不存在于视图中,默认 False`
- `PerformanceOverviewResponse` schema 中没有 `is_estimate` 字段
- `RecordsSummary` schema 中也没有 `is_estimate` 字段
2. **前端使用纯客户端判断**
- `performance.ts` 中:`const isCurrentMonth = year === nowYear && month === nowMonth`
- `performance-records.ts` 中:同样的 `isCurrentMonth` 判断
- WXML 中根据 `isCurrentMonth` 展示"预估"标签和"我的预估收入"文案
3. **前端展示已到位**
- `performance.wxml``<text wx:if="{{isCurrentMonth}}" class="estimate-tag">预估</text>`
- 收入标签:`{{isCurrentMonth ? '我的预估收入' : '我的收入'}}`
- 合计标签:`本月合计<text wx:if="{{isCurrentMonth}}"> 预估</text>`
- `performance-records.wxml`:统计概览中 `<text class="stat-hint" wx:if="{{isCurrentMonth}}">预估</text>`
### 证据
```python
# fdw_queries.py — is_estimate 硬编码
records.append({
...
"income": float(row[10]) if row[10] is not None else 0.0,
# is_estimate 不存在于视图中,默认 False
"is_estimate": False,
})
```
```typescript
// performance.ts — 纯前端判断
const isCurrentMonth = year === nowYear && month === nowMonth
```
```xml
<!-- performance.wxml — 预估标签展示 -->
<text wx:if="{{isCurrentMonth}}" class="estimate-tag">预估</text>
<text class="income-label">{{isCurrentMonth ? '我的预估收入' : '我的收入'}}</text>
```
### 建议
1. **明确"预估"的业务定义**P7 AC7 要求"当月数据显示预估标记",当前前端的"当月 = 预估"实现基本满足此需求,但需确认:
- 是否所有当月数据都算预估?还是仅未结算的记录?
- 月末 ETL 完成最终计算后,当月数据是否仍标记为预估?
2. **后端应提供 `is_estimate` 字段**:即使当前逻辑是"当月 = 预估",也应由后端返回此标记,避免前后端判断逻辑不一致
3. **单条记录级别的预估标记**`is_estimate` 字段已在 `xcx_tasks.py``xcx_customers.py` 的 schema 中定义,但在绩效 schema 中缺失

View File

@@ -0,0 +1,73 @@
# P7→NS1/RNS1 缺失项 #3定档折算惩罚的展示格式
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 后端已返回 `service_hours`(折算后)和 `service_hours_raw`(折算前)两个字段,前端 performance-records 页面已实现折前/折后对比展示DISPLAY-STANDARDS.md 已定义课时展示规范。但 P7 AC6 要求的"120分钟定档折算30分钟"格式未被采用,实际使用的是"2.0h(折后 2.5h"格式,且 performance 概览页未展示折算信息。
## 详细审查
### 审查范围
- `apps/backend/app/services/performance_service.py``compute_summary()``group_records_by_date()`
- `apps/backend/app/schemas/xcx_performance.py``RecordsSummary` 中的 `total_hours_raw`
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 折算展示
- `docs/miniprogram-dev/design-system/DISPLAY-STANDARDS.md` — §2 课时展示规范
### 发现
1. **后端数据已完整**
- `fdw_queries.get_service_records()` 返回 `service_hours``income_seconds / 3600.0`)和 `service_hours_raw``real_use_seconds / 3600.0`
- `compute_summary()` 计算 `total_hours``total_hours_raw`
- `RecordsSummary` schema 包含 `total_hours: float``total_hours_raw: float`
2. **performance-records 页面已实现折算展示**
- WXML 中:`<text class="record-hours-deduct" wx:if="{{rec.hoursRaw && rec.hoursRaw !== rec.hours}}">折前 {{fmt.hours(rec.hoursRaw)}}</text>`
- 统计概览中:`<text class="stat-hours-raw" wx:if="{{totalHoursRawLabel}}">折前 {{totalHoursRawLabel}}</text>`
- 格式为"折前 Nh"而非 P7 要求的"120分钟定档折算30分钟"
3. **DISPLAY-STANDARDS.md 已定义规范**
- §2.1 规则总表:`带折算备注 | 实际h折后 原始h | 2.0h(折后 2.5h`
- §2.3 折算标注字段约定:`hours`(折算后)、`hoursRaw`(折算前),仅当两者不同时展示括号备注
4. **performance 概览页未展示折算**
- `group_records_by_date()` 中的 `record_item` 只有 `hours` 字段,没有 `hoursRaw`
- 概览页 WXML 中服务记录只展示 `rec.hours`,无折算信息
5. **格式差异**
- P7 AC6 要求:"120分钟定档折算30分钟"(分钟单位,括号内说明折算量)
- 实际实现:"2.0h(折后 2.5h"(小时单位,括号内展示折前原始值)
- performance-records 实际使用:"折前 2.5h"(无括号,前缀"折前"
### 证据
```python
# performance_service.py — compute_summary 包含 total_hours_raw
def compute_summary(records: list[dict]) -> dict:
total_hours = sum(r.get("service_hours", 0.0) for r in records)
total_hours_raw = sum(r.get("service_hours_raw", 0.0) for r in records)
return {
"total_count": len(records),
"total_hours": round(total_hours, 2),
"total_hours_raw": round(total_hours_raw, 2),
"total_income": round(total_income, 2),
}
```
```xml
<!-- performance-records.wxml — 折算展示 -->
<text class="record-hours">{{fmt.hours(rec.hours)}}</text>
<text class="record-hours-deduct"
wx:if="{{rec.hoursRaw && rec.hoursRaw !== rec.hours}}">
折前 {{fmt.hours(rec.hoursRaw)}}
</text>
```
```markdown
<!-- DISPLAY-STANDARDS.md §2.1 -->
| 带折算备注 | `实际h折后 原始h` | `2.0h(折后 2.5h` |
```
### 建议
1. **统一展示格式**:当前 performance-records 页面使用"折前 Xh"格式,与 DISPLAY-STANDARDS.md 定义的"实际h折后 原始h"格式不一致,需统一
2. **确认是否采用 P7 的分钟格式**P7 要求"120分钟定档折算30分钟",但设计规范和实际实现均使用小时单位,需与产品确认最终格式
3. **performance 概览页补充折算信息**`group_records_by_date()` 应在 `record_item` 中加入 `hours_raw` 字段

View File

@@ -0,0 +1,81 @@
# P7→NS1/RNS1 缺失项 #4"我的新客"筛选逻辑的完整定义
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 后端已实现新客筛选逻辑,但采用的是"本月有服务 + 历史无记录"的简化定义,与 P7 AC3 定义的"首次服务 + 2月内 + 服务次数≤2"条件不完全一致。未使用 `dws_assistant_customer_stats` 表(该表已在 ETL 层建好),而是直接查询 DWD 层视图。
## 详细审查
### 审查范围
- `apps/backend/app/services/performance_service.py``_build_customer_lists()` 函数
- `apps/backend/app/services/fdw_queries.py` — FDW 查询
- `apps/etl/connectors/feiqiu/tasks/dws/assistant_customer_task.py` — DWS 客户统计 ETL
- `docs/database/ddl/etl_feiqiu__dws.sql``dws_assistant_customer_stats` 表结构
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 新客列表展示
### 发现
1. **后端实现的新客定义**
- `_build_customer_lists()` 中新客判断:`if mid not in historical_members`
- 历史查询:`WHERE create_time < 本月1日 AND tenant_member_id = ANY(本月服务过的会员)`
- 即:**本月有服务记录 + 本月之前从未有过服务记录** = 新客
- 未检查"2月内"和"服务次数≤2"条件
2. **P7 AC3 的完整定义**
- 首次服务first_service_date 在本月)
- 2月内首次服务距今不超过 2 个月)
- 服务次数 ≤ 2
3. **DWS 层已有更完整的数据**
- `dws_assistant_customer_stats` 表包含 `first_service_date``last_service_date``total_service_count` 等字段
- ETL 任务 `AssistantCustomerTask` 已按 `biz_date_sql_expr` 计算营业日归属
-`app.v_dws_assistant_customer_stats` RLS 视图可供后端查询
- 但后端 `fdw_queries.py` 中未使用此视图
4. **前端展示已到位**
- 新客列表展示:姓名、头像、最近服务日期、服务次数
- `<text class="customer-detail">最近服务: {{item.lastService}} · {{item.count}}次</text>`
### 证据
```python
# performance_service.py — 新客判断逻辑
# 查询历史记录(本月之前是否有服务记录)
try:
start_date = f"{year}-{month:02d}-01"
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute("""
SELECT DISTINCT tenant_member_id
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
AND is_delete = 0
AND create_time < %s::timestamptz
AND tenant_member_id = ANY(%s)
""", (assistant_id, start_date, member_ids))
for row in cur.fetchall():
historical_members.add(row[0])
# 新客:历史无记录
if mid not in historical_members:
new_customers.append({...})
```
```sql
-- dws_assistant_customer_stats 表结构(已存在但未被后端使用)
CREATE TABLE dws.dws_assistant_customer_stats (
id bigint NOT NULL,
site_id bigint NOT NULL,
tenant_id bigint NOT NULL,
-- ... 包含 first_service_date, last_service_date, total_service_count 等
-- 唯一约束: (site_id, assistant_id, member_id, stat_date)
);
```
### 建议
1. **对齐 P7 AC3 的完整新客定义**:当前"历史无记录"的判断过于宽松,应补充:
- `first_service_date` 在本月范围内
- `total_service_count <= 2`(或根据业务确认阈值)
- "2月内"条件在当前"当月查询"场景下自然满足,但跨月查看时需考虑
2. **使用 `dws_assistant_customer_stats` 表**:该表已有 `first_service_date``total_service_count` 等预聚合字段,比直接查 DWD 层更高效且口径更准确
3. **确认新客定义的业务边界**:与产品确认"首次服务"是指该助教的首次服务还是全店首次服务

View File

@@ -0,0 +1,74 @@
# P7→NS1/RNS1 缺失项 #5"我的常客"的展示字段
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 后端已在 PERF-1 响应中返回常客列表,包含 P7 AC4 要求的次数、小时数、收入合计三个核心字段。前端已完整展示。Schema 中 `RegularCustomer` 模型字段齐全。
## 详细审查
### 审查范围
- `apps/backend/app/services/performance_service.py``_build_customer_lists()` 常客构建逻辑
- `apps/backend/app/schemas/xcx_performance.py``RegularCustomer` 模型
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 常客列表展示
### 发现
1. **后端常客字段完整**
- `_build_customer_lists()` 中常客判断:`if stats["count"] >= 2`(本月服务次数 ≥ 2
- 返回字段:`name``avatar_char``avatar_color``hours`(总小时数)、`income`(收入合计,格式 `¥N,NNN.NN`)、`count`(服务次数)
- 按收入倒序排列
2. **Schema 定义完整**
- `RegularCustomer(CustomerSummary)` 包含:`hours: float``income: str``count: int`
- `PerformanceOverviewResponse` 包含 `regular_customers: list[RegularCustomer]`
3. **前端展示完整**
- WXML`<text class="customer-detail">{{item.count}}次 · {{fmt.hours(item.hours)}} · {{fmt.safe(item.income)}}</text>`
- 展示格式:`3次 · 4.5h · ¥1,200`
- 支持展开/收起(默认显示 5 条,可展开至 20 条)
4. **P7 AC4 要求对照**
- ✅ 次数 → `count` 字段
- ✅ 小时数 → `hours` 字段
- ✅ 工资合计 → `income` 字段(注:实际为收入合计,非"工资",语义更准确)
### 证据
```python
# performance_service.py — 常客构建
if stats["count"] >= 2:
regular_customers.append({
"name": name,
"avatar_char": char,
"avatar_color": color,
"hours": round(stats["total_hours"], 2),
"income": f"¥{stats['total_income']:,.2f}",
"count": stats["count"],
})
# 按收入倒序
regular_customers.sort(
key=lambda x: float(x.get("income", "¥0").replace("¥", "").replace(",", "")),
reverse=True,
)
```
```python
# xcx_performance.py — Schema
class RegularCustomer(CustomerSummary):
"""常客。"""
hours: float
income: str
count: int
```
```xml
<!-- performance.wxml — 常客展示 -->
<text class="customer-detail">
{{item.count}}次 · {{fmt.hours(item.hours)}} · {{fmt.safe(item.income)}}
</text>
```
### 建议(微调项)
- 常客阈值 `count >= 2` 当前硬编码,可考虑从配置表读取(遵循 feiqiu-data-rules 规则 6
- `income` 字段在后端格式化为字符串(`¥N,NNN.NN`),与 DISPLAY-STANDARDS.md 的金额规范(`¥N,NNN` 无小数)略有差异,建议统一

View File

@@ -0,0 +1,106 @@
# P7→NS1/RNS1 缺失项 #6收入与业绩档位卡片的视觉设计
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 前端已完整实现收入卡片、档位卡片(当前/下一阶段)、升级提示、进度条组件的视觉设计。`perf-progress-bar``metric-card` 组件均已开发完成WXSS 中包含完整的渐变、动画、布局样式。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 收入卡片和档位卡片布局
- `apps/miniprogram/miniprogram/pages/performance/performance.wxss` — 视觉样式
- `apps/miniprogram/miniprogram/components/perf-progress-bar/` — 进度条组件
- `apps/miniprogram/miniprogram/components/metric-card/` — 指标卡片组件
### 发现
1. **收入概览卡片**Banner 区域):
- 双卡片布局:`income-overview` flex 容器,两个 `income-card`
- 左卡片:"我的预估收入"/"我的收入" + 金额44rpx 粗体白色)
- 右卡片:"上月收入" + 金额(绿色高亮 `#a7f3d0`
- 毛玻璃效果:`backdrop-filter: blur(4px)`,半透明白色背景
- SVG 渐变底图:`banner-bg-blue-light-aurora.svg`
2. **档位卡片**(收入情况 Section
- 当前档位:绿色渐变背景(`#f0fdf4 → #dcfce7`),绿色边框,绿色 badge
- 下一阶段:黄色渐变背景(`#fefce8 → #fef9c3`),黄色边框,黄色 badge
- 每个档位卡片展示emoji 图标 + 档位标签 + 基础课费率 + 激励课费率
- 费率展示:`{rate}元/h` 格式,带分隔线
3. **升级提示卡片**
- 蓝色渐变背景(`#eff6ff → #eef2ff`
- 左侧:⏱️ emoji + "距离下一阶段" + "需完成 X 小时"
- 右侧:橙色渐变按钮 "到达即得 X元"
4. **perf-progress-bar 组件**
- 渐变填充条 + 高光动画 + 导火索火星效果
- 支持动态刻度(`ticks` 数组从接口传入,不硬编码)
- 档位高亮:`currentTier` 控制刻度高亮状态
- 动画:`shine`(高光扫过)+ `spark`火花粒子6 个粒子)
5. **metric-card 组件**
- 通用指标卡片:标题 + 数值 + 单位 + 趋势标签
- 趋势支持up绿色箭头/ down红色箭头/ flat横线
- 帮助图标:可选 `helpText`,点击触发 `helpTap` 事件
- 空值处理:`null/undefined → '--'`
6. **前端仍使用 mock 数据**
- `performance.ts``loadData()` 使用 `setTimeout` + 空骨架数据
- 注释标注 `// TODO: 替换为真实 API — 已清空为骨架项`
- 视觉组件和布局已完成,但未接入真实 API
### 证据
```xml
<!-- performance.wxml — 档位卡片 -->
<view class="tier-card tier-current">
<view class="tier-badge badge-current">当前档位</view>
<view class="tier-row">
<view class="tier-icon-label">
<text class="tier-emoji">📊</text>
<text class="tier-label tier-label-green">当前档位</text>
</view>
<view class="tier-rates">
<view class="rate-item">
<text class="rate-value rate-green">{{currentTier.basicRate}}</text>
<text class="rate-unit rate-green-light">元/h</text>
<text class="rate-desc rate-green-light">基础课到手</text>
</view>
<!-- ... 激励课费率 ... -->
</view>
</view>
</view>
```
```css
/* performance.wxss — 档位卡片样式 */
.tier-current {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 2rpx solid #86efac;
}
.tier-next {
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
border: 2rpx solid #fde047;
}
.badge-current {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
```
```typescript
// perf-progress-bar — 组件属性
properties: {
filledPct: { type: Number, value: 0 }, // 进度百分比
clampedSparkPct: { type: Number, value: 0 }, // 火星位置
currentTier: { type: Number, value: 0 }, // 当前档位
ticks: { type: Array, value: [] }, // 刻度数组(接口传入)
shineRunning: { type: Boolean, value: false },
sparkRunning: { type: Boolean, value: false },
}
```
### 建议(微调项)
- performance 页面尚未使用 `perf-progress-bar` 组件WXML 中未引用),需在联调时集成
- performance 页面尚未使用 `metric-card` 组件,当前收入展示是内联实现
- 前端 mock 数据需替换为真实 API 调用(已有 TODO 标注)

View File

@@ -0,0 +1,58 @@
# P7→NS1/RNS1 缺失项 #7服务记录按天归总的展示格式
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 后端 DateGroup 结构完整,日期标签格式为 `M月D日`(如"3月15日"但缺少星期信息P7 定义的"3月15日 周五"格式);前端直接透传后端 `date` 字段(`YYYY-MM-DD` 格式),未做二次格式化。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_performance.py` — DateGroup / DateGroupRecord 模型
- `apps/backend/app/services/performance_service.py``group_records_by_date()``_format_date_label()`
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 日期标签渲染
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 日期标签渲染
- `docs/miniprogram-dev/design-system/DATETIME-DISPLAY-STANDARD.md` — 日期展示规范
### 发现
1. **后端 DateGroup 结构已定义且完整**`DateGroup` 包含 `date``total_hours``total_income``records` 四个字段,`DateGroupRecord` 包含客户名、时间范围、课时、课程类型、地点、收入等完整字段。
2. **后端 `_format_date_label()` 格式为 `M月D日`**:该函数输出如 `3月15日`,但不包含星期信息。然而实际上 `group_records_by_date()``date` 字段使用的是 `YYYY-MM-DD` 格式的 `date_key`,并未调用 `_format_date_label()`
3. **`_format_date_label()` 未被 `group_records_by_date()` 使用**`group_records_by_date()` 直接将 `date_key``YYYY-MM-DD`)赋值给 `date` 字段,`_format_date_label()` 函数虽然存在但未在 DateGroup 构建中被调用。
4. **前端直接渲染后端返回的 `date` 字段**WXML 中 `{{item.date}}` 直接展示,未做格式化。因此用户看到的是 `2026-03-15` 而非 `3月15日 周五`
5. **设计规范文档 DATETIME-DISPLAY-STANDARD.md 定义的是相对时间规范**(刚刚/N分钟前/N天前/日期),不涉及"M月D日 周X"这种绝对日期+星期的格式。
### 证据
后端 `group_records_by_date()` 中日期赋值performance_service.py:130
```python
result.append({
"date": date_key, # date_key = settle_time.strftime("%Y-%m-%d")
"total_hours": f"{total_hours:g}",
"total_income": f"{total_income:.2f}",
"records": recs,
})
```
后端 `_format_date_label()` 存在但未被 DateGroup 使用performance_service.py:188
```python
def _format_date_label(dt) -> str:
"""格式化日期为 "M月D日" 格式。"""
if hasattr(dt, "strftime"):
return f"{dt.month}{dt.day}"
```
前端直接渲染performance-records.wxml
```xml
<text decode class="dd-date">{{item.date}}&nbsp;&nbsp;</text>
```
### 建议
1. **后端**:在 `group_records_by_date()` 中将 `date` 字段改为调用 `_format_date_label()` 并追加星期信息,格式为 `M月D日 周X`(如"3月15日 周五")。或新增 `date_label` 字段保留格式化后的展示文本,`date` 保留 `YYYY-MM-DD` 用于排序。
2. **前端**:如果后端不改,前端可在 WXS 中增加日期格式化函数,将 `YYYY-MM-DD` 转为 `M月D日 周X`
3. **设计规范**:在 DATETIME-DISPLAY-STANDARD.md 中补充"按天归总场景"的日期标签格式规范(`M月D日 周X`)。

View File

@@ -0,0 +1,73 @@
# P7→NS1/RNS1 缺失项 #8本月/上月切换的交互细节
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- performance-records 页面已实现月份切换(含 loading 状态和数据刷新),但 performance 主页面尚未实现月份切换功能(仅展示当月数据,使用 mock 骨架数据)。两个页面均无切换动画。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/performance/performance.ts` — 绩效主页面逻辑
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 绩效主页面模板
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts` — 业绩明细页逻辑
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 业绩明细页模板
- `apps/backend/app/routers/xcx_performance.py` — 后端路由参数
### 发现
1. **performance-records 页面月份切换已实现**
-`switchMonth()` 方法,支持 prev/next 方向切换
-`canGoPrev`/`canGoNext` 状态控制按钮可用性
- 切换后调用 `loadData()` 重新请求数据
-`pageState: 'loading'` 状态展示 loading 浮层
- 不能超过当前月(`canGoNext` 逻辑正确)
2. **performance 主页面未实现月份切换**
- `loadData()` 中硬编码 `year = nowYear, month = nowMonth`,仅展示当月
- WXML 中无月份切换 UI 组件
- 数据仍使用 `setTimeout` + mock 骨架数据,未接入真实 API
-`TODO: 联调时从接口参数或页面参数获取 year/month` 注释
3. **后端 API 已支持月份参数**PERF-1 和 PERF-2 均接受 `year`/`month` 查询参数。
4. **无切换动画**:两个页面的月份切换均为即时替换,无过渡动画效果。
### 证据
performance.ts 中硬编码当月Line 87-89
```typescript
// TODO: 联调时从接口参数或页面参数获取 year/month
const year = nowYear
const month = nowMonth
```
performance-records.ts 中月份切换实现switchMonth 方法):
```typescript
switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
// ... 月份加减逻辑 ...
this.setData({ currentYear, currentMonth, monthLabel, canGoNext, canGoPrev, isCurrentMonth })
this.loadData()
}
```
performance-records.wxml 中月份切换 UI
```xml
<view class="month-switcher">
<view class="month-btn {{canGoPrev ? '' : 'month-btn-disabled'}}" data-direction="prev" bindtap="switchMonth">
<t-icon name="chevron-left" size="32rpx" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canGoNext ? '' : 'month-btn-disabled'}}" data-direction="next" bindtap="switchMonth">
<t-icon name="chevron-right" size="32rpx" />
</view>
</view>
```
### 建议
1. **performance 主页面**:完成 API 联调,移除 mock 数据,增加月份切换 UI 和逻辑(可复用 performance-records 的 `switchMonth` 模式)。
2. **切换动画**P7 AC2 提到的切换动画可作为低优先级优化,当前 loading 浮层已提供基本的状态反馈。
3. **数据刷新策略**:切换月份时已有 loading → 请求 → 渲染的完整流程,满足基本需求。可考虑增加本地缓存避免重复请求已加载过的月份。

View File

@@ -0,0 +1,61 @@
# P7→NS1/RNS1 缺失项 #9绩效页面的空状态
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- performance 主页面和 performance-records 页面均已实现空状态处理,包含空态图标、文案和错误重试。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/performance/performance.ts` — pageState 状态管理
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 空态 UI
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts` — pageState 状态管理
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 空态 UI
### 发现
1. **performance 主页面空状态已实现**
- `pageState` 支持 `'loading' | 'empty' | 'error' | 'normal'` 四种状态
- WXML 中有独立的空态区块:图标 `chart-bar` + 文案"暂无业绩数据"
- 有错误态区块:图标 `close-circle` + 文案"加载失败,请点击重试" + 重试按钮
2. **performance-records 页面空状态已实现**
- 同样支持四种 pageState
- 空态区块:图标 `chart-bar` + 文案"暂无数据"
- 错误态区块:图标 `close-circle` + 文案"加载失败,请点击重试" + 重试按钮
-`dateGroups.length === 0` 时自动切换到 `'empty'` 状态
3. **loading 态使用 toast 浮层**:不销毁内容,避免白屏闪烁。
### 证据
performance.wxml 空态区块:
```xml
<!-- 空数据态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="40px" color="#dcdcdc" />
<text class="empty-text">暂无业绩数据</text>
</view>
```
performance-records.wxml 空态区块:
```xml
<!-- 空态 -->
<view class="page-empty" wx:if="{{pageState === 'empty'}}">
<t-icon name="chart-bar" size="120rpx" color="#dcdcdc" />
<text class="empty-text">暂无数据</text>
</view>
```
performance-records.ts 中空态判断:
```typescript
this.setData({
pageState: dateGroups.length > 0 ? 'normal' : 'empty',
// ...
})
```
### 建议
无需额外补充。空状态处理已覆盖新助教无数据场景。

View File

@@ -0,0 +1,49 @@
# P7→NS1/RNS1 缺失项 #10业绩明细的导出功能
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 后端无绩效数据导出接口,前端无导出按钮。现有的 Excel 相关接口仅用于租户管理后台的数据上传,与绩效导出无关。
## 详细审查
### 审查范围
- `apps/backend/app/routers/xcx_performance.py` — 绩效路由端点清单
- `apps/backend/app/routers/` — 全部路由文件搜索 export/导出/excel 关键词
- `apps/miniprogram/miniprogram/pages/performance/performance.wxml` — 导出按钮
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 导出按钮
### 发现
1. **后端无绩效导出接口**`xcx_performance.py` 仅定义两个端点:
- `GET /api/xcx/performance` — 绩效概览PERF-1
- `GET /api/xcx/performance/records` — 绩效明细PERF-2
-`/export` 或类似导出端点
2. **现有 Excel 接口与绩效无关**`tenant_excel.py` 提供的是租户管理后台的 Excel 上传/校验/写入功能(财务支出、团购收入、助教奖罚、充值业绩归属模板),不涉及绩效数据导出。
3. **前端无导出按钮**performance 和 performance-records 两个页面的 WXML 中均无导出相关的 UI 元素。
4. **P7 中"导出 Excel"为隐含需求**:原始 PRD 中提到但未作为核心功能明确定义NS1/RNS1 未将其纳入实现范围。
### 证据
后端路由完整端点清单xcx_performance.py 文件头注释):
```python
"""
端点清单:
- GET /api/xcx/performance — 绩效概览PERF-1
- GET /api/xcx/performance/records — 绩效明细PERF-2
"""
```
全局搜索 `export|导出|excel` 在后端路由中的结果:仅 `tenant_excel.py`(租户 Excel 上传)和 `env_config.py`(环境配置导出),无绩效相关导出。
### 建议
1. **评估优先级**:导出功能在 P7 中为隐含需求,非核心交互。建议在 MVP 阶段暂不实现,后续根据用户反馈决定是否补充。
2. **如需实现**
- 后端新增 `GET /api/xcx/performance/export?year=&month=` 端点,返回 Excel 文件openpyxl 生成)
- 前端在 performance-records 页面顶部或底部增加"导出本月明细"按钮
- 小程序端可通过 `wx.downloadFile` + `wx.openDocument` 实现文件下载和预览
3. **替代方案**可在管理后台admin-web而非小程序端提供导出功能降低小程序端复杂度。

View File

@@ -0,0 +1,75 @@
# P7→NS1/RNS1 缺失项 #11绩效数据的刷新频率说明
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟡 低
- 后端直接查询 ETL 库 DWS/DWD 视图(通过 FDW无应用层缓存数据新鲜度取决于 ETL 执行频率。ETL 为手动触发CLI无自动定时调度但代码中无数据新鲜度说明文档。
## 详细审查
### 审查范围
- `apps/backend/app/services/performance_service.py` — 数据来源和查询方式
- `apps/backend/app/services/fdw_queries.py` — FDW 查询函数(`get_salary_calc``get_service_records`
- `apps/etl/connectors/feiqiu/orchestration/` — 调度配置
- `apps/etl/connectors/feiqiu/cli/main.py` — CLI 入口
- `apps/etl/connectors/feiqiu/tasks/dws/assistant_salary_task.py` — DWS 薪资任务
### 发现
1. **后端无缓存,直接查 FDW 视图**
- `get_salary_calc()` 查询 `app.v_dws_assistant_salary_calc`DWS 层视图)
- `get_service_records()` 查询 `app.v_dwd_assistant_service_log`DWD 层视图)
- 两者均通过 FDWpostgres_fdw从 ETL 库只读访问
- 无 Redis/内存缓存层,每次请求直接查库
2. **ETL 为手动 CLI 触发,无自动定时调度**
- `cli/main.py` 提供 `--flow``--tasks` 等参数手动执行
- `orchestration/scheduler.py` 已标记为弃用(`ETLScheduler 已弃用`
- 无 cron/定时任务配置文件
- 无 Windows Task Scheduler 或 systemd timer 配置
3. **DWS 薪资表使用 delete-before-insert 策略**`assistant_salary_task.py` 中有 `_delete_by_month()` 方法,符合 DWS 层幂等策略。
4. **无数据新鲜度文档**NS1/RNS1 未定义数据刷新频率,前端也未展示"数据更新时间"提示。
### 证据
fdw_queries.py 中 `get_salary_calc()` 数据来源:
```python
cur.execute("""
SELECT salary_month, assistant_level_name, tier_id, ...
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = %s AND salary_month = %s::date
""", (assistant_id, calc_month))
```
fdw_queries.py 中 `get_service_records()` 数据来源:
```python
cur.execute("""
SELECT sl.assistant_service_id, dm.nickname AS customer_name, ...
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm ON ...
WHERE sl.site_assistant_id = %s AND sl.is_delete = 0
AND sl.create_time >= %s::timestamptz
AND sl.create_time < %s::timestamptz
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
""", ...)
```
ETL 调度器弃用标记orchestration/scheduler.py
```python
class ETLScheduler:
"""调度器薄包装层(已弃用)。"""
def __init__(self, config, logger):
warnings.warn("ETLScheduler 已弃用,请直接使用 TaskExecutor 和 FlowRunner", ...)
```
### 建议
1. **文档补充**:在 NS1 或运维文档中明确说明数据新鲜度策略:
- DWD 层服务记录ETL 执行后即时更新
- DWS 层薪资计算ETL 执行后 delete-before-insert 重算
- 当前为手动触发,建议说明推荐执行频率(如每日一次)
2. **前端提示**:可在绩效页面底部增加"数据更新于 YYYY-MM-DD HH:mm"提示(需后端返回 ETL 最后执行时间)。
3. **自动调度**:长期建议配置 Windows Task Scheduler 或 cron 定时执行 ETL flow确保数据每日刷新。

View File

@@ -0,0 +1,72 @@
# P7→NS1/RNS1 缺失项 #12业绩明细页的口径选择交互
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 后端 PERF-2 API 仅支持 `year`/`month` 参数(月口径),不支持周口径参数。前端 performance-records 页面仅有月份切换,无周口径切换 UI。
## 详细审查
### 审查范围
- `apps/backend/app/routers/xcx_performance.py` — PERF-2 端点参数定义
- `apps/backend/app/services/performance_service.py``get_records()` 函数签名
- `apps/backend/app/services/fdw_queries.py``get_service_records()` 查询条件
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts` — 口径切换逻辑
- `apps/miniprogram/miniprogram/pages/performance-records/performance-records.wxml` — 口径切换 UI
### 发现
1. **后端 PERF-2 仅支持月口径**
- 路由参数:`year: int, month: int, page: int, page_size: int`
-`period_type`(月/周)、`week_start`/`week_end` 等周口径参数
- `get_records()` 函数签名:`(user_id, site_id, year, month, page, page_size)`
2. **FDW 查询按月过滤**`get_service_records()` 使用 `create_time >= '{year}-{month:02d}-01'``create_time < '{year}-{month+1:02d}-01'` 作为时间范围,硬编码为自然月。
3. **前端无周口径切换**
- performance-records.ts 中仅有 `switchMonth()` 方法
- WXML 中仅有月份切换器(`month-switcher`),无"本周/上周"切换 UI
- 全局搜索 `week|周|weekly` 在后端绩效文件中无匹配
4. **P7 提到"本周/上周"口径**:原始 PRD 中定义了周维度的业绩查看,但 NS1/RNS1 仅实现了月维度。
### 证据
后端 PERF-2 路由定义xcx_performance.py
```python
@router.get("/records", response_model=PerformanceRecordsResponse)
async def get_performance_records(
year: int = Query(...),
month: int = Query(..., ge=1, le=12),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
):
```
FDW 查询时间范围fdw_queries.py:get_service_records
```python
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
```
前端仅有月份切换performance-records.wxml
```xml
<view class="month-switcher">
<view class="month-btn" data-direction="prev" bindtap="switchMonth">...</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn" data-direction="next" bindtap="switchMonth">...</view>
</view>
```
### 建议
1. **评估必要性**:周口径在绩效场景中的实际使用频率较低(助教薪资按月结算),建议与产品确认是否为 MVP 必需。
2. **如需实现**
- 后端PERF-2 增加可选参数 `period_type: str = Query("month", regex="^(month|week)$")`,以及 `week_start: date | None``week_end: date | None`
- FDW 查询:`get_service_records()` 支持自定义时间范围(`start_date`/`end_date`)而非固定月份
- 前端:在月份切换器上方增加 Tab 切换("按月" / "按周"),按周模式下展示"本周/上周"切换器
3. **渐进方案**:可先在前端增加"自定义日期范围"筛选器,后端接受 `start_date`/`end_date` 参数,同时覆盖周口径和任意时间段需求。

View File

@@ -0,0 +1,80 @@
# P8→NS1/RNS1 缺失项 #1三看板 Tab 切换的缓存策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🔴 高
- 三看板 Tab 切换已实现,但采用页面跳转而非组件切换,切换回来时不保持筛选状态和滚动位置。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/app.json`tabBar 配置)
- `apps/miniprogram/miniprogram/custom-tab-bar/index.ts`(自定义 TabBar
- `apps/miniprogram/miniprogram/components/board-tab-bar/board-tab-bar.ts`(看板内 Tab 组件)
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`(财务看板 Tab 切换)
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts`(助教看板 Tab 切换)
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.ts`(客户看板 Tab 切换)
### 发现
1. **Tab 切换方式:页面跳转,非组件内切换**
- `app.json` 中 tabBar 仅注册了 `board-finance` 为 tabBar 页面
- `board-customer``board-coach` 是普通页面(非 tabBar 页面)
-`board-finance` 切换到其他看板使用 `wx.navigateTo()`(页面栈压入)
-`board-coach`/`board-customer` 切换到 `board-finance` 使用 `wx.switchTab()`(清空页面栈)
2. **筛选状态不保持**
- 每个看板页面的筛选状态(`selectedSort``selectedDimension``selectedTime` 等)存储在 Page data 中
- 使用 `wx.navigateTo` 跳转时,离开的页面会被销毁(返回时重新 `onLoad`
- 使用 `wx.switchTab` 回到 `board-finance` 时,该页面会触发 `onShow` 但不会重新 `onLoad`tabBar 页面有缓存)
-`board-coach``board-customer` 作为非 tabBar 页面,每次进入都会重新创建
3. **滚动位置不保持**
- 三个看板页面均无滚动位置保存/恢复逻辑
- 页面重新加载后滚动位置归零
4. **切换动画**
- 未实现 P8 定义的切换动画
- 使用微信默认的页面跳转动画(右滑进入/左滑返回)
### 证据
```typescript
// board-finance.ts — 切换到其他看板
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' })
}
}
// board-coach.ts — 切换回财务看板
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' })
}
}
```
```json
// app.json — 仅 board-finance 是 tabBar 页面
"tabBar": {
"custom": true,
"list": [
{ "pagePath": "pages/task-list/task-list", "text": "任务" },
{ "pagePath": "pages/board-finance/board-finance", "text": "看板" },
{ "pagePath": "pages/my-profile/my-profile", "text": "我的" }
]
}
```
### 建议
1. **方案 A推荐**:将筛选状态持久化到 `getApp().globalData``wx.setStorageSync`,页面 `onLoad` 时恢复
2. **方案 B**:将三个看板合并为一个页面,使用 `wx:if``hidden` 切换内容区域,天然保持状态
3. 滚动位置可通过 `onPageScroll` 记录 + `onLoad``wx.pageScrollTo` 恢复
4. 切换动画可通过 CSS transition 在顶部 Tab 区域实现高亮滑动效果

View File

@@ -0,0 +1,69 @@
# P8→NS1/RNS1 缺失项 #2财务看板分段加载策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🔴 高
- 后端 BOARD-3 API 一次返回全部 6 板块数据,无 sections 参数支持分段加载;前端仅赠送卡矩阵做了异步加载,其余板块为 mock 静态数据。
## 详细审查
### 审查范围
- `apps/backend/app/routers/xcx_board.py`BOARD-3 路由定义)
- `apps/backend/app/schemas/xcx_board.py`FinanceBoardResponse schema
- `apps/backend/app/services/board_service.py`get_finance_board 服务)
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`(前端加载逻辑)
### 发现
1. **后端 API 不支持分段加载**
- BOARD-3 路由参数仅有 `time``area``compare`,无 `sections` 参数
- `FinanceBoardResponse` 一次性返回全部 6 个 Panel`overview``recharge``revenue``cashflow``expense``coach_analysis`
- `get_finance_board` 服务函数内部顺序构建所有板块数据,无条件跳过机制
2. **前端加载策略:大部分为 mock 静态数据**
- `board-finance.ts``overview``revenue``cashflow``expense``coachAnalysis` 的数据全部在 Page data 中以空字符串初始化,未调用 API
-`_loadGiftRows()` 方法通过 `fetchBoardFinance()` 加载了赠送卡矩阵(`recharge.giftRows`)和 AI 洞察数据
- 页面 `onLoad` 时直接设置 `pageState: 'normal'`,无整体数据加载流程
3. **唯一的区域条件过滤**
- `recharge` 板块在 `selectedArea !== 'all'` 时通过 `wx:if` 隐藏(前端条件渲染)
- 后端 schema 中 `recharge: RechargePanel | None` 支持 area≠all 时返回 null
### 证据
```python
# xcx_board.py — BOARD-3 路由,无 sections 参数
@router.get("/finance", response_model=FinanceBoardResponse)
async def get_finance_board(
time: FinanceTimeEnum = Query(default=FinanceTimeEnum.month),
area: AreaFilterEnum = Query(default=AreaFilterEnum.all),
compare: int = Query(default=0, ge=0, le=1),
user: CurrentUser = Depends(require_permission("view_board_finance")),
):
# FinanceBoardResponse — 一次返回全部 6 板块
class FinanceBoardResponse(CamelModel):
overview: OverviewPanel
recharge: RechargePanel | None
revenue: RevenuePanel
cashflow: CashflowPanel
expense: ExpensePanel
coach_analysis: CoachAnalysisPanel
```
```typescript
// board-finance.ts — 仅加载赠送卡数据,其余为 mock
async _loadGiftRows() {
const data = await fetchBoardFinance({
time: this.data.selectedTime,
area: this.data.selectedArea,
compare: this.data.compareEnabled ? 1 : 0,
})
// 仅处理 giftRows 和 aiInsights
}
```
### 建议
1. **后端**:为 BOARD-3 API 增加可选 `sections` 查询参数(如 `sections=overview,recharge`),服务层按需构建板块,未请求的板块返回 null
2. **前端**:实现分段加载策略——首次加载仅请求 `overview`,用户滚动到对应板块时再按需请求其余板块(利用 IntersectionObserver 或 onPageScroll 触发)
3. 当前 mock 数据阶段此问题影响不大,但联调前必须完成分段加载改造,否则 6 板块全量查询会导致首屏加载缓慢

View File

@@ -0,0 +1,50 @@
# P8→NS1/RNS1 缺失项 #3客户看板卡片点击跳转到 customer-detail
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 客户卡片已实现 bindtap 事件,点击后通过 wx.navigateTo 跳转到 customer-detail 页面并传递 id 参数。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxml`(卡片模板)
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.ts`(点击事件处理)
- `apps/miniprogram/miniprogram/app.json`customer-detail 页面注册)
### 发现
1. **WXML 模板中卡片绑定了 bindtap 事件**
- 每个 `customer-card` 元素绑定了 `bindtap="onCustomerTap"`
- 通过 `data-id="{{item.id}}"` 传递客户 ID
- 同时设置了 `hover-class="customer-card--hover"` 提供点击反馈
2. **TS 中实现了跳转逻辑**
- `onCustomerTap` 方法从事件中提取 `id`,使用 `wx.navigateTo` 跳转到 `customer-detail` 页面
- 跳转 URL 格式:`/pages/customer-detail/customer-detail?id=xxx`
3. **customer-detail 页面已注册**
- `app.json` 的 pages 数组中包含 `pages/customer-detail/customer-detail`
- `pages/customer-detail/` 目录存在
### 证据
```html
<!-- board-customer.wxml -->
<view
class="customer-card"
hover-class="customer-card--hover"
wx:for="{{customers}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCustomerTap"
>
```
```typescript
// board-customer.ts
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,80 @@
# P8→NS1/RNS1 缺失项 #4助教看板的"距升档"进度条
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🔴 高
- 后端仅返回 `perfGap` 文本字段(如"距升档 13.8h"),无进度百分比数据;`perf-progress-bar` 组件已开发但未在看板页面中使用,看板仅以文字形式展示距升档信息。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_board.py`CoachBoardItem schema
- `apps/backend/app/services/board_service.py`get_coach_board 服务)
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml`(看板模板)
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts`(看板逻辑)
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.json`(组件引用)
- `apps/miniprogram/miniprogram/components/perf-progress-bar/perf-progress-bar.ts`(进度条组件)
### 发现
1. **后端 Schema 无进度百分比字段**
- `CoachBoardItem` 中与升档相关的字段:
- `perf_hours: float` — 当前定档业绩小时数
- `perf_gap: str | None` — 文本描述(如"距升档 13.8h"
- `perf_reached: bool` — 是否已达标
- 缺少:`perf_pct`(进度百分比)、`perf_target`(目标小时数)、`perf_tier`(当前档位)等可视化所需字段
2. **perf-progress-bar 组件已开发,功能完整**
- 组件支持:`filledPct`(填充百分比)、`clampedSparkPct`(火星位置)、`currentTier`(当前档位 0~5`ticks`(刻度数组)
- 支持高光动画和火花动画
- 已在 `task-list``coach-detail` 页面中使用
3. **看板页面未引用进度条组件**
- `board-coach.json``usingComponents` 中无 `perf-progress-bar`
- `board-coach.wxml` 中升档信息仅以文字展示:
- 未达标:`<text class="bottom-right bottom-right--warning">{{item.perfGap}}</text>`
- 已达标:`<text class="bottom-right bottom-right--success">✅ 已达标</text>`
### 证据
```python
# CoachBoardItem — 仅有文本字段,无百分比
class CoachBoardItem(CamelModel):
perf_hours: float = 0.0
perf_hours_before: float | None = None
perf_gap: str | None = None # "距升档 13.8h" 或 None
perf_reached: bool = False
# 缺少: perf_pct, perf_target, current_tier, ticks 等
```
```html
<!-- board-coach.wxml — 仅文字展示,无进度条 -->
<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>
```
```json
// board-coach.json — 未引用 perf-progress-bar
{
"usingComponents": {
"coach-level-tag": "/components/coach-level-tag/coach-level-tag",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown",
// ... 无 perf-progress-bar
}
}
```
### 建议
1. **后端**:在 `CoachBoardItem` 中增加进度可视化字段:
- `perf_pct: float` — 当前业绩占目标的百分比0~100
- `perf_target: float` — 当前档位目标小时数
- `current_tier: int` — 当前档位0~5
- `ticks: list[dict]` — 档位刻度数组(复用 `perf-progress-bar` 组件的 ticks 格式)
2. **前端**:在 `board-coach.json` 中引入 `perf-progress-bar` 组件,在卡片的 perf 维度区域渲染进度条
3. 可参考 `coach-detail` 页面中 `perf-progress-bar` 的使用方式

View File

@@ -0,0 +1,61 @@
# P8→NS1/RNS1 缺失项 #5看板数据的实时性标识
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟠 中
- 三个看板页面均无"数据更新于 XX:XX"的展示,后端 API 响应中也无数据截止时间字段。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_board.py`(三个 Response schema
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml`
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxml`
- 全局搜索关键词:`更新于``updated_at``dataTime``updateTime``截止`
### 发现
1. **后端 Schema 无数据截止时间字段**
- `CoachBoardResponse`:仅包含 `items``dim_type`
- `CustomerBoardResponse`:仅包含维度数据列表
- `FinanceBoardResponse`:仅包含 6 个 Panel无时间戳
- 三个 Response 均无 `data_updated_at``snapshot_time` 等字段
2. **前端页面无数据更新时间展示**
- 全局搜索 `更新于``updated_at``dataTime``updateTime``截止` 在 board 相关文件中均无匹配
- 三个看板页面的 WXML 模板中无任何时间戳展示区域
3. **财务看板有"预估"标签但非实时性标识**
- `board-finance` 中有 `isCurrentMonth` 判断,当月数据显示"(预估)"后缀
- 这是数据性质标识,不是数据截止时间
### 证据
```python
# 三个 Response schema 均无时间戳字段
class CoachBoardResponse(CamelModel):
items: list[CoachBoardItem]
dim_type: str
class FinanceBoardResponse(CamelModel):
overview: OverviewPanel
recharge: RechargePanel | None
revenue: RevenuePanel
cashflow: CashflowPanel
expense: ExpensePanel
coach_analysis: CoachAnalysisPanel
# 缺少: data_updated_at / snapshot_time
```
```
# 全局搜索结果
grep "更新于|updated_at|dataTime|updateTime|截止" **/board-*/**
→ No matches found.
```
### 建议
1. **后端**:在三个 BoardResponse 中增加 `data_updated_at: datetime` 字段,返回 DWS 层最后一次 ETL 刷新时间
2. **前端**在每个看板页面顶部Tab 下方或筛选栏下方)展示"数据更新于 HH:MM"
3. 数据来源可从 ETL 调度记录表(如 `dws.etl_run_log`)获取最后成功执行时间
4. 建议格式:当天数据显示"更新于 14:30",非当天显示"更新于 03-20 14:30"

View File

@@ -0,0 +1,60 @@
# P8→NS1/RNS1 缺失项 #6财务看板环比数据的 tooltip 说明
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 环比数据已展示(↑/↓箭头+数值),环比开关已实现,但点击环比箭头不会显示计算详情 tooltip仅指标名称旁的"?"图标有 tip 弹窗。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml`(环比展示区域)
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`(交互逻辑)
### 发现
1. **环比开关已实现**
- 顶部筛选栏有环比开关(`toggleCompare`),点击切换 `compareEnabled` 状态
- 环比数据通过 `wx:if="{{compareEnabled}}"` 条件渲染
2. **环比数据展示格式完整**
- 使用 `↑`/`↓` 箭头 + 数值文本展示环比变化
- 样式区分:上升用 `compare-text-up`(绿色),下降用 `compare-text-down`(红色),持平用 `compare-text-flat`
3. **环比箭头无点击交互**
- 所有 `compare-text-*` 元素均为纯文本展示,无 `bindtap` 事件
- 搜索 `compare.*tap``tooltip``onCompareTap` 在 board-finance 中无匹配
- P8 定义的"点击环比箭头显示计算详情(如:本期 ¥12,000 vs 上期 ¥10,000变化 +20%"未实现
4. **指标名称的"?"帮助图标已实现**
- 各指标旁有 `help-icon` 元素,绑定 `onHelpTap` 事件
- 点击后弹出 `tipContents` 中预定义的说明文案
- 但这是指标含义说明,不是环比计算详情
### 证据
```html
<!-- board-finance.wxml — 环比数据为纯文本,无 bindtap -->
<view class="compare-row" wx:if="{{compareEnabled}}">
<text class="compare-text-up">↑{{overview.occurrenceCompare}}</text>
</view>
<!-- 对比:指标名称的"?"有 bindtap -->
<view class="help-icon-light" data-key="occurrence" bindtap="onHelpTap">?</view>
```
```typescript
// board-finance.ts — tipContents 仅包含指标含义,无环比计算详情
const tipContents: Record<string, { title: string; content: string }> = {
occurrence: {
title: '发生额/正价',
content: '所有消费项目按标价计算的总金额,不扣除任何优惠。...',
},
// ... 无环比计算详情
}
```
### 建议
1. **方案 A轻量**:为环比文本添加 `bindtap` 事件,点击后弹出包含"本期值 vs 上期值 → 变化率"的 tooltip
2. **方案 B完整**:后端在环比数据中返回 `current_value``previous_value``change_pct` 三个字段,前端据此渲染详情弹窗
3. 当前后端 `calc_compare` 函数已计算了 current/previous 值,只需在响应中透传即可

View File

@@ -0,0 +1,58 @@
# P8→NS1/RNS1 缺失项 #7助教看板卡片点击跳转到 coach-detail
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 助教卡片已实现 bindtap 事件,点击后通过 wx.navigateTo 跳转到 coach-detail 页面并传递 id 参数。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml`(卡片模板)
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts`(点击事件处理)
- `apps/miniprogram/miniprogram/app.json`coach-detail 页面注册)
### 发现
1. **WXML 模板中卡片绑定了 bindtap 事件**
- 每个 `coach-card` 元素绑定了 `bindtap="onCoachTap"`
- 通过 `data-id="{{item.id}}"` 传递助教 ID
- 设置了 `hover-class="coach-card--hover"` 提供点击反馈
2. **TS 中实现了跳转逻辑**
- `onCoachTap` 方法从事件中提取 `id`,使用 `wx.navigateTo` 跳转到 `coach-detail` 页面
- 跳转 URL 格式:`/pages/coach-detail/coach-detail?id=xxx`
3. **coach-detail 页面已注册且功能完整**
- `app.json` 的 pages 数组中包含 `pages/coach-detail/coach-detail`
- `pages/coach-detail/` 目录存在
- coach-detail 页面已引用 `perf-progress-bar` 组件(进度条在详情页可用)
### 证据
```html
<!-- board-coach.wxml -->
<view class="coach-card"
wx:for="{{coaches}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onCoachTap"
hover-class="coach-card--hover">
```
```typescript
// board-coach.ts
onCoachTap(e: WechatMiniprogram.TouchEvent) {
const id = e.currentTarget.dataset.id as string
wx.navigateTo({ url: '/pages/coach-detail/coach-detail?id=' + id })
},
```
```json
// app.json — coach-detail 已注册
"pages": [
...
"pages/coach-detail/coach-detail",
...
]
```

View File

@@ -0,0 +1,35 @@
# P8→NS1/RNS1 缺失项 #8客户看板"最频繁"维度的柱状图交互
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 柱状图已实现渲染,但缺少点击柱子显示具体数据的交互
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxml`
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.ts`
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxss`
### 发现
1. **柱状图渲染已实现**`board-customer.wxml``dimType === 'freq60'` 时渲染了 `mini-chart` 迷你柱状图,包含 8 周数据、柱子高度百分比、渐变透明度、底部数字
2. **数据结构已定义**`weeklyVisits: Array<{ val: number; pct: number }>` 在 TS 接口中已定义Mock 数据包含 8 个元素
3. **缺少点击交互**:柱状图的 `mini-bar-col` 元素没有 `bindtap` 事件绑定,无法点击柱子查看具体数据
4. **无 tooltip/弹窗组件**:没有实现点击柱子后显示详细数据(如具体到店日期、消费金额等)的 UI
### 证据
WXML 中柱状图部分(无 bindtap
```xml
<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 + wIdx * 0.057}}"></view>
</view>
```
TS 中无柱状图点击处理函数,仅有 `onCustomerTap`(整张卡片点击跳转详情页)。
### 建议
1.`mini-bar-col` 上添加 `bindtap="onBarTap"` 并传递 `data-week-index``data-customer-id`
2. 实现 `onBarTap` 方法,弹出轻量 tooltip 显示该周具体到店次数和日期
3. 或者考虑:由于柱状图尺寸较小(迷你图),点击交互在移动端体验可能不佳,可评估是否改为点击整张卡片进入详情页后查看完整图表

View File

@@ -0,0 +1,57 @@
# P8→NS1/RNS1 缺失项 #9看板页面的下拉刷新行为
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 三个看板页面均已实现下拉刷新
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.json` + `.ts`
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.json` + `.ts`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.json` + `.ts`
### 发现
1. **JSON 配置已启用**:三个页面的 `.json` 文件均设置了 `"enablePullDownRefresh": true`
2. **TS 生命周期已实现**:三个页面均实现了 `onPullDownRefresh()` 方法
3. **刷新逻辑完整**
- `board-finance`:调用 `_loadGiftRows()` 重新加载数据500ms 后 `wx.stopPullDownRefresh()`
- `board-customer`:调用 `loadData()` 重新加载数据500ms 后 `wx.stopPullDownRefresh()`
- `board-coach`:调用 `loadData()` 重新加载数据500ms 后 `wx.stopPullDownRefresh()`
### 证据
board-finance.ts
```typescript
onPullDownRefresh() {
this._loadGiftRows()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
```
board-customer.ts
```typescript
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
```
board-coach.ts
```typescript
onPullDownRefresh() {
this.loadData()
setTimeout(() => wx.stopPullDownRefresh(), 500)
},
```
三个页面 JSON 均包含:
```json
"enablePullDownRefresh": true
```
### 建议
无。功能已完整实现。
> 小优化建议(非必须):`setTimeout(() => wx.stopPullDownRefresh(), 500)` 使用固定延时,理想情况应在数据加载完成后再停止刷新动画,避免数据未返回时刷新动画就消失。待 API 联调时可改为 Promise 链式调用。

View File

@@ -0,0 +1,50 @@
# P8→NS1/RNS1 缺失项 #10财务看板各板块的折叠/展开交互
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 财务看板各板块(经营一览、预收资产、应计收入等)没有折叠/展开控制,所有板块始终完全展开
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml`
### 发现
1. **无折叠状态变量**TS 的 `data` 中没有任何 `collapsed``expanded``folded` 等板块折叠状态字段
2. **无折叠切换方法**TS 中没有 `toggleSection``collapseSection` 等方法
3. **WXML 无折叠控制**:各 `card-section` 没有条件渲染或高度动画控制,所有板块内容始终完全展示
4. **已有替代方案**财务看板实现了目录导航TOC功能用户可通过目录快速跳转到指定板块部分弥补了长页面浏览的不便
5. **吸顶板块头已实现**:滚动时显示当前板块标题的吸顶头,帮助用户定位当前位置
### 证据
WXML 中板块结构(无折叠控制):
```xml
<!-- ===== 板块 1: 经营一览(深色) ===== -->
<view id="section-overview" class="card-section section-dark">
<!-- 内容始终展示,无 wx:if 或 height 动画控制 -->
</view>
<!-- ===== 板块 2: 预收资产 ===== -->
<view id="section-recharge" class="card-section" wx:if="{{selectedArea === 'all'}}">
<!-- 仅按区域筛选条件显示/隐藏,非用户手动折叠 -->
</view>
```
TS 中与板块交互相关的方法仅有:
```typescript
toggleToc() // 目录导航开关
onTocItemTap() // 目录项点击跳转
toggleCompare() // 环比开关
```
### 建议
1. **评估必要性**:当前财务看板已有 TOC 目录导航 + 吸顶板块头,用户可快速定位。折叠/展开在移动端长页面中是常见模式,但 H5 原型是否有此交互需确认
2. **如需实现**
-`data` 中添加 `sectionCollapsed: Record<string, boolean>` 状态
- 在各板块 `card-header` 上添加 `bindtap="toggleSection"` 并传递 `data-section`
- 使用 CSS `max-height` + `transition` 实现展开/收起动画
- 折叠时仅显示板块标题行,展开时显示完整内容
3. **优先级评估**:鉴于已有 TOC 导航,此项可作为体验优化延后处理

View File

@@ -0,0 +1,68 @@
# P8→NS1/RNS1 缺失项 #11看板数据加载失败时的错误展示
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 三个看板页面均已实现错误态展示和重试按钮
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml` + `.ts`
- `apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxml` + `.ts`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml` + `.ts`
### 发现
1. **pageState 状态机已定义**:三个页面均定义了 `pageState: 'loading' | 'empty' | 'normal' | 'error'` 四态
2. **错误态 UI 已实现**:三个页面 WXML 均包含 `wx:elif="{{pageState === 'error'}}"` 条件渲染的错误态视图
3. **重试按钮已实现**:错误态视图中均包含 `bindtap="onRetry"` 的重试按钮
4. **onRetry 方法已实现**:三个页面 TS 均实现了 `onRetry()` 方法,调用 `loadData()` 或相应的数据加载方法
5. **loadData 中有 catch 处理**`board-customer``board-coach``loadData()` 使用 try-catchcatch 中设置 `pageState: 'error'`
### 证据
board-coach.wxml 错误态:
```xml
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">点击重试</view>
</view>
```
board-customer.wxml 错误态(结构一致):
```xml
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">点击重试</view>
</view>
```
board-finance.wxml 错误态:
```xml
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-empty description="加载失败" />
<view class="retry-btn" bindtap="onRetry">
<text class="retry-btn-text">点击重试</text>
</view>
</view>
```
board-coach.ts 错误处理:
```typescript
loadData() {
this.setData({ pageState: 'loading' })
setTimeout(() => {
try {
// ...
} catch {
this.setData({ pageState: 'error' })
}
}, 400)
},
onRetry() {
this.loadData()
},
```
### 建议
无。功能已完整实现。三个看板页面均具备 loading → normal/empty/error 四态切换,错误态有 `<t-empty>` 空态组件 + 重试按钮。

View File

@@ -0,0 +1,59 @@
# P8→NS1/RNS1 缺失项 #12筛选项的动画效果
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- filter-dropdown 组件已实现展开/收起动画,包括面板滑入、箭头旋转、遮罩层
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/components/filter-dropdown/filter-dropdown.ts`
- `apps/miniprogram/miniprogram/components/filter-dropdown/filter-dropdown.wxml`
- `apps/miniprogram/miniprogram/components/filter-dropdown/filter-dropdown.wxss`
### 发现
1. **面板展开/收起动画已实现**WXSS 中 `.dropdown-panel` 使用 `opacity` + `transform: translateY` 过渡动画展开时从上方滑入0.25s ease
2. **箭头旋转动画已实现**`.filter-arrow` 使用 `transform: rotate(180deg)` + `transition: 0.25s ease` 实现展开时箭头翻转
3. **遮罩层已实现**:展开时显示半透明黑色遮罩 `rgba(0, 0, 0, 0.5)`,点击遮罩关闭下拉
4. **按钮状态变化已实现**:展开时按钮边框变为主色调 `--color-primary`,背景变为浅色 `--color-primary-light`,有 0.2s 过渡
5. **面板定位动态计算**:展开时通过 `createSelectorQuery` 计算按钮底部位置,面板从该位置展开
### 证据
WXSS 动画定义:
```css
/* 面板展开/收起动画 */
.dropdown-panel {
opacity: 0;
transform: translateY(-16rpx);
transition: opacity 0.25s ease, transform 0.25s ease;
pointer-events: none;
}
.dropdown-panel--show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* 箭头旋转动画 */
.filter-arrow {
transition: transform 0.25s ease;
}
.filter-arrow--up {
transform: rotate(180deg);
}
/* 按钮状态过渡 */
.filter-dropdown {
transition: border-color 0.2s, background-color 0.2s;
}
```
WXML 动画触发:
```xml
<view class="dropdown-panel {{expanded ? 'dropdown-panel--show' : ''}}" style="top: {{panelTop}}px">
```
### 建议
无。动画效果已完整实现包含面板滑入opacity + translateY、箭头旋转rotate 180deg、按钮状态变化border-color + background-color均使用 CSS transition 实现,性能良好。

View File

@@ -0,0 +1,61 @@
# P8→NS1/RNS1 缺失项 #13助教看板的排名序号展示
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 助教卡片列表中没有排名序号(如 #1#2#3)展示
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxss`
### 发现
1. **无排名序号渲染**WXML 中 `coach-card``wx:for` 循环没有使用 `wx:for-index` 来展示排名序号
2. **无排名字段**TS 的 `CoachItem` 接口中没有 `rank` 字段
3. **无排名样式**WXSS 中没有 `rank``序号``number` 等相关样式类
4. **卡片结构**:当前卡片结构为 `头像 → 姓名+等级+技能+右侧指标 → 底部客户列表`,没有排名序号的位置
### 证据
WXML 中助教列表渲染(无排名序号):
```xml
<view class="coach-list">
<view class="coach-card" wx:for="{{coaches}}" wx:key="id"
data-id="{{item.id}}" bindtap="onCoachTap"
hover-class="coach-card--hover">
<view class="card-row">
<!-- 头像 -->
<view class="card-avatar avatar-{{item.avatarGradient}}">
<text class="avatar-text">{{item.initial}}</text>
</view>
<!-- 信息区(无排名序号) -->
<view class="card-info">
...
</view>
</view>
</view>
</view>
```
CoachItem 接口(无 rank 字段):
```typescript
interface CoachItem {
id: string
name: string
initial: string
avatarGradient: string
level: string
// ... 无 rank 字段
}
```
### 建议
1. **评估必要性**:排名序号在看板场景中有助于快速识别排名位置,但也会增加视觉噪音。需确认 P8 原型中是否明确要求显示
2. **如需实现**
- 方案 A推荐利用 `wx:for``index` 直接渲染,在头像左侧或上方添加 `#{{index + 1}}` 序号
- 方案 B在卡片左上角添加小圆形排名徽章前 3 名用金/银/铜色区分
- 在 WXML 的 `card-row` 开头添加:`<text class="rank-num">#{{index + 1}}</text>`
3. **客户看板同理**:如果助教看板需要排名序号,客户看板的列表也应考虑一致性

View File

@@ -0,0 +1,96 @@
# P8→NS1/RNS1 缺失项 #14财务看板数字的格式化规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 千分位、小数位、货币符号的格式化规范已在设计文档中定义工具函数已实现TS + WXS 双版本),财务看板已使用
## 详细审查
### 审查范围
- `docs/miniprogram-dev/design-system/DISPLAY-STANDARDS.md`
- `apps/miniprogram/miniprogram/utils/money.ts`
- `apps/miniprogram/miniprogram/utils/format.wxs`
- `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
- `apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts`
### 发现
#### 1. 设计规范文档已完善
`DISPLAY-STANDARDS.md` 第 1 章"金额展示规范"明确定义了:
- 正常金额:`¥N,NNN`(千分位逗号,无小数)
- 负数金额:`-¥N,NNN`(负号在 ¥ 前)
- 零值:`¥0`
- 空值:`--`
- 大额金额:不简写,保留完整数字
- 禁止事项:禁止 `¥-368``¥0.00``¥12万``toLocaleString()`
#### 2. TS 工具函数已实现(`utils/money.ts`
- `formatMoney(value)` — 金额格式化,千分位 + ¥ 前缀
- `formatCount(value, unit)` — 计数格式化,千分位 + 单位
- `formatNumber(value)` — 纯数字千分位
- `formatPercent(value)` — 百分比,保留 1 位小数
- `formatTrendValue(value)` — 同比/环比差值,+¥/-¥ 前缀
- `toProgressWidth(value)` — 进度条宽度,截断至 [0, 100]
#### 3. WXS 工具函数已实现(`utils/format.wxs`
- `money(value)` — 金额格式化WXS 版,用于 WXML 模板)
- `count(value, unit)` — 计数格式化
- `percent(value)` — 百分比
- `hours(value)` — 课时格式化
- `trendValue(value)` — 同比/环比差值
- `safe(val)` — 空值兜底
#### 4. 看板页面已使用格式化函数
- `board-finance.ts`:导入并使用 `formatMoney` 格式化赠送卡矩阵数据
- `board-coach.ts`:导入并使用 `formatMoney``formatCount``formatHours` 格式化助教数据
- `board-finance.wxml`:未引入 `format.wxs`(财务看板数据在 TS 层预格式化后传入模板)
- `board-customer.wxml`:引入 `format.wxs`,使用 `fmt.safe()` 兜底
### 证据
DISPLAY-STANDARDS.md 金额规范:
```markdown
| 场景 | 格式 | 示例 |
|---|---|---|
| 正常金额 | `¥N,NNN`(千分位逗号,无小数) | `¥12,680` |
| 负数金额 | `-¥N,NNN`(负号在 ¥ 前) | `-¥368` |
| 零值 | `¥0` | `¥0` |
| 空值 / undefined | `--` | `--` |
```
money.ts 核心函数:
```typescript
export function formatMoney(value: number | null | undefined): string {
if (value === null || value === undefined) return '--'
if (value === 0) return '¥0'
const abs = Math.round(Math.abs(value))
const formatted = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return value < 0 ? `${formatted}` : `¥${formatted}`
}
```
format.wxs 金额函数WXS 版,逻辑一致):
```javascript
function money(value) {
if (value === undefined || value === null) return '--'
if (value === 0) return '¥0'
// ... 千分位处理
return (value < 0 ? '-¥' : '¥') + result
}
```
board-finance.ts 使用示例:
```typescript
import { formatMoney } from '../../utils/money'
// ...
wine: formatMoney(row.liquor?.value),
table: formatMoney(row.tableFee?.value),
```
### 建议
无。格式化规范已完整覆盖:
- 设计文档DISPLAY-STANDARDS.md定义了规则
- TS 工具函数money.ts供 JS 层使用
- WXS 工具函数format.wxs供 WXML 模板使用
- 看板页面已实际调用格式化函数

View File

@@ -0,0 +1,65 @@
# P9→NS1/RNS1 缺失项 #1客户详情页分段加载策略
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🔴 高
- 后端采用单 API 返回全部数据(无分段端点),但各扩展模块有独立 try/except 优雅降级;前端无 skeleton 占位,仅有全局 loading toast。
## 详细审查
### 审查范围
- `apps/backend/app/routers/xcx_customers.py` — CUST-1 路由
- `apps/backend/app/services/customer_service.py``get_customer_detail()` 实现
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts` — 前端加载逻辑
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 前端模板
### 发现
1. **后端:单 API无分段端点**
- `GET /api/xcx/customers/{customer_id}` 一次性返回全部数据(基本信息 + Banner + AI 洞察 + 关联助教 + 最亲密助教 + 消费记录 + 备注)
- 无独立的 `/ai-insight``/notes``/records` 等子端点
- P9 定义的"基本信息→消费汇总→AI 洞察→消费记录→备注"分段加载策略未实现
2. **后端:优雅降级已实现**
- 各扩展模块(`ai_insight``retention_clues``notes``consumption_records``coach_tasks``favorite_coaches`)均有独立 try/except失败时降级为空默认值
- 核心字段member_info失败直接 500符合预期
3. **前端:无 skeleton 占位**
- 加载态为全局 `g-toast-loading`(圆形 loading + "加载中..."文字),非 P9 定义的分段 skeleton
- `loadDetail()` 调用单个 `fetchCustomerDetail(id)` 后一次性 setData
- 无分段渲染逻辑(先展示基本信息,再逐步加载扩展模块)
### 证据
后端 `get_customer_detail()` 一次性返回所有模块:
```python
return {
"id": customer_id, "name": name, "phone": phone, ...
"balance": balance, "consumption_60d": consumption_60d, ...
"ai_insight": ai_insight,
"coach_tasks": coach_tasks,
"favorite_coaches": favorite_coaches,
"retention_clues": retention_clues,
"consumption_records": consumption_records,
"notes": notes,
}
```
前端加载逻辑(无分段):
```typescript
async loadDetail(id?: string) {
this.setData({ pageState: 'loading' })
try {
if (id) {
const detail = await fetchCustomerDetail(id)
// 一次性 setData
}
this.setData({ pageState: 'normal' })
} catch { ... }
}
```
### 建议(如未完全解决)
1. **短期**:前端可在单 API 返回后,先渲染 Banner 区域,再用 `nextTick``setTimeout` 分批 setData 扩展模块,减少首屏白屏时间
2. **中期**:为各扩展模块添加 skeleton 占位组件(参考 TDesign `t-skeleton`
3. **长期**:后端拆分为多个子端点(`/basic``/ai-insight``/records` 等),前端并行请求 + 分段渲染

View File

@@ -0,0 +1,60 @@
# P9→NS1/RNS1 缺失项 #2助教详情页档位进度时间轴的视觉规范
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 已实现 `perf-progress-bar` 进度条组件(含渐变填充、刻度标记、高光/火花动画),但非 P9 定义的"时间轴"样式,缺少当前档位高亮节点和升档动画。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_coaches.py``tier_nodes` 字段定义
- `apps/backend/app/services/coach_service.py``_build_tier_nodes()` 实现
- `apps/miniprogram/miniprogram/components/perf-progress-bar/` — 进度条组件
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — 绩效概览区域
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts` — 进度条数据构建
### 发现
1. **后端tier_nodes 数据已返回**
- `CoachDetailResponse.tier_nodes: list[float]` 已定义
- `_build_tier_nodes()` 从配置表读取档位节点
2. **前端:进度条组件已实现,但非时间轴**
- `perf-progress-bar` 组件实现了水平进度条,含:
- 渐变填充条(`ppb-fill` + `ppb-gradient-bar`
- 刻度标记(`ppb-ticks`,由 `ticks` 数组动态渲染)
- 高光动画(`ppb-shine`)和火花动画(`ppb-spark`
- 当前档位高亮(`ppb-tick--done` class
- 但这是**水平进度条**,非 P9 定义的**垂直时间轴**样式
- 缺少 P9 定义的"档位节点"tierNodes的时间轴展示如里程碑节点、连接线、当前位置标记
3. **动画已实现但非"升档动画"**
- 高光shine和火花spark是循环播放的装饰动画
- 非 P9 定义的"升档时触发的庆祝动画"
4. **前端 tier_nodes 未使用 API 数据**
- `coach-detail.ts``tierNodes` 硬编码为 `[0, 100, 130, 160, 190, 220]`,注释标注 "Mock实际由接口返回"
- API 返回的 `tier_nodes` 未被前端消费
### 证据
前端硬编码 tierNodes
```typescript
const tierNodes = [0, 100, 130, 160, 190, 220] // Mock实际由接口返回
```
进度条组件刻度渲染(水平进度条,非时间轴):
```html
<view class="ppb-ticks">
<text wx:for="{{ticks}}" wx:key="value"
class="ppb-tick {{currentTier >= index ? 'ppb-tick--done' : ''}}">
{{item.label}}
</text>
</view>
```
### 建议(如未完全解决)
1. 将前端 `tierNodes` 改为使用 API 返回的 `detail.tierNodes` 数据
2. 如需时间轴样式,新建 `tier-timeline` 组件,展示垂直时间轴(里程碑节点 + 连接线 + 当前位置)
3. 添加升档动画:当 `currentTier` 变化时触发一次性庆祝效果

View File

@@ -0,0 +1,57 @@
# P9→NS1/RNS1 缺失项 #3消费记录 3 种类型的图标/颜色/标签样式映射
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🔴 高
- 台桌消费和商城消费有颜色区分(蓝色/绿色 header + 圆点),但充值类型在客户详情页 wxml 中缺少渲染模板;设计规范文档中无消费记录类型的视觉映射定义。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``ConsumptionRecord.type` 字段
- `apps/backend/app/services/customer_service.py``_build_consumption_records()` 实现
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 消费记录展示
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss` — 消费记录样式
- `docs/miniprogram-dev/design-system/` — 设计规范文档
### 发现
1. **后端type 字段已定义但实际只返回 "table"**
- Schema 定义 `type: str # table / shop / recharge`
- `_build_consumption_records()` 硬编码 `"type": "table"`,未根据实际数据区分商城/充值类型
- 前端 TypeScript 接口定义了 `type: "table" | "shop" | "recharge"` 三种类型
2. **前端:台桌和商城有视觉区分,充值缺失**
- 台桌消费(`type === 'table'`):蓝色 header`record-header-blue`+ 蓝色圆点(`record-dot-blue`
- 商城消费(`type === 'shop'`):绿色 header`record-header-green`+ 绿色圆点(`record-dot-green`
- 充值(`type === 'recharge'`**wxml 中无对应的渲染模板**`wx:elif` 链中缺少 recharge 分支)
- Mock 数据中有 `{ type: 'recharge', rechargeAmount: 0 }` 但无对应 UI
3. **设计规范文档中无消费记录类型映射**
- `VI-DESIGN-SYSTEM.md``DISPLAY-STANDARDS.md` 中未定义消费记录类型的图标/颜色/标签映射
- 无统一的类型→视觉映射表
### 证据
后端硬编码 type 为 "table"
```python
result.append({
"type": "table", # 始终为 table未区分 shop/recharge
...
})
```
前端 wxml 缺少 recharge 分支:
```html
<view class="record-card" wx:if="{{item.type === 'table'}}">...</view>
<view class="record-card" wx:elif="{{item.type === 'shop'}}">...</view>
<!-- 缺少 wx:elif="{{item.type === 'recharge'}}" -->
```
### 建议(如未完全解决)
1. **后端**`_build_consumption_records()` 根据结算单类型字段区分 table/shop/recharge
2. **前端**:添加 recharge 类型的渲染模板(建议橙色/金色 header充值图标
3. **设计规范**:在 `VI-DESIGN-SYSTEM.md` 中添加消费记录类型映射表:
- 台桌消费:🎱 蓝色(`#3b82f6`
- 商城消费:🛒 绿色(`#22c55e`
- 充值:💰 橙色/金色(`#f59e0b`

View File

@@ -0,0 +1,73 @@
# P9→NS1/RNS1 缺失项 #4备注 AI 评分的星级展示规范
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- `star-rating` 组件已实现0-10 分→0-5 星,支持半星),设计规范文档已定义评分展示规范,但客户详情页备注区域未使用该组件,后端备注 API 未返回 `ai_score` 字段。
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/components/star-rating/` — 星级评分组件
- `apps/backend/app/services/customer_service.py``_build_notes()` 实现
- `apps/backend/app/schemas/xcx_customers.py``CustomerNote` schema
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 备注区域
- `docs/miniprogram-dev/design-system/DISPLAY-STANDARDS-2.md` — 评分展示规范
### 发现
1. **组件层star-rating 已完整实现**
- 接收 `score`0-10内部转换为 0-5 星,支持半星
- 使用 TDesign `t-rate` 组件渲染,金黄色(`#fbbf24`
- 支持只读模式
2. **设计规范:评分展示规范已定义**
- `DISPLAY-STANDARDS-2.md` 第 8 节定义了:
- 分制约定(后端 0-10 分UI 0-5 星)
- 展示场景(任务卡片、备注满意度等)
- 半星映射规则(`scoreToHalfStar()`
- 未评分态处理(`score=0/null/undefined` → 展示 `--`
3. **后端:备注 API 未返回 ai_score**
- `_build_notes()` 查询 `biz.notes` 表,只返回 `id``tag_label``created_at``content`
- `CustomerNote` schema 无 `ai_score` / `score` 字段
- 数据库 `biz.notes` 表是否有 `ai_score` 列未确认
4. **前端:备注区域未使用 star-rating 组件**
- `customer-detail.wxml` 备注列表只展示 `tagLabel``createdAt``content`
-`<star-rating>` 组件引用
- `customer-detail.json` 未注册 `star-rating` 组件(需确认)
### 证据
后端 `_build_notes()` 无 score 字段:
```python
return [
{
"id": r[0],
"tag_label": r[1] or "",
"created_at": r[2].isoformat() if r[2] else "",
"content": r[3] or "",
# 缺少 ai_score / score 字段
}
for r in rows
]
```
前端备注展示无星级:
```html
<view class="note-item" wx:for="{{sortedNotes}}" wx:key="id">
<view class="note-top">
<text class="note-author">{{item.tagLabel}}</text>
<text class="note-time">{{item.createdAt}}</text>
</view>
<text class="note-content">{{item.content}}</text>
<!-- 缺少 <star-rating score="{{item.score}}" /> -->
</view>
```
### 建议(如未完全解决)
1. **后端**`_build_notes()` 查询中增加 `ai_score` 字段(如 `biz.notes` 表有该列)
2. **Schema**`CustomerNote` 添加 `score: int | None = None`
3. **前端**:备注卡片中添加 `<star-rating score="{{item.score}}" size="32rpx" />`,未评分时展示 `--`
4. **tooltip**P9 定义的评分说明 tooltip 需额外实现(小程序原生不支持 tooltip可用长按弹窗替代

View File

@@ -0,0 +1,61 @@
# P9→NS1/RNS1 缺失项 #5客户详情页 Banner 区域的视觉设计
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- Banner 区域已实现完整的 4 字段布局(储值余额/60天消费/理想间隔/距今到店),含渐变背景、毛玻璃统计栏、颜色区分。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py` — Banner 字段定义
- `apps/backend/app/services/customer_service.py` — Banner 字段查询
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — Banner 区域模板
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss` — Banner 区域样式
### 发现
1. **后端4 个 Banner 字段已定义并实现**
- `balance: float | None` — 储值余额
- `consumption_60d: float | None` — 60 天消费
- `ideal_interval: int | None` — 理想间隔
- `days_since_visit: int | None` — 距今到店
- 各字段独立 try/except查询失败降级为 `null`
2. **前端Banner 布局已完整实现**
- SVG 渐变背景图(`banner-bg-dark-gold-aurora.svg`
- 客户头部信息(头像 + 姓名 + 手机号查看/复制)
- 4 格统计栏(`banner-stats`),毛玻璃效果(`backdrop-filter: blur(8px)`
- 颜色区分:余额绿色(`stat-green #6ee7b7`)、距今到店琥珀色(`stat-amber #fcd34d`
3. **布局与 RNS1 T2-1 定义一致**
- 4 个字段均已展示,布局为水平等分 + 分隔线
### 证据
前端 Banner 统计区域:
```html
<view class="banner-stats">
<view class="stat-item stat-border">
<text class="stat-value stat-green">¥{{fmt.safe(detail.balance)}}</text>
<text class="stat-label">储值余额</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">¥{{fmt.safe(detail.consumption60d)}}</text>
<text class="stat-label">60天消费</text>
</view>
<view class="stat-item stat-border">
<text class="stat-value">{{fmt.safe(detail.idealInterval)}}</text>
<text class="stat-label">理想间隔</text>
</view>
<view class="stat-item">
<text class="stat-value stat-amber">{{fmt.safe(detail.daysSinceVisit)}}</text>
<text class="stat-label">距今到店</text>
</view>
</view>
```
### 建议(如未完全解决)
无重大缺失。可考虑的微调:
- `ideal_interval` 后端当前返回 `None`,需确认数据源是否已接入
- 可添加单位后缀(如"天"、"元")提升可读性

View File

@@ -0,0 +1,55 @@
# P9→NS1/RNS1 缺失项 #6AI 洞察卡片的展示规范
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- AI 洞察卡片已实现标题/摘要/策略列表展示,但缺少 P9 定义的"展开详情"交互和"刷新"按钮。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``AiInsight` schema
- `apps/backend/app/services/customer_service.py``_build_ai_insight()` 实现
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — AI 洞察区域
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss` — AI 洞察样式
### 发现
1. **后端AI 洞察数据已实现**
- `AiInsight` schema 含 `summary: str``strategies: list[AiStrategy]`
- `_build_ai_insight()``biz.ai_cache``cache_type='app4_analysis'`)读取缓存的 AI 分析结果
- 无缓存时返回空默认值
2. **前端:卡片展示已实现**
- 渐变背景卡片(紫色渐变 `#667eea → #764ba2`
- AI 图标 + "AI 智能洞察"标题
- 摘要文本展示
- 策略列表(带颜色左边框,支持 green/amber/pink 三色)
3. **缺失交互**
- **无"展开详情"功能**P9 定义了摘要可展开查看完整分析,当前实现直接展示全部内容
- **无"刷新"按钮**P9 定义了手动触发 AI 重新分析的刷新按钮,当前无此交互
- 后端也无对应的"触发 AI 重新分析"端点
### 证据
前端 AI 洞察卡片(无展开/刷新交互):
```html
<view class="ai-insight-card">
<view class="ai-insight-header">
<view class="ai-icon-box">...</view>
<text class="ai-insight-label">AI 智能洞察</text>
<!-- 缺少刷新按钮 -->
</view>
<view class="ai-insight-summary-v">
<text class="ai-insight-summary">{{fmt.safe(aiInsight.summary)}}</text>
<!-- 无展开/折叠控制 -->
</view>
...
</view>
```
### 建议(如未完全解决)
1. **展开详情**:如摘要较长,可添加 `wx:if="{{aiExpanded}}"` 控制展示行数,默认 3 行 + "查看更多"
2. **刷新按钮**:在 header 右侧添加刷新图标,点击调用后端 AI 分析端点
3. **后端**:添加 `POST /api/xcx/customers/{id}/ai-refresh` 端点触发重新分析

View File

@@ -0,0 +1,58 @@
# P9→NS1/RNS1 缺失项 #7关联助教任务列表的展示规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 关联助教任务列表已完整实现:任务类型标签(带颜色映射)、状态标签(置顶/已放弃)、服务统计指标(服务次数/总时长/次均时长),布局和交互完整。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``CoachTask` schema
- `apps/backend/app/services/customer_service.py``_build_coach_tasks()` 实现
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 助教任务区域
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss` — 助教任务样式
### 发现
1. **后端:任务数据已完整实现**
- `CoachTask` schema 含:`name``level``level_color``task_type``task_color``bg_class``status``last_service``metrics`
- `_build_coach_tasks()``biz.coach_tasks` 查询,关联助教信息和绩效等级
- 任务类型通过 `TASK_TYPE_MAP` 映射(含 label/color/bg_class
- 近 60 天统计指标(服务次数/总时长/次均时长)已计算
2. **前端:展示已完整实现**
- 任务类型标签:`type-red`/`type-pink`/`type-orange`/`type-teal` 四色映射
- 状态标签:📌 置顶 / ❌ 已放弃
- 助教等级标签:使用 `coach-level-tag` 组件
- 服务统计3 个指标卡片(服务次数/总时长/次均时长)
- 上次服务时间展示
- 卡片背景色根据助教等级区分(`coach-card-red`/`pink`/`orange`/`teal`
### 证据
前端任务卡片展示:
```html
<view class="coach-task-card {{item.bgClass}}" wx:for="{{coachTasks}}" wx:key="index">
<view class="coach-task-top">
<view class="coach-name-row">
<text class="coach-name">{{item.name}}</text>
<coach-level-tag level="{{item.level}}" />
</view>
<view class="coach-task-right">
<text class="coach-task-type type-{{item.taskColor}}">{{item.taskType}}</text>
<text class="coach-task-status status-{{item.status}}" wx:if="{{item.status !== 'normal'}}">...</text>
</view>
</view>
<text class="coach-last-service">上次服务:{{item.lastService}}</text>
<view class="coach-metrics">
<view class="coach-metric" wx:for="{{item.metrics}}" wx:for-item="m" wx:key="label">
<text class="metric-label">{{m.label}}</text>
<text class="metric-value">{{m.value}}</text>
</view>
</view>
</view>
```
### 建议(如未完全解决)
无重大缺失。当前实现已覆盖 P9 定义的任务类型图标(用颜色标签替代)、状态标签、服务统计。

View File

@@ -0,0 +1,60 @@
# P9→NS1/RNS1 缺失项 #8最亲密助教的展示规范
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 最亲密助教卡片已实现emoji + 姓名 + 关系指数 + 统计指标),但缺少 P9 定义的"关系指数可视化"(如仪表盘/环形图)和"课时统计图表"。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``FavoriteCoach` schema
- `apps/backend/app/services/customer_service.py``_build_favorite_coaches()` 实现
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 最亲密助教区域
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss` — 最亲密助教样式
### 发现
1. **后端:数据已完整实现**
- `FavoriteCoach` schema 含:`emoji``name``relation_index``index_color``bg_class``stats`
- `_build_favorite_coaches()` 从关系指数表查询,四级 emoji 映射(💖🧡💛💙)
- 统计指标:基础课时收入、激励课时收入、上课次数、总时长
2. **前端:卡片展示已实现**
- emoji + 姓名 + 关系指数数值(带颜色)
- "近60天"时间范围标签
- 4 个统计指标卡片
- 卡片背景色根据关系等级区分(`fav-card-pink`/`fav-card-amber`
3. **缺失的可视化元素**
- **无关系指数可视化**P9 定义了关系指数的可视化展示(如仪表盘、环形进度条),当前仅展示数值
- **无课时统计图表**P9 定义了课时统计的图表展示(如柱状图/折线图),当前仅展示数值列表
### 证据
前端最亲密助教卡片(纯数值展示,无图表):
```html
<view class="fav-coach-card {{item.bgClass}}" wx:for="{{favoriteCoaches}}" wx:key="index">
<view class="fav-coach-top">
<view class="fav-coach-name">
<text class="fav-emoji">{{item.emoji}}</text>
<text class="fav-name">{{item.name}}</text>
</view>
<view class="fav-index">
<text class="fav-index-label">关系指数</text>
<text class="fav-index-value text-{{item.indexColor}}">{{fmt.safe(item.relationIndex)}}</text>
</view>
</view>
<view class="fav-stats">
<view class="fav-stat" wx:for="{{item.stats}}" wx:for-item="s" wx:key="label">
<text class="fav-stat-label">{{s.label}}</text>
<text class="fav-stat-value">{{s.value}}</text>
</view>
</view>
</view>
```
### 建议(如未完全解决)
1. **关系指数可视化**:可用 CSS 环形进度条或 `wx-canvas` 绘制仪表盘,展示 0-10 刻度上的当前位置
2. **课时统计图表**:如需图表,可引入 `wx-charts` 或用 CSS 柱状图展示近期课时趋势
3. **优先级评估**:当前数值展示已满足基本信息需求,图表可作为后续优化项

View File

@@ -0,0 +1,57 @@
# P9→NS1/RNS1 缺失项 #9消费记录中助教明细子列表的展开/折叠交互
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 客户详情页消费记录中的助教明细子列表已实现展示(网格布局),但采用的是"始终展开"策略而非"展开/折叠"交互。考虑到数据量通常较小1-2 位助教),这是合理的设计决策。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``ConsumptionRecord.coaches` 字段
- `apps/backend/app/services/customer_service.py``_build_consumption_records()` coaches 构建
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 消费记录助教明细
### 发现
1. **后端coaches 子数组已实现**
- `ConsumptionRecord.coaches: list[CoachServiceItem]` 已定义
- `CoachServiceItem` 含:`name``level``level_color``course_type``hours``perf_hours``fee`
- `_build_consumption_records()` 根据 `assistant_pd_money`/`assistant_cx_money` 构建 coaches 列表
2. **前端:助教明细已展示(始终展开)**
- 使用 `record-coach-grid`2 列网格布局)展示助教卡片
- 每张卡片含:助教姓名 + 等级标签 + 课程类型 + 时长 + 定档绩效 + 费用
- 条件渲染:`wx:if="{{item.coaches && item.coaches.length > 0}}"`
- 台桌消费和商城消费均有助教明细展示
3. **无展开/折叠交互**
- 助教明细始终展开显示,无折叠按钮
- 考虑到每条消费记录通常只有 1-2 位助教,始终展开是合理的
- P9 定义的展开/折叠交互更适用于助教数量较多的场景
### 证据
前端助教明细展示(始终展开):
```html
<view class="record-coaches" wx:if="{{item.coaches && item.coaches.length > 0}}">
<view class="record-coach-grid">
<view class="record-coach-card" wx:for="{{item.coaches}}" wx:for-item="c" wx:key="name">
<view class="record-coach-name-row">
<text class="record-coach-name">{{c.name}}</text>
<coach-level-tag level="{{c.level}}" />
</view>
<text class="record-coach-type">{{c.courseType}} · {{c.hours}}</text>
<view class="record-coach-bottom">
<text class="record-coach-perf" wx:if="{{c.perfHours}}">定档绩效:{{c.perfHours}}</text>
<text class="record-coach-fee">¥{{c.fee}}</text>
</view>
</view>
</view>
</view>
```
### 建议(如未完全解决)
当前实现已满足需求。如后续助教数量增多(>3 位),可考虑:
1. 默认展示前 2 位,超出部分折叠
2. 添加"展开全部"/"收起"按钮

View File

@@ -0,0 +1,79 @@
# P9→NS1/RNS1 缺失项 #10助教详情页 TOP20 客户列表的排序规则和展示字段
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- TOP20 客户列表已完整实现:后端通过 `fdw_queries.get_coach_top_customers()` 获取排序后的数据limit=20前端展示含头像、姓名、心形 emoji、关系分、服务次数、储值余额、消费金额支持展开/收起和点击跳转。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_coaches.py``TopCustomer` schema
- `apps/backend/app/services/coach_service.py``_build_top_customers()` 实现
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — TOP20 客户区域
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts` — 展开/收起逻辑
### 发现
1. **后端:排序和数据已完整实现**
- `_build_top_customers()` 调用 `fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=20)`
- 排序由 `fdw_queries` 层的 SQL 决定(按关系指数/服务次数等排序)
- 关系指数通过 `fdw_queries.get_relation_index()` 获取
- 四级 heart emoji 映射P6 AC3>8.5→💖 / >7→🧡 / >5→💛 / ≤5→💙
- 展示字段:`id``name``initial``avatar_gradient``heart_emoji``relation_score``score_color``service_count``balance``consume`
2. **前端:展示已完整实现**
- 默认展示前 5 位,点击"查看更多"展开全部
- 每行含:头像(渐变色首字母)+ 姓名 + 心形 emoji + 关系分(带颜色)+ 服务次数 + 储值余额 + 消费金额
- 点击跳转客户详情页
- 右侧箭头图标
3. **后端 schema 字段名不一致(小问题)**
- Schema 定义 `score: str`,但后端实际返回 `relation_score`
- CamelModel 自动转换为 camelCase前端使用 `item.score`
### 证据
后端 TOP 客户构建(含排序和 emoji 映射):
```python
raw = fdw_queries.get_coach_top_customers(conn, site_id, coach_id, limit=20)
# ...
result.append({
"id": mid or 0,
"name": name,
"heart_emoji": heart_emoji, # 四级映射
"relation_score": f"{score:.2f}",
"score_color": score_color,
"service_count": cust.get("service_count", 0),
"balance": _format_currency(balance),
"consume": _format_currency(consume),
})
```
前端展示(默认 5 条 + 展开):
```html
<view class="top-customer-item"
wx:for="{{topCustomers}}" wx:key="id"
wx:if="{{topCustomersExpanded || index < 5}}"
data-id="{{item.id}}" bindtap="onCustomerTap">
<view class="top-customer-avatar avatar-{{item.avatarGradient}}">
<text>{{item.initial}}</text>
</view>
<view class="top-customer-info">
<view class="top-customer-name-row">
<text>{{item.name}}</text>
<text>{{item.heartEmoji}}</text>
<text>{{fmt.safe(item.score)}}</text>
</view>
<view class="top-customer-stats">
<text>服务 {{fmt.safe(item.serviceCount)}}次</text>
<text>储值 {{fmt.safe(item.balance)}}</text>
<text>消费 {{fmt.safe(item.consume)}}</text>
</view>
</view>
</view>
```
### 建议(如未完全解决)
1. **前端数据绑定**:当前 `coach-detail.ts``topCustomers` 使用 mock 数据(空字符串),需确认 API 数据已正确绑定
2. **Schema 字段名**`TopCustomer.score` 与后端返回的 `relation_score` 需确认 CamelModel 映射是否正确

View File

@@ -0,0 +1,67 @@
# P9→NS1/RNS1 缺失项 #11助教详情页历史月份统计的图表展示
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟠 中
- 历史月份统计已实现为表格形式6 个月数据,含客户数/回访召回/业绩时长/工资),但非 P9 定义的折线图/柱状图可视化形式。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_coaches.py``HistoryMonth` schema
- `apps/backend/app/services/coach_service.py``_build_history_months()` 实现
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — 历史月份区域
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts` — 数据绑定
### 发现
1. **后端:数据已完整实现**
- `HistoryMonth` schema 含:`month``estimated``customers``hours``salary``callback_done``recall_done`
- `_build_history_months()` 查询最近 6 个月数据:
- 工时/工资:`fdw_queries.get_salary_calc_multi_months()`
- 客户数:`fdw_queries.get_monthly_customer_count()`
- 回访/召回完成数:`biz.coach_tasks` 聚合
- 本月标记 `estimated=True`
- 格式化:`"22人"``"87.5h"``"¥6,950"`
2. **前端:表格展示已实现**
- 5 列表格:月份 / 服务客户 / 访/召完成 / 业绩时长 / 工资
- 本月行高亮(`history-row-current`+ "预估"标签
- 本月业绩时长蓝色(`text-primary`)、工资绿色(`text-success`
3. **缺失的图表可视化**
- P9 定义了折线图/柱状图展示趋势,当前仅为纯表格
- 无数据趋势可视化(如工时趋势折线、工资柱状图)
- 小程序环境下图表实现需要 `wx-canvas` 或第三方图表库
### 证据
前端表格展示:
```html
<view class="history-table">
<view class="history-thead">
<text class="history-th history-th-left">月份</text>
<text class="history-th">服务客户</text>
<text class="history-th">访/召完成</text>
<text class="history-th">业绩时长</text>
<text class="history-th">工资</text>
</view>
<view class="history-row {{index === 0 ? 'history-row-current' : ''}}"
wx:for="{{historyMonths}}" wx:key="month">
<view class="history-td history-td-left">
<text>{{item.month}}</text>
<text class="history-est" wx:if="{{item.estimated}}">预估</text>
</view>
<text class="history-td">{{fmt.safe(item.customers)}}</text>
<text class="history-td">{{fmt.safe(item.callbackDone)}} | {{fmt.safe(item.recallDone)}}</text>
<text class="history-td">{{fmt.safe(item.hours)}}</text>
<text class="history-td">{{fmt.safe(item.salary)}}</text>
</view>
</view>
```
### 建议(如未完全解决)
1. **短期**:表格形式已满足数据展示需求,可作为 MVP
2. **中期**在表格上方添加迷你折线图sparkline展示工时/工资趋势
3. **长期**:引入 `wx-charts``echarts-for-weixin` 实现完整图表
4. **注意**:前端 `historyMonths` 当前使用 mock 数据(空字符串),需确认 API 数据已正确绑定

View File

@@ -0,0 +1,68 @@
# P9→NS1/RNS1 缺失项 #12客户服务记录页的月度统计汇总展示
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟡 低
- 月度统计汇总已完整实现:后端返回 `month_count`/`month_hours`,前端展示为三栏统计条(本月服务次数/服务时长/关系指数),含月份切换器和格式化展示。
## 详细审查
### 审查范围
- `apps/backend/app/schemas/xcx_customers.py``CustomerRecordsResponse` schema
- `apps/backend/app/services/customer_service.py``get_customer_records()` 实现
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.wxml` — 月度统计区域
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` — 数据处理
### 发现
1. **后端:月度统计数据已实现**
- `CustomerRecordsResponse``month_count: int``month_hours: float`
- `get_customer_records()` 调用 `_get_month_aggregation()` 计算当月汇总(非分页子集)
- 返回 `total_service_count`(跨月累计)
2. **前端:月度统计展示已完整实现**
- 月份切换器(上月/下月箭头 + 月份标签)
- 三栏统计条:
- 本月服务:`monthCount`(如 "3次"
- 服务时长:`monthHours`(如 "12.5h"),蓝色高亮
- 关系指数:`monthRelation`(如 "0.85"),橙色高亮
- 使用 `formatCount()``formatHours()` 格式化函数
- 月份切换时重新请求 API`loadMonthRecords()`
3. **月份切换交互已实现**
- `onPrevMonth()` / `onNextMonth()` 切换月份
- 边界控制:`canPrev`/`canNext` 禁用按钮
- 切换时显示 loading 状态
### 证据
前端月度统计展示:
```html
<view class="month-summary">
<view class="summary-item">
<text class="summary-label">本月服务</text>
<text class="summary-value">{{fmt.safe(monthCount)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">服务时长</text>
<text class="summary-value value-primary">{{fmt.safe(monthHours)}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">关系指数</text>
<text class="summary-value value-warning">{{fmt.safe(monthRelation)}}</text>
</view>
</view>
```
后端月度汇总查询:
```python
month_count, month_hours = _get_month_aggregation(
conn, site_id, customer_id, year, month, table
)
```
### 建议(如未完全解决)
无重大缺失。可考虑的微调:
- `monthRelation`(关系指数)当前前端硬编码为 `'0.85'`,后端 `CustomerRecordsResponse` 中无 `relation_index` 字段返回,需确认数据源

View File

@@ -0,0 +1,54 @@
# P9→NS1/RNS1 缺失项 #13助教详情页任务分组的视觉区分
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟢 低
- 后端按 active/inactive/abandoned 三组返回数据,前端为每组定义了差异化视觉样式
## 详细审查
### 审查范围
- `apps/backend/app/services/coach_service.py``_build_task_groups()`
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — 任务执行区域
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss` — 任务样式
### 发现
1. 后端 `_build_task_groups()` 按 status 分为三组:`visible_tasks`active`hidden_tasks`inactive`abandoned_tasks`abandoned数据结构清晰
2. 前端 WXML 中三组有明确的视觉区分:
- `visibleTasks`:直接展示,每个 task-item 带 `task-item-{{item.typeClass}}` 样式(如 `task-item-high-priority``task-item-priority``task-item-relationship``task-item-callback`),有彩色背景和边框
- `hiddenTasks`:在 `tasksExpanded` 展开后显示,样式与 visible 一致但位于 `task-list-extra` 区域
- `abandonedTasks`:使用 `task-item-abandoned` 样式,灰色背景 `#fafafa``opacity: 0.55`、客户名带删除线 `text-decoration: line-through`
3. WXSS 中定义了完整的颜色映射:
- `task-item-high-priority`:红色系 `rgba(254, 226, 226, 0.6)`
- `task-item-priority`:橙色系 `rgba(255, 237, 213, 0.4)`
- `task-item-relationship`:粉色系 `rgba(252, 231, 243, 0.4)`
- `task-item-callback`:青色系 `rgba(204, 251, 241, 0.4)`
- `task-item-abandoned`:灰色 + 半透明
### 证据
```html
<!-- coach-detail.wxml — active 任务 -->
<view class="task-item task-item-{{item.typeClass}}" wx:for="{{visibleTasks}}" ...>
<text class="task-tag-text {{item.typeClass}}">{{item.typeLabel}}</text>
...
</view>
<!-- abandoned 任务 -->
<view class="task-item task-item-abandoned" wx:for="{{abandonedTasks}}" ...>
<text class="task-abandoned-name">{{item.customerName}}</text>
<text class="task-abandoned-reason">{{item.reason}}</text>
</view>
```
```css
/* coach-detail.wxss */
.task-item-abandoned {
background: #fafafa;
border-color: #eeeeee;
opacity: 0.55;
}
.task-abandoned-name {
text-decoration: line-through;
color: #c5c5c5;
}
```

View File

@@ -0,0 +1,54 @@
# P9→NS1/RNS1 缺失项 #14客户详情页维客线索的展示规范
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟢 低
- clue-card 组件已实现完整的卡片布局和 category 颜色映射6 种配色),后端从 `member_retention_clue` 表查询数据
## 详细审查
### 审查范围
- `apps/backend/app/services/customer_service.py``_build_retention_clues()`
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 维客线索区域
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.ts` — 组件属性
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.wxml` — 组件模板
- `apps/miniprogram/miniprogram/components/clue-card/clue-card.wxss` — 组件样式
### 发现
1. 后端 `_build_retention_clues()``public.member_retention_clue` 表查询 `clue_type``clue_text`,按 `created_at` 倒序
2. 前端 `clue-card` 组件接收 `tag``category`(颜色类名)、`emoji``title``source``content` 六个属性
3. WXSS 中定义了 VI 规范 2.1 的六种客户标签配色:
- `clue-tag-primary`:蓝色(客户基础)
- `clue-tag-success`:绿色(消费习惯)
- `clue-tag-orange`:橙色(玩法偏好)
- `clue-tag-gold` / `clue-tag-warning`:金色(促销偏好)
- `clue-tag-purple`:紫色(社交关系)
- `clue-tag-error`:红色(重要反馈)
- `clue-tag-pink`:粉色(社交关系别名)
4. 卡片布局包含标签方块72rpx×72rpx、文本内容区、来源标注、可选详情描述
### 证据
```html
<!-- customer-detail.wxml -->
<clue-card
wx:for="{{clues}}" wx:key="index"
tag="{{item.category}}"
category="{{item.categoryColor}}"
emoji=""
title="{{item.text}}"
source="By:{{item.source}}"
content="{{item.detail}}"
/>
```
```css
/* clue-card.wxss — VI 规范 2.1 六种配色 */
.clue-tag-primary { background: rgba(0, 82, 217, 0.10); color: #0052d9; }
.clue-tag-success { background: rgba(0, 168, 112, 0.10); color: #00a870; }
.clue-tag-orange { background: rgba(237, 123, 47, 0.12); color: #ed7b2f; }
.clue-tag-purple { background: rgba(123, 97, 255, 0.10); color: #7b61ff; }
.clue-tag-error { background: rgba(227, 77, 89, 0.10); color: #e34d59; }
```
### 建议
- 后端 `_build_retention_clues()` 当前仅返回 `type``text`,未返回 `category`(颜色类名)和 `source`。前端 `customer-detail.ts``clues` 数据目前是 mock 硬编码的 `categoryColor`。建议后端补充 `clue_type → categoryColor` 的映射逻辑,或在前端建立映射表。

View File

@@ -0,0 +1,33 @@
# P9→NS1/RNS1 缺失项 #15客户详情页的分享功能
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 客户详情页未实现任何分享功能,无 `onShareAppMessage`、无分享按钮
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts` — 页面逻辑
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 页面模板
### 发现
1. `customer-detail.ts` 中未定义 `onShareAppMessage()``onShareTimeline()` 生命周期方法
2. `customer-detail.wxml` 中无 `<button open-type="share">` 按钮
3. 底部操作栏仅有"问问助手"和"备注"两个按钮,无分享入口
4. 全文搜索 `share` 关键词在 customer-detail 目录下无匹配
### 证据
```html
<!-- customer-detail.wxml — 底部操作栏,无分享按钮 -->
<view class="bottom-bar safe-area-bottom">
<view class="btn-chat" bindtap="onStartChat">问问助手</view>
<view class="btn-note" bindtap="onAddNote">备注</view>
</view>
```
### 建议
- 如需实现分享功能,需在 `customer-detail.ts` 中添加 `onShareAppMessage()` 方法
- 可在底部操作栏或页面右上角添加分享按钮
- 分享内容应包含客户基本信息脱敏后的姓名、ID接收方打开后跳转到客户详情页
- 注意:分享内容中不应包含敏感信息(完整手机号、余额等)

View File

@@ -0,0 +1,41 @@
# P9→NS1/RNS1 缺失项 #16助教详情页的联系方式展示
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 助教详情页未展示任何联系方式(电话/微信),后端也未返回相关字段
## 详细审查
### 审查范围
- `apps/backend/app/services/coach_service.py``get_coach_detail()` 返回字段
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — 页面模板
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts` — 页面逻辑
### 发现
1. 后端 `get_coach_detail()` 返回的字段中无 `phone``mobile``wechat` 等联系方式字段
2. 后端 `fdw_queries.get_assistant_info()` 返回的 `assistant_info` 中仅使用了 `name``avatar``level``skills``work_years``hire_date`
3. 前端 WXML 中 Banner 区域仅展示:头像、姓名、等级标签、技能标签、工龄、客户数
4. 全文搜索 `phone|mobile|wechat|微信|电话|联系` 在 coach-detail 目录下无匹配
### 证据
```python
# coach_service.py — get_coach_detail() 返回值,无联系方式
return {
"id": coach_id,
"name": assistant_info.get("name", ""),
"avatar": assistant_info.get("avatar", ""),
"level": ...,
"skills": ...,
"work_years": ...,
"customer_count": ...,
"hire_date": ...,
# 无 phone / wechat 字段
}
```
### 建议
- 如需展示联系方式,需确认数据来源(飞球 SaaS 是否提供助教手机号/微信号)
- 后端需在 `get_assistant_info()` 查询中增加联系方式字段
- 前端在 Banner 区域或"更多信息"卡片中添加联系方式展示
- 联系方式应做脱敏处理,提供"点击查看"交互

View File

@@ -0,0 +1,46 @@
# P9→NS1/RNS1 缺失项 #17消费记录的时间范围筛选
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- 仅支持按月切换(上/下月箭头),未实现自定义日期范围筛选
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` — 页面逻辑
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.wxml` — 页面模板
- `apps/backend/app/services/customer_service.py``get_customer_records()` API
### 发现
1. 前端仅实现了 `onPrevMonth()``onNextMonth()` 两个月份切换方法
2. WXML 中月份切换 UI 为左右箭头 + 月份标签(如"2026年3月"),无日期选择器
3. 后端 `get_customer_records()` 接收 `year``month` 参数,按月查询
4. 无自定义日期范围的 API 参数(如 `start_date``end_date`
5. 月份范围有边界控制:`minYearMonth`(数据起始)到 `maxYearMonth`(当前月)
### 证据
```html
<!-- customer-service-records.wxml — 仅月份切换 -->
<view class="month-switcher">
<view class="month-btn" bindtap="onPrevMonth">
<t-icon name="chevron-left" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn" bindtap="onNextMonth">
<t-icon name="chevron-right" />
</view>
</view>
```
```typescript
// customer-service-records.ts — 仅按月请求
async loadMonthRecords(customerId: string, year: number, month: number) { ... }
```
### 建议
- 如需自定义日期范围,需:
1. 前端添加日期范围选择器组件(如 TDesign `t-date-time-picker`
2. 后端 `get_customer_records()` 增加 `start_date` / `end_date` 可选参数
3. 月份切换和自定义范围可共存,自定义范围优先级更高
- 当前按月切换已满足基本需求,自定义范围为增强功能,优先级可后置

View File

@@ -0,0 +1,46 @@
# P9→NS1/RNS1 缺失项 #18详情页的返回按钮行为
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟡 低
- customer-service-records 页面有明确的 `navigateBack` 返回逻辑customer-detail 和 coach-detail 页面依赖小程序默认导航栏返回,未自定义返回行为
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts`
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml`
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts`
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml`
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts`
### 发现
1. `customer-service-records.ts` 定义了 `onBack()` 方法,调用 `wx.navigateBack()`
2. `customer-detail.ts``coach-detail.ts` 均未定义自定义返回方法
3. 两个详情页的 WXML 中均无自定义返回按钮,依赖微信小程序默认导航栏的返回按钮
4. 默认导航栏返回行为是 `navigateBack`(返回上一页),而非 `navigateTo`(返回列表页)
5. 如果用户通过分享链接直接进入详情页(页面栈为空),默认返回按钮无法返回列表页
### 证据
```typescript
// customer-service-records.ts — 有明确返回逻辑
onBack() {
wx.navigateBack()
}
// customer-detail.ts — 无返回方法
// coach-detail.ts — 无返回方法
```
### 建议
- 对于通过分享链接直接进入的场景,建议在详情页添加返回兜底逻辑:
```typescript
onBack() {
if (getCurrentPages().length > 1) {
wx.navigateBack()
} else {
wx.redirectTo({ url: '/pages/customer-list/customer-list' })
}
}
```
- 当前依赖默认导航栏返回在正常导航流程中可正常工作,仅在直接进入场景有问题

View File

@@ -0,0 +1,47 @@
# P9→NS1/RNS1 缺失项 #19客户详情页电话号码的脱敏展示
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟢 低
- 后端已实现 `_mask_phone()` 脱敏函数(中间 4 位用 `****`),前端实现了"点击查看/复制"交互
## 详细审查
### 审查范围
- `apps/backend/app/services/customer_service.py``_mask_phone()` 函数、`get_customer_detail()` 返回
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml` — 电话展示区域
- `apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts` — 电话交互逻辑
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` — 同样的脱敏逻辑
### 发现
1. 后端 `_mask_phone()` 实现:`phone[:3] + "****" + phone[-4:]`,如 `139****5678`
2. 后端 `get_customer_detail()` 同时返回 `phone`(脱敏)和 `phone_full`(完整),供前端按需展示
3. customer-detail 前端实现了 `phoneVisible` 状态切换:
- 默认显示脱敏号码 `138****5678`
- 点击"查看"按钮切换为完整号码
- 显示完整号码后按钮变为"复制",调用 `wx.setClipboardData`
4. customer-service-records 页面也实现了相同的脱敏+查看交互:
- 前端自行脱敏:`detail.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')`
- 同样有 `onTogglePhone()``onCopyPhone()` 方法
### 证据
```python
# customer_service.py
def _mask_phone(phone: str | None) -> str:
"""手机号脱敏139****5678 格式。"""
if not phone or len(phone) < 7:
return phone or ""
return f"{phone[:3]}****{phone[-4:]}"
```
```html
<!-- customer-detail.wxml -->
<text class="phone">{{phoneVisible ? detail.phone : '138****5678'}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
```
### 建议
- customer-detail.wxml 中脱敏号码硬编码为 `138****5678`,应改为使用后端返回的 `phone` 字段(已脱敏)
- 建议统一脱敏策略:要么全部由后端脱敏,要么全部由前端脱敏,避免两端重复实现

View File

@@ -0,0 +1,43 @@
# P9→NS1/RNS1 缺失项 #20助教详情页"收入明细"的展开/折叠交互
## 简要结论
- 状态:⚠️ 部分解决
- 风险等级:🟡 低
- 实现了本月/上月 Tab 切换交互,但不是 P9 定义的展开/折叠交互4 项收入明细始终全部展示
## 详细审查
### 审查范围
- `apps/backend/app/services/coach_service.py``_build_income()` 返回结构
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts``switchIncomeTab()`
- `apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml` — 收入明细区域
### 发现
1. 后端 `_build_income()` 返回 `this_month``last_month` 各 4 项:基础课时费、激励课时费、充值提成、酒水提成
2. 前端实现了 Tab 切换交互(`onIncomeTabTap``switchIncomeTab`),在"本月"和"上月"之间切换
3. 切换后重新计算合计金额并更新 `currentIncome``incomeTotal`
4. 收入明细列表始终全部展示 4 项,无展开/折叠逻辑
5. P9 定义的"展开/折叠"交互未实现——当前是 Tab 切换而非折叠面板
### 证据
```html
<!-- coach-detail.wxml — Tab 切换,非展开/折叠 -->
<view class="income-tabs">
<view class="income-tab {{incomeTab === 'this' ? 'active' : ''}}" data-tab="this" bindtap="onIncomeTabTap">
<text>本月</text><text class="income-tab-est" wx:if="{{incomeTab === 'this'}}">预估</text>
</view>
<view class="income-tab {{incomeTab === 'last' ? 'active' : ''}}" data-tab="last" bindtap="onIncomeTabTap">
<text>上月</text>
</view>
</view>
<!-- 收入列表始终全部展示 -->
<view class="income-list">
<view class="income-item" wx:for="{{currentIncome}}" wx:key="label">...</view>
<view class="income-total">...</view>
</view>
```
### 建议
- 当前 Tab 切换交互在功能上已满足"查看本月/上月收入"的需求
- 如需严格对齐 P9 的展开/折叠设计,可在收入明细区域添加折叠逻辑:默认仅显示合计,点击展开显示 4 项明细
- 考虑到仅 4 项数据,全部展示的体验可能优于折叠,建议与产品确认是否需要折叠

View File

@@ -0,0 +1,42 @@
# P9→NS1/RNS1 缺失项 #21客户服务记录中"饮品描述"字段的展示
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟢 低
- `drinks` 字段已在数据模型、API 传递、组件属性、组件模板中完整贯通
## 详细审查
### 审查范围
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts``ServiceRecord` 接口、数据映射
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.wxml` — 组件调用
- `apps/miniprogram/miniprogram/components/service-record-card/service-record-card.ts` — 组件属性
- `apps/miniprogram/miniprogram/components/service-record-card/service-record-card.wxml` — 组件模板
### 发现
1. `customer-service-records.ts``ServiceRecord` 接口定义了 `drinks: string`(注释:商品/饮品描述)
2. 数据映射中从 API 响应提取:`drinks: r.drinks || ''`
3. WXML 中通过 `service-record-card` 组件传递:`drinks="{{item.drinks}}"`
4. `service-record-card` 组件定义了 `drinks` 属性:`{ type: String, value: '' }`
5. 组件模板中在第二行展示:`<text class="svc-drinks">{{drinks || '—'}}</text>`,无数据时显示破折号
### 证据
```typescript
// customer-service-records.ts — ServiceRecord 接口
interface ServiceRecord {
/** 商品/饮品描述 */
drinks: string
// ...
}
// 数据映射
drinks: r.drinks || '',
```
```html
<!-- customer-service-records.wxml — 传递给组件 -->
<service-record-card drinks="{{item.drinks}}" ... />
<!-- service-record-card.wxml — 展示 -->
<text class="svc-drinks">{{drinks || '—'}}</text>
```

View File

@@ -0,0 +1,68 @@
# P9→NS1/RNS1 缺失项 #22详情页各模块的加载失败独立处理
## 简要结论
- 状态:✅ 已解决
- 风险等级:🟢 低
- 后端对每个扩展模块使用独立 try/except 优雅降级;前端 customer-service-records 也实现了模块级独立错误处理
## 详细审查
### 审查范围
- `apps/backend/app/services/customer_service.py``get_customer_detail()` 错误处理
- `apps/backend/app/services/coach_service.py``get_coach_detail()` 错误处理
- `apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts` — 前端错误处理
### 发现
#### 后端 — customer_service.py
1. 核心字段member_info查询失败 → 直接抛 500/404
2. Banner 字段balance、consumption_60d、days_since_visit各自独立 try/except失败降级为 `None`
3. 扩展模块ai_insight、retention_clues、notes、consumption_records、coach_tasks、favorite_coaches各自独立 try/except失败降级为空默认值
4. 注释明确标注:"核心字段查询失败 → 500扩展模块查询失败 → 空默认值(优雅降级)"
#### 后端 — coach_service.py
1. 核心字段assistant_info查询失败 → 直接抛 500/404
2. 扩展模块income、tier_nodes、top_customers、service_records、task_groups、notes、history_months各自独立 try/except
3. 每个模块失败时 logger.warning 记录日志,降级为空默认值
#### 前端 — customer-service-records.ts
1. `loadCustomerInfo()` 失败不阻塞记录展示:`catch` 中仅 `console.error`,注释"客户信息加载失败不阻塞记录展示"
2. `loadMonthRecords()` 失败设置 `pageState: 'error'`,提供重试按钮
### 证据
```python
# customer_service.py — 每个模块独立 try/except
try:
ai_insight = _build_ai_insight(customer_id, conn)
except Exception:
logger.warning("构建 aiInsight 失败,降级为空", exc_info=True)
ai_insight = {"summary": "", "strategies": []}
try:
retention_clues = _build_retention_clues(customer_id, conn)
except Exception:
logger.warning("构建 retentionClues 失败,降级为空列表", exc_info=True)
retention_clues = []
```
```python
# coach_service.py — 同样的模式
try:
income = _build_income(conn, site_id, coach_id, now)
except Exception:
logger.warning("构建 income 失败,降级为空", exc_info=True)
income = {"this_month": [], "last_month": []}
```
```typescript
// customer-service-records.ts — 客户信息加载失败不阻塞
async loadCustomerInfo(id: string) {
try { ... } catch (err) {
console.error('[customer-service-records] loadCustomerInfo failed:', err)
// 客户信息加载失败不阻塞记录展示
}
}
```
### 建议
- 前端 customer-detail.ts 的 `loadDetail()` 目前是单一 try/catch所有数据在一个请求中获取。如果后端某个模块降级为空前端会正常展示空状态但如果整个 API 请求失败,所有模块都不可用。这是可接受的设计,因为核心数据和扩展数据在同一个 API 调用中返回。

View File

@@ -0,0 +1,59 @@
# P9→NS1/RNS1 缺失项 #23客户详情页"可用月数"的计算说明
## 简要结论
- 状态:❌ 未解决
- 风险等级:🟡 低
- `available_months` 字段仅在客户看板 Schema 中定义,客户详情页 API 未返回该字段,计算公式未在代码中实现
## 详细审查
### 审查范围
- `apps/backend/app/services/customer_service.py``get_customer_detail()` 返回字段
- `apps/backend/app/schemas/xcx_board.py` — Schema 定义
- `apps/backend/app/services/board_service.py` — 看板服务
- `apps/backend/app/services/fdw_queries.py` — 数据查询
### 发现
1. `xcx_board.py` 中定义了 `available_months: str # "约0.8个月"``monthly_consume: float`
2. `fdw_queries.py` 中查询了 `monthly_consume` 字段(来自 `v_dws_member_consumption_summary`
3. 但在整个后端代码中,未找到 `available_months` 的计算赋值逻辑——仅有 Schema 定义
4. `customer_service.py``get_customer_detail()` 返回字段中无 `available_months`
5. 客户详情页前端也未展示"可用月数"字段
6. P9 定义的计算公式(余额 ÷ 月均消耗)和边界处理(月均消耗为 0 时)均未实现
### 证据
```python
# xcx_board.py — 仅有 Schema 定义
class CustomerBoardItem(BaseModel):
monthly_consume: float
available_months: str # "约0.8个月"
# customer_service.py — get_customer_detail() 返回值中无 available_months
return {
"id": customer_id,
"name": name,
"phone": phone,
"balance": balance,
"consumption_60d": consumption_60d,
"days_since_visit": days_since_visit,
# 无 available_months
}
```
```
# 全局搜索 available_months 赋值逻辑 → 无结果
# 仅在 Schema 定义中出现
```
### 建议
1.`customer_service.py``board_service.py` 中实现计算逻辑:
```python
def _calc_available_months(balance: float, monthly_consume: float) -> str:
if monthly_consume <= 0:
return "—" # 月均消耗为 0无法计算
months = balance / monthly_consume
return f"约{months:.1f}个月"
```
2. 需要确认 `monthly_consume` 的口径:是 60 天月均还是历史全量月均
3. 边界处理:余额为 0 → "0个月";月均消耗为 0 → 显示"—"或"充足"
4. 将计算结果添加到 `get_customer_detail()` 返回值中,前端在 Banner 统计区域展示