merge: integrate AI陪练 phase2 pilot branch

Merge codex/ai-peilian-phase2 into main after review and pre-merge fixes for
admin scope, org role gates, pilot completion metric, and self-assignment reads.
This commit is contained in:
2026-07-11 01:25:05 +08:00
164 changed files with 39459 additions and 5627 deletions
+3
View File
@@ -14,4 +14,7 @@ frontend/dist/
frontend/.vite/
mobile/node_modules/
mobile/dist/
mobile-uni/node_modules/
mobile-uni/dist/
.tmp/
.worktrees/
+15 -11
View File
@@ -8,7 +8,7 @@
- `backend/` 是 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支检出。
- `frontend/` 是 `CrazyLionCat/plus-ui` 的 `5.X` 分支检出,只承载管理端。
- `mobile/` 是独立移动端工程,承载员工端、候选人端、主管端;当前是 Vue/Vite H5,三端首页按 `docs/prototypes/*端-首页.png` 实现,后续确定小程序技术栈后再迁移到 uni-app/Taro。
- `mobile-uni/` 是当前用户侧工程,承载员工端、候选人端、主管端,使用 uni-app Vue3 H5,后续便于转换 H5/APP/小程序;旧 `mobile/` 仅保留为 MVP 演示兜底,不再承载新增功能。
- 本项目自己的本地编排放在根目录:`docker-compose.dev.yml`、`scripts/`;运行说明在 `docs/DEV_SETUP.md`。
- 除非确实要接业务模块,不要改上游框架默认配置;本地端口和开关优先放根目录脚本里覆盖。
@@ -20,25 +20,27 @@
- Qdrant:REST `127.0.0.1:6333`,默认 collection `aihr_knowledge`;可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。
- 资料导入根目录:默认 `./.data/import`,由 `scripts/dev-backend.sh` 传入 `aihr.import.root`;也可用 `AIHR_IMPORT_ROOT` 覆盖。
- 本地私密配置:根目录 `.env.local` 已被 `.gitignore` 忽略,`scripts/dev-backend.sh` 会自动加载;阿里云短信密钥只放这里或外部环境变量,不写入 `application-*.yml`。
- 后端:`./scripts/dev-backend.sh`,端口 `8080`。
- 管理端前端:`./scripts/dev-frontend.sh`,端口 `5173`。
- 移动端前端:`./scripts/dev-mobile.sh`,端口 `5174`。
- 后端:默认通过 portless 启动,入口 `https://wygj-api.localhost/`;命令为 `portless run --name wygj-api ./scripts/dev-backend.sh` 或根目录 `./scripts/dev.sh`,显式 `PORTLESS=0` 时才回退端口 `8080`。管理端 Vite 代理目标由 `frontend/.env.development` 的 `VITE_APP_PROXY_TARGET` 指向该入口。
- 管理端前端:`./scripts/dev-frontend.sh`,默认走 portless,入口 `https://wygj-admin.localhost/`;显式 `PORTLESS=0` 时回退端口 `5173`。
- 当前用户侧前端:`portless` 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`。
- 旧移动端兜底:`./scripts/dev-mobile.sh`,端口 `5174`,只用于回看 MVP 演示壳。
- 重置数据:`./scripts/reset-dev-db.sh`,会删除并重建本地 `ry-vue`。
导入 SQL 必须带 `--default-character-set=utf8mb4`,否则中文昵称可能按错误字符集导入并触发字段长度问题。
## 验证口径
- 前端首页:`http://127.0.0.1:5173/`
- 移动端首页:`http://127.0.0.1:5174/h5/user`
- 租户接口:`http://127.0.0.1:5173/dev-api/auth/tenant/list`
- 验证码接口:`http://127.0.0.1:5173/dev-api/auth/code`
- 前端首页:`https://wygj-admin.localhost/`
- 用户侧首页:`https://wygj-mobile-uni.localhost/h5/`
- 后端接口:`https://wygj-api.localhost/`
- 租户接口:`https://wygj-admin.localhost/dev-api/auth/tenant/list`
- 验证码接口:`https://wygj-admin.localhost/dev-api/auth/code`
- 默认登录:租户 `000000`,管理员 `admin / admin123`。
## 发布口径
- 线上移动端 H5:`https://peilian.njzhmj.top/h5/user`,Caddy 服务 `/opt/wygj/www/h5`。
- 只发布移动端静态资源时,用 `VITE_BASE=/h5/ npm --prefix mobile run build` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile/dist/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。
- 线上移动端 H5:`https://peilian.njzhmj.top/h5/`,Caddy 服务 `/opt/wygj/www/h5`。
- 只发布移动端静态资源时,用 `npm --prefix mobile-uni run build:h5` 后备份远端 `/opt/wygj/www/h5`,再 `rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/`;前端静态修复不需要重启后端。
## 品牌入口
@@ -55,10 +57,11 @@
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。
- 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。
- 当前业务页已跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 已接真 LLM(`AihrPracticeLlmService`,评分 temperature=0 结构化输出),seed 剧本是剧情锚点与兜底,改对练逻辑时必须保留"未配置/失败回退 seed"的降级链,不要让演示依赖外部 API。
- 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。
@@ -82,6 +85,7 @@
- 阶段交付归档:`docs/archive/2026-07-mvp-delivery/`
- 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md`
- AI 陪练二期推进(阶段一执行):`docs/AI陪练二期开发推进计划.md`
- 数字师傅产品定案(2026-07-08 学练问报/问题榜/学分):`docs/20260708/数字师傅学练问报整合方案.md`
- AI 陪练缺口修订:`docs/AI陪练功能优化与缺口分析(修订).md`
- 后端 API 对接:`docs/API_INTEGRATION.md`
- 对外会议版口径:`docs/AI人力资源系统项目规划方案与AI接口说明.md`
+11 -9
View File
@@ -6,15 +6,16 @@
- 后端:`backend`,来自 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支。
- 管理端前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支。
- 移动端前端:`mobile`,独立 Vue/Vite 工程,承接员工端、候选人端、主管端。
- 用户侧前端:`mobile-uni`,uni-app Vue3 H5 工程,承接员工端、候选人端、主管端;旧 `mobile` 保留为 MVP 演示兜底。
- 本地依赖:MySQL + Redis + MinIO + Qdrant 由 `docker-compose.dev.yml` 启动。
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“开始训练”可完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。
- 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
@@ -24,7 +25,7 @@
./scripts/dev.sh
```
首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。`./scripts/dev.sh` 默认通过 portless 启动后端、管理端和当前 `mobile-uni` 用户侧,对应入口为 `https://wygj-api.localhost/`、`https://wygj-admin.localhost/`、`https://wygj-mobile-uni.localhost/h5/`。只有显式 `PORTLESS=0 ./scripts/dev.sh` 时才回退固定端口和旧移动端兜底。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
## 目录
@@ -32,7 +33,8 @@
|---|---|
| `backend/` | RuoYi-Vue-Plus 后端基座 |
| `frontend/` | plus-ui 管理端前端基座 |
| `mobile/` | 员工端、候选人端、主管端独立移动端工程 |
| `mobile-uni/` | 当前用户侧 uni-app H5 工程 |
| `mobile/` | 旧移动端 H5,保留为 MVP 演示兜底 |
| `scripts/` | 本地 MVP 启动与数据重置脚本 |
| `docker-compose.dev.yml` | 本地 MySQL / Redis / MinIO / Qdrant |
| `docs/` | 项目文档、旧方案参考、Word 原件 |
@@ -44,17 +46,17 @@
| [文档目录](docs/README.md) | docs 区完整索引 |
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、移动端训练记录、候选资料上传/审核和 SOP 上传解析接口 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
| [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲与阶段二/三边界 |
| [数字师傅学练问报整合方案](docs/20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:学练问报四模块、开放问题榜、学分晋升、发布会「生活顾问的一天」 |
| [AI 陪练缺口修订版](docs/AI陪练功能优化与缺口分析(修订).md) | 24 项陪练缺口的代码实况校对和二期排期依据 |
| [阶段交付归档](docs/archive/2026-07-mvp-delivery/) | 2026-07 MVP 作战清单、演示 PPT 和阶段交付材料 |
| [阶段交付归档](docs/archive/2026-07-mvp-delivery/) | 2026-07 MVP 作战清单、演示 PPT、设计 QA 记录和阶段交付材料 |
| [业务需求 BRD](docs/物业AI人力资源系统业务需求文档BRD.md) | 业务范围、角色、边界和验收 |
| [开发规格 TechSpec](docs/物业AI人力资源系统开发规格TechSpec.md) | 模块、数据模型、路由、API 和实现切分 |
| [移动端设计 QA](design-qa.md) | 三端首页高保真实现的截图对照记录 |
## 下一步
现役演示验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。2026-07-07 立项会后按 [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) 三阶段推进:阶段一即 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)(时间锚点 2026-07-10 Alpha → 半年会发布 → 9 月收费季前试点可用);大喇叭信息平台、知识学习平台、北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环已经就绪,当前唯一主线是补齐正式组织身份数据,完成 1–2 个项目、至少 20 名员工的真实训练、校准和 SOP 业务评审后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
@@ -47,8 +47,8 @@ public class UserActionListener implements SaTokenListener {
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setBrowser(userAgent == null ? "Unknown" : userAgent.getBrowser().getName());
dto.setOs(userAgent == null ? "Unknown" : userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
+6
View File
@@ -53,5 +53,11 @@
<artifactId>tika-parser-microsoft-module</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,11 +1,17 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.service.AihrCandidateMaterialService;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse;
import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -22,6 +28,8 @@ import java.util.List;
@RestController
public class AihrCandidateMaterialController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrCandidateMaterialService materialService;
@PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -29,21 +37,40 @@ public class AihrCandidateMaterialController {
@RequestParam(value = "candidateId", required = false) String candidateId,
@RequestParam(value = "candidateName", required = false) String candidateName,
@RequestParam(value = "materialType", required = false) String materialType) {
return R.ok(materialService.upload(file, candidateId, candidateName, materialType));
String appUsername = currentAppUsername();
String boundCandidateId = appUsername.isBlank() ? candidateId : appUsername;
String boundCandidateName = appUsername.isBlank() ? candidateName : appCandidateName(appUsername);
return R.ok(materialService.upload(file, boundCandidateId, boundCandidateName, materialType));
}
@GetMapping("/api/aihr/mobile/candidate/materials")
public R<List<MaterialResponse>> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
return R.ok(materialService.list(candidateId));
String appUsername = currentAppUsername();
return R.ok(materialService.list(appUsername.isBlank() ? candidateId : appUsername));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/api/aihr/hr/candidate/materials")
public R<List<MaterialReviewResponse>> reviewList(@RequestParam(value = "status", required = false) String status) {
return R.ok(materialService.reviewList(status));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(materialService.review(id, request));
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
private static String appCandidateName(String username) {
return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
}
}
@@ -5,10 +5,12 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,6 +19,8 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 案例语音整理 API。
*/
@@ -41,4 +45,9 @@ public class AihrCaseController {
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
return R.ok(caseService.curate(request));
}
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false) Integer limit) {
return R.ok(caseService.records(limit));
}
}
@@ -10,6 +10,9 @@ import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -31,7 +34,7 @@ public class AihrInterviewController {
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(interviewService.start(request));
return R.ok(interviewService.start(bindAppCandidate(request)));
}
@PostMapping("/answer")
@@ -45,7 +48,32 @@ public class AihrInterviewController {
}
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
return R.ok(interviewService.records(limit));
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit,
@RequestParam(value = "candidateId", required = false) String candidateId) {
String appUsername = currentAppUsername();
return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername));
}
private static StartRequest bindAppCandidate(StartRequest request) {
String appUsername = currentAppUsername();
if (appUsername.isBlank()) {
return request;
}
String positionCode = request == null ? null : request.positionCode();
String mode = request == null ? null : request.mode();
return new StartRequest(appUsername, appCandidateName(appUsername), positionCode, mode);
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
private static String appCandidateName(String username) {
return username.matches("1[3-9]\\d{9}") ? "候选人" + username.substring(username.length() - 4) : username;
}
}
@@ -1,20 +1,36 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.service.ISysOssService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -22,14 +38,15 @@ import java.util.Map;
/**
* Mobile H5 home APIs.
*/
@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/aihr/mobile")
public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService;
private final ISysOssService ossService;
@SaIgnore
@GetMapping("/home/{role}")
public R<HomeResponse> home(@PathVariable String role) {
return R.ok(mobileSeedService.home(role));
@@ -37,28 +54,99 @@ public class AihrMobileController {
@GetMapping("/practice/history")
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceHistory(extPartyId));
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews() {
return R.ok(mobileSeedService.practiceReviews());
public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String extPartyId,
@RequestParam(required = false) Integer limit) {
return R.ok(mobileSeedService.practiceReviews(supervisorScopeExtPartyId(), projectCode, extPartyId, limit));
}
@GetMapping("/practice/team")
public R<PracticeTeamResponse> practiceTeam() {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
}
@GetMapping("/practice/reviews/{id}")
public R<Map<String, Object>> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id);
ReviewDetailResponse detail = mobileSeedService.practiceReview(id, supervisorScopeExtPartyId());
return detail == null ? R.fail("复盘记录不存在") : R.ok(reviewDetailPayload(detail));
}
@GetMapping("/oss/{ossId}")
public void oss(@PathVariable Long ossId, HttpServletResponse response) throws IOException {
ossService.download(ossId, response);
}
@PostMapping("/practice/reviews/{id}/reviewed")
public R<Void> markReviewed(@PathVariable Long id) {
return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在");
public R<Void> markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) {
return mobileSeedService.markReviewed(id, request, supervisorScopeExtPartyId()) ? R.ok() : R.fail("复盘记录不存在");
}
@GetMapping("/profile")
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.profile(extPartyId));
return R.ok(mobileSeedService.profile(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/profile/promotion-evidence")
public R<PromotionEvidenceResponse> promotionEvidence(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.promotionEvidence(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/practice/alerts")
public R<List<PracticeAlertResponse>> practiceAlerts() {
return R.ok(mobileSeedService.practiceAlerts(supervisorScopeExtPartyId()));
}
@GetMapping("/practice/assignments")
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername()));
}
@PostMapping("/practice/assignments")
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/assignments/{id}/answer")
public R<DailyDrillAnswerResponse> answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) {
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null));
return response == null ? R.fail("每日三题不存在") : R.ok(response);
}
private static String ownMobileExtPartyId(String requested) {
String username = currentAppUsername();
return username.isBlank() ? requested : username;
}
private static String scopedAssignmentExtPartyId(String requested) {
if (requested == null || requested.isBlank()) {
return requested;
}
return ownMobileExtPartyId(requested);
}
/**
* APP supervisor tokens are org-scoped; management console sys_user keeps unscoped access
* so admin review/growth pages continue to work against the same mobile APIs.
*/
private String supervisorScopeExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
return mobileSeedService.requireSupervisorIdentity(currentAppUsername());
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
private static Map<String, Object> reviewDetailPayload(ReviewDetailResponse detail) {
@@ -73,16 +161,33 @@ public class AihrMobileController {
data.put("summary", detail.summary());
data.put("mentorRewrite", detail.mentorRewrite());
data.put("aiComment", detail.aiComment());
data.put("reviewAdvice", detail.reviewAdvice());
data.put("incentivePoint", detail.incentivePoint());
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
"label", item.label(),
"value", item.value(),
"note", item.note()
)).toList());
data.put("dialogue", detail.dialogue().stream().map(turn -> Map.of(
"role", turn.role(),
"label", turn.label(),
"text", turn.text()
)).toList());
data.put("dialogue", detail.dialogue().stream().map(turn -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("role", turn.role());
item.put("label", turn.label());
item.put("text", turn.text());
item.put("audioUrl", turn.audioUrl());
item.put("audioOssId", turn.audioOssId());
return item;
}).toList());
data.put("annotations", detail.annotations().stream().map(AihrMobileController::annotationPayload).toList());
return data;
}
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
return Map.of(
"turnIndex", annotation.turnIndex(),
"sourceText", annotation.sourceText(),
"issue", annotation.issue(),
"rewrite", annotation.rewrite(),
"sopPoint", annotation.sopPoint()
);
}
}
@@ -1,13 +1,19 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@@ -15,10 +21,25 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/aihr/org")
public class AihrOrgSyncController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrOrgSyncService orgSyncService;
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/sync")
public R<SyncResponse> sync(@RequestBody(required = false) SyncRequest request) {
return R.ok(orgSyncService.sync(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/snapshot")
public R<OrgSnapshotResponse> snapshot(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String positionLevel,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false) Integer pageSize,
@RequestParam(required = false, defaultValue = "200") Integer limit) {
return R.ok(orgSyncService.snapshot(keyword, projectCode, positionLevel, status, pageNum, pageSize, limit));
}
}
@@ -1,19 +1,46 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.dromara.aihr.service.AihrPracticeSeedService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.HttpHeaders;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
/**
* 三角色对练 API。
*/
@@ -22,11 +49,61 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/train/practice")
public class AihrPracticeController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrPracticeSeedService practiceSeedService;
@GetMapping("/scenarios")
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
@RequestParam(required = false) String scenarioType) {
return R.ok(practiceSeedService.scenarios(position, scenarioType));
}
@GetMapping("/scenarios/{id}")
public R<ScenarioResponse> scenario(@PathVariable String id) {
return R.ok(practiceSeedService.scenario(id));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/scenarios")
public R<ScenarioResponse> saveScenario(@RequestBody ScenarioSaveRequest request) {
return R.ok(practiceSeedService.saveScenario(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/scenarios/{id}/enabled")
public R<Void> updateScenarioEnabled(@PathVariable String id, @RequestBody ScenarioEnabledRequest request) {
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
}
@GetMapping("/scenarios/{id}/prep-card")
public R<PrepCardResponse> prepCard(@PathVariable String id) {
return R.ok(practiceSeedService.prepCard(id));
}
@GetMapping("/rubrics")
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
return R.ok(practiceSeedService.rubrics(scenarioId));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/export")
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
HttpServletResponse response) throws IOException {
byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv(startDate, endDate)).getBytes(StandardCharsets.UTF_8);
response.reset();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("text/csv;charset=UTF-8");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=aihr-practice-pilot-" + startDate + "-" + endDate + ".csv");
response.setContentLength(body.length);
response.getOutputStream().write(body);
}
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(practiceSeedService.start(request));
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
}
@PostMapping("/turn")
@@ -38,4 +115,37 @@ public class AihrPracticeController {
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(practiceSeedService.finish(request));
}
@PostMapping("/sessions/{sessionId}/annotations")
public R<List<AnnotationResponse>> annotations(@PathVariable String sessionId, @RequestBody(required = false) AnnotationSaveRequest request) {
List<AnnotationResponse> annotations = practiceSeedService.saveAnnotations(sessionId, request);
return annotations.isEmpty() ? R.fail("训练会话不存在") : R.ok(annotations);
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/sessions/{sessionId}/calibration")
public R<CalibrationResponse> calibration(@PathVariable String sessionId, @RequestBody(required = false) CalibrationRequest request) {
CalibrationResponse response = practiceSeedService.calibrate(sessionId, request);
return response == null ? R.fail("训练会话不存在") : R.ok(response);
}
private static StartRequest bindMobileStartIdentity(StartRequest request) {
if (request == null || !"mobile".equalsIgnoreCase(request.mode())) {
return request;
}
String username = currentAppUsername();
if (username.isBlank()) {
return request;
}
return new StartRequest(username, request.scenarioId(), request.mode(), request.assignmentId());
}
private static String currentAppUsername() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
String username = loginUser.getUsername();
return username == null ? "" : username.trim();
}
}
@@ -0,0 +1,53 @@
package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.service.AihrPracticeSeedService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* AI HR 提示词模板 API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/aihr/prompt-templates")
public class AihrPromptTemplateController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrPracticeSeedService practiceSeedService;
@GetMapping
public R<List<PromptTemplateResponse>> list(@RequestParam(required = false) String category,
@RequestParam(required = false) String code,
@RequestParam(required = false) Boolean enabled) {
return R.ok(practiceSeedService.promptTemplates(category, code, enabled));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping
public R<PromptTemplateResponse> create(@RequestBody PromptTemplateRequest request) {
return R.ok(practiceSeedService.savePromptTemplate(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PutMapping("/{id}")
public R<PromptTemplateResponse> update(@PathVariable Long id, @RequestBody PromptTemplateRequest request) {
PromptTemplateResponse response = practiceSeedService.updatePromptTemplate(id, request);
return response == null ? R.fail("提示词模板不存在") : R.ok(response);
}
}
@@ -1,12 +1,18 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackItemResponse;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackRequest;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackResponse;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackReviewResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
@@ -63,6 +69,29 @@ public class AihrSopController {
return R.ok(sopSeedService.search(request));
}
@PostMapping("/answer-feedback")
public R<AnswerFeedbackResponse> answerFeedback(@RequestBody AnswerFeedbackRequest request) {
return R.ok(sopSeedService.answerFeedback(request));
}
@GetMapping("/answer-feedback")
public R<List<AnswerFeedbackItemResponse>> answerFeedbackList(@RequestParam(value = "status", required = false, defaultValue = "待复核") String status,
@RequestParam(value = "limit", required = false, defaultValue = "50") int limit) {
return R.ok(sopSeedService.answerFeedbackList(status, limit));
}
@PostMapping("/answer-feedback/{id}/review")
public R<AnswerFeedbackReviewResponse> reviewAnswerFeedback(@PathVariable Long id) {
AnswerFeedbackReviewResponse response = sopSeedService.reviewAnswerFeedback(id);
return response == null ? R.fail("答案反馈记录不存在") : R.ok(response);
}
@PostMapping("/reviews/{id}")
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
SopReviewResponse response = sopSeedService.reviewSearch(id, request);
return response == null ? R.fail("SOP问答评审记录不存在") : R.ok(response);
}
@PostMapping("/summary-card")
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
return R.ok(sopSeedService.summaryCard(request));
@@ -1,11 +1,14 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse;
import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
import org.dromara.aihr.service.AihrSpeechService;
import org.dromara.common.core.domain.R;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -14,12 +17,16 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/**
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/ai")
@@ -30,6 +37,7 @@ public class AihrSpeechController {
private static final int MAX_TTS_CHARS = 300;
private final AihrSpeechService speechService;
private final ISysOssService ossService;
@PostMapping(value = "/asr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AsrResponse> asr(@RequestPart("file") MultipartFile file) throws IOException {
@@ -43,7 +51,10 @@ public class AihrSpeechController {
return R.fail("音频过大(上限 5MB),请缩短录音时长");
}
return speechService.transcribe(file.getBytes(), file.getOriginalFilename(), file.getContentType())
.map(text -> R.ok(new AsrResponse(text, "openai-compatible")))
.map(text -> {
SysOssVo oss = ossService.upload(file);
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
})
.orElseGet(() -> R.fail("语音识别失败,请改用文字输入"));
}
@@ -60,7 +71,30 @@ public class AihrSpeechController {
text = text.substring(0, MAX_TTS_CHARS);
}
return speechService.synthesize(text, request == null ? null : request.voice())
.map(audio -> R.ok(new TtsResponse("data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio), "openai-compatible")))
.map(this::storeTtsAudio)
.orElseGet(() -> R.fail("语音合成失败,已降级为文本展示"));
}
private R<TtsResponse> storeTtsAudio(byte[] audio) {
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
Path tempFile = null;
try {
tempFile = Files.createTempFile("aihr-tts-", ".mp3");
Files.write(tempFile, audio);
File file = tempFile.toFile();
SysOssVo oss = ossService.upload(file);
return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), inlineAudioUrl));
} catch (Exception e) {
log.warn("tts audio upload failed: {}", e.getMessage());
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible"));
} finally {
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
log.warn("delete tts temp file failed: {}", e.getMessage());
}
}
}
}
}
@@ -8,7 +8,7 @@ public final class AihrInterviewDto {
private AihrInterviewDto() {
}
public record StartRequest(String candidateId, String positionCode, String mode) {
public record StartRequest(String candidateId, String candidateName, String positionCode, String mode) {
}
public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List<QuestionResponse> questions, String source) {
@@ -27,7 +27,44 @@ public final class AihrOrgSyncDto {
int employeeCount,
int syncedCount,
int skippedCount,
int phoneLinked,
int maskedPhone,
int suspectText,
List<String> warnings
) {
}
public record OrgSnapshotResponse(
int total,
int active,
int departed,
int phoneLinked,
int suspectText,
int pageNum,
int pageSize,
List<OrgProjectOption> projects,
List<String> levels,
List<OrgPersonRow> rows
) {
}
public record OrgProjectOption(
String projectCode,
String projectName
) {
}
public record OrgPersonRow(
Long id,
String projectCode,
String projectName,
String deptName,
String extPartyId,
String personName,
String positionName,
String positionLevel,
String employmentStatus,
String snapshotDate
) {
}
}
@@ -7,7 +7,7 @@ public final class AihrPracticeDto {
private AihrPracticeDto() {
}
public record StartRequest(String extPartyId, String scenarioId, String mode) {
public record StartRequest(String extPartyId, String scenarioId, String mode, Long assignmentId) {
}
public record StartResponse(
@@ -26,7 +26,62 @@ public final class AihrPracticeDto {
public record PersonaResponse(String customer, String project, String strategy, String goal) {
}
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl) {
public record ScenarioResponse(
String id,
String name,
String position,
String scenarioType,
String projectType,
Integer difficulty,
String customerPersona,
String openingText,
String explicitNeed,
String hiddenNeed,
String irritants,
String delights,
String sopRefs,
String successCriteria,
String strategy,
String goal,
Boolean enabled,
RubricResponse rubric
) {
}
public record ScenarioSaveRequest(
String id,
String name,
String position,
String scenarioType,
String projectType,
Integer difficulty,
String customerPersona,
String openingText,
String explicitNeed,
String hiddenNeed,
String irritants,
String delights,
String sopRefs,
String successCriteria,
String strategy,
String goal,
Boolean enabled
) {
}
public record ScenarioEnabledRequest(Boolean enabled) {
}
public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) {
}
public record RubricDimensionResponse(String code, String name, Double weight, String description) {
}
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) {
}
public record TurnResponse(
@@ -35,12 +90,14 @@ public final class AihrPracticeDto {
Integer emotion,
Integer trust,
String coachHint,
Boolean redFlag,
Integer roundIndex,
Boolean finished
Boolean finished,
String message
) {
}
public record FinishRequest(String sessionId) {
public record FinishRequest(String sessionId, Long assignmentId) {
}
public record FinishResponse(
@@ -56,10 +113,40 @@ public final class AihrPracticeDto {
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary, String extPartyId) {
}
public record DialogueResponse(String role, String label, String text) {
public record DialogueResponse(String role, String label, String text, String audioUrl, Long audioOssId, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
public DialogueResponse(String role, String label, String text) {
this(role, label, text, "", null, null, null, null, "");
}
public DialogueResponse(String role, String label, String text, String audioUrl) {
this(role, label, text, audioUrl, null, null, null, null, "");
}
}
public record AnnotationResponse(Integer turnIndex, String sourceText, String issue, String rewrite, String sopPoint) {
}
public record AnnotationSaveRequest(List<AnnotationResponse> annotations) {
}
public record CalibrationRequest(
String reviewer,
Integer correctedTotal,
Integer correctedCompliance,
Integer correctedEmotion,
Integer correctedCommunication,
Integer correctedMarketing,
String reason
) {
}
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
}
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) {
}
public record ReviewDetailResponse(
@@ -73,11 +160,112 @@ public final class AihrPracticeDto {
String summary,
String mentorRewrite,
String aiComment,
String reviewAdvice,
Integer incentivePoint,
List<DimensionResponse> scoreItems,
List<DialogueResponse> dialogue
List<DialogueResponse> dialogue,
List<AnnotationResponse> annotations
) {
}
public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List<DimensionResponse> dimensions) {
public record CompetencyResponse(
Integer score,
Integer completed,
Integer pendingReview,
Integer trainingMinutes,
Integer contribution,
Integer assessmentScore,
String aiLevel,
List<DimensionResponse> dimensions,
List<GrowthStageResponse> growthPath
) {
}
public record GrowthStageResponse(String level, Integer progress, String status, String evidence) {
}
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
}
public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) {
}
public record PracticeAssignmentResponse(
Long id,
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String status,
String createTime,
String question,
String referenceAnswer,
String userAnswer,
String feedback,
Boolean correct
) {
}
public record TeamMemberResponse(String extPartyId, String name) {
}
public record PracticeTeamResponse(
List<TeamMemberResponse> members,
List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments
) {
}
public record DailyDrillAnswerRequest(String answer) {
}
public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) {
}
public record PromptTemplateRequest(
String code,
String name,
String category,
String outputFormat,
String systemPrompt,
String template,
String variables,
String version,
Boolean enabled,
String description
) {
}
public record PromptTemplateResponse(
Long id,
String code,
String name,
String category,
String outputFormat,
String systemPrompt,
String template,
String variables,
String version,
Boolean enabled,
String description,
String updateTime
) {
}
public record PromotionEvidenceResponse(
String extPartyId,
String currentLevel,
Integer assessmentScore,
Integer trainingMinutes,
Integer contribution,
Integer completed,
Integer pendingReview,
List<DimensionResponse> dimensions,
List<RecordResponse> latestRecords,
List<PracticeAssignmentResponse> assignments,
List<String> evidenceItems
) {
}
}
@@ -7,12 +7,30 @@ public final class AihrSopDto {
private AihrSopDto() {
}
public record SearchRequest(String queryText, String category, Integer limit) {
public record SearchRequest(String queryText, String category, String position, String source, Integer limit) {
}
public record SummaryCardRequest(String queryText, String category) {
}
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
}
public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt) {
}
public record AnswerFeedbackRequest(String queryText, Long fragmentId, List<Long> fragmentIds, String verdict, String category, String position, String source) {
}
public record AnswerFeedbackResponse(Integer saved, String verdict, String reviewStatus) {
}
public record AnswerFeedbackItemResponse(Long id, String queryText, String queryNorm, Long fragmentId, String verdict, String category, String position, String source, String reviewStatus, String createTime, String updateTime) {
}
public record AnswerFeedbackReviewResponse(Long id, String reviewStatus) {
}
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
}
@@ -79,14 +97,18 @@ public final class AihrSopDto {
List<String> training,
List<RecordResponse> records,
List<String> keyPoints,
List<String> cautions
List<String> cautions,
Long reviewId
) {
}
public record DocResponse(String title, String desc, String hit, String type) {
}
public record SnippetResponse(String title, String text) {
public record SnippetResponse(String title, String text, Long fragmentId) {
public SnippetResponse(String title, String text) {
this(title, text, null);
}
}
public record RecordResponse(String title, String category, String version, String status, String owner, String summary) {
@@ -5,12 +5,18 @@ public final class AihrSpeechDto {
private AihrSpeechDto() {
}
public record AsrResponse(String text, String source) {
public record AsrResponse(String text, String source, Long ossId, String audioUrl) {
public AsrResponse(String text, String source) {
this(text, source, null, "");
}
}
public record TtsRequest(String text, String voice) {
}
public record TtsResponse(String audioUrl, String source) {
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
public TtsResponse(String audioUrl, String source) {
this(audioUrl, source, null, "");
}
}
}
@@ -1,6 +1,7 @@
package org.dromara.aihr.service;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -36,6 +37,7 @@ public class AihrCandidateMaterialService {
if (file.getSize() > MAX_SIZE) {
throw new IllegalArgumentException("资料不能超过20MB");
}
String boundCandidateId = requireCandidateId(candidateId);
String fileName = clean(file.getOriginalFilename(), "candidate-material");
String type = clean(materialType, materialType(fileName));
SysOssVo oss = ossService.upload(file);
@@ -47,7 +49,7 @@ public class AihrCandidateMaterialService {
values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, TENANT_ID);
ps.setString(2, clean(candidateId, "mobile-candidate"));
ps.setString(2, boundCandidateId);
ps.setString(3, clean(candidateName, "候选人"));
ps.setString(4, type);
ps.setString(5, fileName);
@@ -63,6 +65,7 @@ public class AihrCandidateMaterialService {
public List<MaterialResponse> list(String candidateId) {
ensureTable();
String boundCandidateId = requireCandidateId(candidateId);
return jdbcTemplate.query("""
select id, oss_id, file_name, material_type, file_size, status, create_time
from aihr_candidate_material
@@ -77,7 +80,7 @@ public class AihrCandidateMaterialService {
rs.getLong("file_size"),
rs.getString("status"),
rs.getTimestamp("create_time").toLocalDateTime().format(TIME_FORMAT)
), TENANT_ID, clean(candidateId, "mobile-candidate"));
), TENANT_ID, boundCandidateId);
}
public List<MaterialReviewResponse> reviewList(String status) {
@@ -188,6 +191,14 @@ public class AihrCandidateMaterialService {
return text.isEmpty() ? fallback : text;
}
static String requireCandidateId(String candidateId) {
String text = clean(candidateId, "");
if (text.isEmpty()) {
throw new ServiceException("候选人身份缺失,请重新登录");
}
return text;
}
private static String normalizeReviewStatus(String status) {
String text = clean(status, "待审核");
if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) {
@@ -48,6 +48,7 @@ public class AihrCaseService {
try {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
.orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败"));
transcript = maskSensitiveText(transcript);
String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
cases.put(caseId, state);
@@ -83,7 +84,26 @@ public class AihrCaseService {
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
);
saveCase(state, "已入库", record.title(), record.summary());
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。");
}
public List<RecordResponse> records(Integer limit) {
ensureCaseTable();
int safeLimit = limit == null ? 10 : Math.max(1, Math.min(20, limit));
return jdbcTemplate.query("""
SELECT title, type, status, update_time, owner, summary
FROM aihr_case_record
WHERE tenant_id = '000000'
ORDER BY update_time DESC, id DESC
LIMIT ?
""", (rs, rowNum) -> new RecordResponse(
firstNonBlank(rs.getString("title"), "现场案例"),
firstNonBlank(rs.getString("type"), "现场案例"),
firstNonBlank(rs.getString("status"), "已入库"),
rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT),
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("summary"), "")
), safeLimit);
}
private void saveCase(CaseState state, String status, String title, String summary) {
@@ -108,8 +128,8 @@ public class AihrCaseService {
truncate(title, 120),
primaryTag(state.tags()),
status,
truncate(state.transcript(), 4000),
truncate(summary, 1000)
truncate(maskSensitiveText(state.transcript()), 4000),
truncate(maskSensitiveText(summary), 1000)
);
}
@@ -164,7 +184,7 @@ public class AihrCaseService {
if (summaryNode.isArray()) {
for (JsonNode item : summaryNode) {
String label = clean(item.path("label").asText(""));
String text = clean(item.path("text").asText(""));
String text = maskSensitiveText(item.path("text").asText(""));
if (!label.isBlank() && !text.isBlank()) {
summary.add(new SummaryResponse(label, truncate(text, 120)));
}
@@ -186,7 +206,7 @@ public class AihrCaseService {
if (tags.isEmpty()) {
tags = tagsFromText(root.path("aiSummary").asText(""));
}
return Optional.of(new CaseSummary(summary, tags, truncate(root.path("aiSummary").asText(""), 180), "real-llm"));
return Optional.of(new CaseSummary(summary, tags, truncate(maskSensitiveText(root.path("aiSummary").asText("")), 180), "real-llm"));
} catch (Exception e) {
log.warn("case summary parse failed, uses local transcript summary: {}", e.getMessage());
return Optional.empty();
@@ -275,6 +295,18 @@ public class AihrCaseService {
return value == null ? "" : value.trim();
}
private static String maskSensitiveText(String text) {
if (text == null || text.isBlank()) {
return clean(text);
}
String masked = text;
masked = masked.replaceAll("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)", "$1****$2");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)", "$1$2****");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)", "$1-****");
masked = masked.replaceAll("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)", "$1*$3");
return clean(masked);
}
private record CaseState(
String id,
String fileName,
@@ -59,18 +59,18 @@ public class AihrDashboardService {
private List<FeatureStatusResponse> heroEntries(InterviewStats interview, PracticeStats practice, KnowledgeStats knowledge, CaseStats cases, ModelStats models) {
return List.of(
new FeatureStatusResponse("interview", interview.total() > 0 ? "已有记录" : "可演示", "success", interview.total() + " 条面试结果"),
new FeatureStatusResponse("interview", interview.total() > 0 ? "已有记录" : "可试用", "success", interview.total() + " 条面试结果"),
new FeatureStatusResponse("practice", practice.total() > 0 ? "已有记录" : "待跑通", practice.total() > 0 ? "success" : "warning", practice.total() + " 条训练记录"),
new FeatureStatusResponse("case", cases.stored() > 0 ? "已入库" : models.asrEnabled() ? "可试跑" : "样片兜底", cases.stored() > 0 ? "success" : "warning", cases.stored() + " 条案例入库"),
new FeatureStatusResponse("case", cases.stored() > 0 ? "已入库" : models.asrEnabled() ? "可试跑" : "待配置ASR", cases.stored() > 0 ? "success" : "warning", cases.stored() + " 条案例入库"),
new FeatureStatusResponse("sop", knowledge.fragments() > 0 ? "可检索" : "待入库", knowledge.fragments() > 0 ? "success" : "warning", knowledge.fragments() + " 个知识片段")
);
}
private List<StageResponse> stages(InterviewStats interview, PracticeStats practice, KnowledgeStats knowledge, CaseStats cases, OrgStats org) {
return List.of(
new StageResponse(1, "选:AI面试", "题目与评估", interview.total() > 0 ? "已留痕" : "可演示", "done", "success"),
new StageResponse(1, "选:AI面试", "题目与评估", interview.total() > 0 ? "已留痕" : "可试用", "done", "success"),
new StageResponse(2, "用:岗位适配", "组织快照", org.activePeople() > 0 ? "已接入" : "待同步", org.activePeople() > 0 ? "done" : "idle", org.activePeople() > 0 ? "success" : "info"),
new StageResponse(3, "育:三角色对练", "实战陪练", practice.total() > 0 ? "已留痕" : "待演示", practice.total() > 0 ? "done" : "active", practice.total() > 0 ? "success" : "danger"),
new StageResponse(3, "育:三角色对练", "实战陪练", practice.total() > 0 ? "已留痕" : "待跑通", practice.total() > 0 ? "done" : "active", practice.total() > 0 ? "success" : "danger"),
new StageResponse(4, "留:成长追踪", "能力画像", practice.total() > 0 ? "可生成" : "待积累", practice.total() > 0 ? "active" : "idle", practice.total() > 0 ? "warning" : "info"),
new StageResponse(5, "案例沉淀", "知识与复用", cases.stored() > 0 || knowledge.completedDocs() > 0 ? "进行中" : "待入库", cases.stored() > 0 ? "done" : "idle", cases.stored() > 0 ? "success" : "info")
);
@@ -98,7 +98,7 @@ public class AihrDashboardService {
new PipelineStepResponse("ASR 转写", cases.transcribed() + " 条", state(cases.transcribed())),
new PipelineStepResponse("AI 整理", cases.organized() + " 条", state(cases.organized())),
new PipelineStepResponse("审核入库", cases.stored() + " 条", state(cases.stored())),
new PipelineStepResponse("样片展示", cases.stored() + " 条", state(cases.stored()))
new PipelineStepResponse("培训复用", cases.stored() + " 条", state(cases.stored()))
);
}
@@ -44,7 +44,12 @@ public class AihrInterviewService {
private volatile boolean tableReady;
public StartResponse start(StartRequest request) {
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
CandidateProfile candidate = resolveCandidate(
request == null ? null : request.candidateId(),
request == null ? null : request.candidateName(),
request == null ? null : request.positionCode(),
null
);
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
String sessionId = "iv-" + UUID.randomUUID();
sessions.put(sessionId, new InterviewSession(candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source()));
@@ -70,7 +75,7 @@ public class AihrInterviewService {
InterviewSession session = sessions.computeIfAbsent(
sessionKey,
key -> {
CandidateProfile candidate = resolveCandidate(null, key);
CandidateProfile candidate = resolveCandidate(null, null, null, key);
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
}
);
@@ -98,15 +103,27 @@ public class AihrInterviewService {
}
public List<RecordResponse> records(int limit) {
return records(limit, null);
}
public List<RecordResponse> records(int limit, String candidateId) {
ensureResultTable();
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
return jdbcTemplate.query("""
StringBuilder sql = new StringBuilder("""
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
FROM aihr_interview_result
WHERE tenant_id = '000000'
""");
List<Object> args = new ArrayList<>();
if (!isBlank(candidateId)) {
sql.append(" AND candidate_id = ?");
args.add(clean(candidateId));
}
sql.append("""
ORDER BY finished_time DESC, id DESC
LIMIT %d
""".formatted(safeLimit), this::mapRecord);
""".formatted(safeLimit));
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
}
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
@@ -330,13 +347,40 @@ public class AihrInterviewService {
return builder.toString();
}
private CandidateProfile resolveCandidate(String candidateId, String sessionId) {
private CandidateProfile resolveCandidate(String candidateId, String candidateName, String positionCode, String sessionId) {
if (!isBlank(candidateId) && candidates.containsKey(candidateId)) {
return candidates.get(candidateId);
}
if (!isBlank(candidateId)) {
return dynamicCandidate(candidateId, candidateName, positionCode);
}
return candidates.get("zhang-ming");
}
private CandidateProfile dynamicCandidate(String candidateId, String candidateName, String positionCode) {
CandidateProfile template = candidates.get("zhang-ming");
String cleanId = clean(candidateId);
String name = firstNonBlank(candidateName, cleanId.matches("1[3-9]\\d{9}") ? "候选人" + cleanId.substring(cleanId.length() - 4) : cleanId);
return new CandidateProfile(
cleanId,
name,
positionName(positionCode),
"待分配项目",
"候选人补充资料待审核",
"需通过结构化面试确认服务场景能力",
template.capabilities(),
template.questions()
);
}
private static String positionName(String positionCode) {
String code = clean(positionCode);
if (code.isBlank() || "customer-service".equals(code) || "life-consultant".equals(code)) {
return "生活顾问";
}
return code;
}
private static Map<String, CandidateProfile> buildCandidates() {
Map<String, CandidateProfile> result = new LinkedHashMap<>();
result.put("zhang-ming", new CandidateProfile(
@@ -1,7 +1,15 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
@@ -23,7 +31,6 @@ import java.util.List;
@Service
public class AihrMobileSeedService {
private static final int TEAM_SIZE = 15;
private final AihrPracticeSeedService practiceSeedService;
public AihrMobileSeedService(AihrPracticeSeedService practiceSeedService) {
@@ -46,18 +53,82 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(5);
}
public List<RecordResponse> practiceReviews(String supervisorExtPartyId) {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5);
}
public List<RecordResponse> practiceReviews(String supervisorExtPartyId, String projectCode, String extPartyId, Integer limit) {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit);
}
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) {
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200);
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity);
}
public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id);
}
public ReviewDetailResponse practiceReview(Long id, String supervisorExtPartyId) {
return practiceSeedService.reviewDetail(id, supervisorExtPartyId);
}
public boolean markReviewed(Long id) {
return practiceSeedService.markReviewed(id);
return markReviewed(id, null);
}
public boolean markReviewed(Long id, ReviewAdviceRequest request) {
return practiceSeedService.markReviewed(id, request);
}
public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) {
return practiceSeedService.markReviewed(id, request, supervisorExtPartyId);
}
public CompetencyResponse profile(String extPartyId) {
return practiceSeedService.competency(extPartyId);
}
public PromotionEvidenceResponse promotionEvidence(String extPartyId) {
return practiceSeedService.promotionEvidence(extPartyId);
}
public List<PracticeAlertResponse> practiceAlerts() {
return practiceSeedService.alerts();
}
public List<PracticeAlertResponse> practiceAlerts(String supervisorExtPartyId) {
return practiceSeedService.alerts(supervisorExtPartyId);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId) {
return practiceSeedService.assignments(extPartyId, 10);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId, String supervisorExtPartyId) {
return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10);
}
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) {
return practiceSeedService.createAssignment(request);
}
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
return practiceSeedService.createAssignment(request, supervisorExtPartyId);
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
return answerDailyDrill(id, request, "");
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) {
return practiceSeedService.answerDailyDrill(id, request, extPartyId);
}
private static String normalize(String role) {
if (role == null) {
return "user";
@@ -120,7 +191,8 @@ public class AihrMobileSeedService {
private HomeResponse workerHome(String role, String roleLabel, String avatarTone, String greeting, String location) {
int completed = practiceSeedService.mobileCompletedCount();
int pendingReview = 2 + practiceSeedService.mobilePendingReviewCount();
int pendingReview = practiceSeedService.mobilePendingReviewCount();
int averageScore = practiceSeedService.mobileAverageScore();
return new HomeResponse(
role,
roleLabel,
@@ -132,7 +204,7 @@ public class AihrMobileSeedService {
new PrimaryTaskResponse(
"重点训练",
"投诉接待三角色对练",
"3分钟语音练习 · 完成后自动评分",
"3分钟情景练习 · 完成后自动评分",
"1/1",
100,
"开始训练",
@@ -149,9 +221,9 @@ public class AihrMobileSeedService {
new ToolResponse("pen", "提交素材", "记录工作场景\n供审核入库", "purple")
),
List.of(
new MetricResponse("bars", "完成", (6 + completed) + "/10", "今日任务进度", "teal", Math.min(100, 60 + completed * 10)),
new MetricResponse("target", "能力", String.valueOf(Math.min(95, 82 + completed * 2)), "综合能力分", "blue", Math.min(95, 82 + completed * 2)),
new MetricResponse("clipboard", "待复盘", String.valueOf(pendingReview), "待复盘任务", "orange", Math.min(100, 48 + completed * 10))
new MetricResponse("bars", "训练", String.valueOf(completed), "累计训练次数", "teal", Math.min(100, completed * 10)),
new MetricResponse("target", "能力", String.valueOf(averageScore), "移动端平均分", "blue", averageScore),
new MetricResponse("clipboard", "待复盘", String.valueOf(pendingReview), "待复盘任务", "orange", Math.min(100, pendingReview * 4))
),
List.of(
new TabResponse("首页", "home", true),
@@ -166,11 +238,14 @@ public class AihrMobileSeedService {
}
private HomeResponse supervisorHome() {
int mobileCompleted = practiceSeedService.mobileCompletedCount();
int completed = Math.min(TEAM_SIZE, 12 + mobileCompleted);
int pending = Math.max(0, TEAM_SIZE - completed);
int pendingReview = 3 + practiceSeedService.mobilePendingReviewCount();
int completionRate = Math.round(completed * 100F / TEAM_SIZE);
int completed = practiceSeedService.mobileCompletedPeopleCount();
int total = practiceSeedService.mobilePilotPeopleCount();
int pending = Math.max(0, total - completed);
int pendingReview = practiceSeedService.mobilePendingReviewCount();
int pendingAssignment = practiceSeedService.mobilePendingAssignmentCount();
int lowScore = practiceSeedService.mobileLowScoreCount();
int averageScore = practiceSeedService.mobileAverageScore();
int completionRate = total == 0 ? 0 : Math.round(completed * 100F / total);
return new HomeResponse(
"supervisor",
"主管端",
@@ -202,21 +277,20 @@ public class AihrMobileSeedService {
"查看详情",
List.of(
new MiniStatResponse("users", "完训率", completionRate + "%", "teal"),
new MiniStatResponse("bars", "平均能力", String.valueOf(Math.min(90, 78 + mobileCompleted)), "teal"),
new MiniStatResponse("warning", "高频短板", "3", "orange")
new MiniStatResponse("bars", "平均能力", String.valueOf(averageScore), "teal"),
new MiniStatResponse("warning", "低分训练", String.valueOf(lowScore), "orange")
)
),
List.of(
new TodoResponse("presentation", "待复盘对练", "查看对练记录,辅导与点评", pendingReview, "teal"),
new TodoResponse("folder", "待提交本周案例", "提交优质案例素材,供审核入库", 1, "orange"),
new TodoResponse("clipboard", "待指派专项", "为低分或薄弱项员工指派训练专项", 2, "blue")
new TodoResponse("clipboard", "待指派专项", "为低分或薄弱项员工指派训练专项", pendingAssignment, "blue")
),
new SuggestionResponse(
lowScore > 0 ? new SuggestionResponse(
"bulb",
"建议:给催费沟通低分员工指派专项",
"基于团队短板分析,建议开展「催费沟通技巧」专项训练",
"amber"
)
) : null
);
}
}
@@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrModelDto.ConfigResponse;
import org.dromara.aihr.domain.AihrModelDto.ProviderRequest;
import org.dromara.aihr.domain.AihrModelDto.ProviderResponse;
import org.dromara.aihr.domain.AihrModelDto.ProviderStatusRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -43,6 +44,15 @@ public class AihrModelSeedService {
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
@Value("${aihr.ai-runtime.enabled:${AIHR_AI_RUNTIME_ENABLED:true}}")
private boolean aiEnabled;
@Value("${aihr.ai-runtime.chat-enabled:${AIHR_AI_CHAT_ENABLED:true}}")
private boolean chatEnabled;
@Value("${aihr.ai-runtime.speech-enabled:${AIHR_AI_SPEECH_ENABLED:true}}")
private boolean speechEnabled;
public List<ProviderResponse> providers() {
List<ProviderResponse> rows = dbProviders();
return rows.isEmpty() ? seedProviders() : mergeProviders(rows);
@@ -66,7 +76,7 @@ public class AihrModelSeedService {
private static List<ConfigResponse> seedConfigs() {
return List.of(
new ConfigResponse(-1L, "chat", DEFAULT_MODEL, DEFAULT_PROVIDER, "Demo 对话与 RAG 生成模型", null, "Y", null, true, false, false),
new ConfigResponse(-1L, "chat", DEFAULT_MODEL, DEFAULT_PROVIDER, "通用对话与 RAG 生成模型", null, "Y", null, true, false, false),
new ConfigResponse(-2L, "vector", "embedding-3", "zhipu", "后续用于知识片段向量化", 2048, "N", null, false, false, false),
new ConfigResponse(-3L, "rerank", "rerank", "zhipu", "后续用于召回片段重排序", null, "N", null, false, false, false),
new ConfigResponse(-4L, "vector", "BAAI/bge-m3", "siliconflow", "硅基流动 bge-m3,用于知识片段向量化", 1024, "N", null, false, false, false),
@@ -181,12 +191,24 @@ public class AihrModelSeedService {
// 运行参数已就绪时以解析出的模型名为准:请求可能带的是 seed 占位模型名(如 gpt-4o-mini),不能透传给供应商
String modelName = runtime.configured() || request == null || isBlank(request.model()) ? runtime.modelName() : request.model();
if (!chatAllowed()) {
return new ChatResponse(
false,
runtime.providerCode(),
modelName,
"AI 调用已被试点成本闸门关闭,当前返回文本兜底:先安抚业主情绪,确认事实与责任人,再承诺首次反馈时间。",
"cost-guard-fallback",
null,
List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。")
);
}
if (!runtime.configured()) {
return new ChatResponse(
false,
runtime.providerCode(),
modelName,
"模型运行参数未配置,当前返回 seed fallback:先安抚业主情绪,确认房号、影响范围和现场照片,再创建工单并承诺首次反馈时间。",
"模型运行参数未配置,当前返回内置默认兜底:先安抚业主情绪,确认房号、影响范围和现场照片,再创建工单并承诺首次反馈时间。",
"seed-fallback",
null,
List.of(
@@ -204,7 +226,7 @@ public class AihrModelSeedService {
true,
runtime.providerCode(),
modelName,
"真实模型调用失败,已回退 seed:先确认事实、同步时限、再生成工单闭环。",
"真实模型调用失败,已回退内置默认兜底:先确认事实、同步时限、再生成工单闭环。",
"openai-compatible-failed",
e.getMessage(),
List.of("检查 aihr_model_provider.api_host/api_key 和 aihr_model_config.model_name 是否与供应商一致。")
@@ -216,6 +238,9 @@ public class AihrModelSeedService {
* 按类目(asr/tts)查已启用且配置齐全的语音模型运行参数,供 AihrSpeechService 使用。
*/
public Optional<SpeechModel> speechModel(String category) {
if (!speechAllowed()) {
return Optional.empty();
}
try {
List<SpeechModel> rows = jdbcTemplate.query("""
select c.model_name, c.provider_code,
@@ -249,6 +274,10 @@ public class AihrModelSeedService {
* 供其他模块(如三角色对练)复用的 chat 调用:模型未配置或调用失败返回 empty,由调用方决定兜底。
*/
public Optional<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
if (!chatAllowed()) {
// ponytail: manual cost breaker; replace with metered monthly billing guard when vendor usage data is wired.
return Optional.empty();
}
RuntimeConfig runtime = runtimeConfig(null);
if (!runtime.configured()) {
return Optional.empty();
@@ -261,6 +290,14 @@ public class AihrModelSeedService {
}
}
private boolean chatAllowed() {
return aiEnabled && chatEnabled;
}
private boolean speechAllowed() {
return aiEnabled && speechEnabled;
}
private List<ProviderResponse> dbProviders() {
try {
return jdbcTemplate.query("""
@@ -302,7 +339,7 @@ public class AihrModelSeedService {
rs.getString("category"),
rs.getString("model_name"),
rs.getString("provider_code"),
rs.getString("model_describe"),
normalizeModelDescribe(rs.getString("model_describe")),
rs.getObject("model_dimension", Integer.class),
rs.getString("model_show"),
rs.getString("api_host"),
@@ -347,7 +384,7 @@ public class AihrModelSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 MVP 演示,简洁、可执行。" : systemPrompt);
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。" : systemPrompt);
ObjectNode user = messages.addObject();
user.put("role", "user");
@@ -459,6 +496,11 @@ public class AihrModelSeedService {
return value == null ? null : value.trim();
}
private static String normalizeModelDescribe(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.replace("用于 Demo 对话", "用于通用对话").replace("Demo 对话", "通用对话");
}
private static String emptyToNull(String value) {
String cleaned = clean(value);
return isBlank(cleaned) ? null : cleaned;
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgProjectOption;
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgSnapshotResponse;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse;
import org.springframework.beans.factory.annotation.Value;
@@ -23,9 +26,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -41,6 +42,9 @@ public class AihrOrgSyncService {
private static final int MAX_PAGE_SIZE = 500;
private static final int DEFAULT_MAX_PAGES = 200;
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30);
private static final String[] PHONE_FIELDS = {"phone", "phone_number", "phoneNumber", "mobile", "mobile_phone",
"mobilePhone", "mobile_tel", "mobileTel", "cellphone", "cellPhone", "contact_phone",
"contactPhone", "contact_mobile", "contactMobile", "phonenumber", "telephone", "tel", "手机号", "手机", "联系电话"};
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
@@ -65,13 +69,15 @@ public class AihrOrgSyncService {
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null) : request;
String baseUrl = normalizeBaseUrl(configuredBaseUrl);
if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /open/v1 前缀");
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
}
requireSnapshotTable();
int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE);
int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES);
boolean dryRun = Boolean.TRUE.equals(req.dryRun());
if (!dryRun) {
requireSnapshotTable();
}
boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting());
String token = accessToken(baseUrl);
List<String> warnings = new ArrayList<>();
@@ -84,14 +90,29 @@ public class AihrOrgSyncService {
List<OrgRow> rows = new ArrayList<>();
int skipped = 0;
int maskedPhone = 0;
for (JsonNode employee : employeeItems) {
OrgRow row = orgRow(employee, companies, departments);
if (row == null) {
skipped++;
} else {
if (row.personPhone().isBlank() && hasUnusablePhoneCandidate(employee)) {
maskedPhone++;
}
rows.add(row);
}
}
int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count();
int suspectText = (int) rows.stream().filter(AihrOrgSyncService::hasSuspectText).count();
if (!rows.isEmpty() && phoneLinked < rows.size()) {
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
}
if (suspectText > 0) {
warnings.add("外部员工快照存在 " + suspectText + " 条疑似乱码字段,请检查开放接口编码或导入链路");
}
if (maskedPhone > 0) {
warnings.add("外部员工快照存在 " + maskedPhone + " 条脱敏/不可用手机号,不能用于移动端正式登录映射");
}
if (!dryRun && replaceExisting && rows.isEmpty()) {
throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照");
}
@@ -108,10 +129,61 @@ public class AihrOrgSyncService {
employeeItems.size(),
dryRun ? 0 : rows.size(),
skipped,
phoneLinked,
maskedPhone,
suspectText,
List.copyOf(warnings)
);
}
public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status,
Integer pageNum, Integer pageSize, Integer limit) {
requireSnapshotTable();
int safePageNum = clamp(pageNum, 1, 1, 100000);
int safePageSize = clamp(pageSize == null ? limit : pageSize, 20, 1, MAX_PAGE_SIZE);
int offset = (safePageNum - 1) * safePageSize;
List<Object> args = new ArrayList<>();
String where = orgSnapshotWhere(keyword, projectCode, positionLevel, status, args);
int total = countOrgRows("select count(*) from aihr_org_snapshot" + where, args);
int active = countOrgRows("select count(*) from aihr_org_snapshot" + where + " and employment_status = 'active'", args);
int departed = countOrgRows("select count(*) from aihr_org_snapshot" + where + " and employment_status = 'departed'", args);
int phoneLinked = countOrgRows("select count(*) from aihr_org_snapshot" + where + " and person_phone is not null and person_phone <> ''", args);
int suspectText = countOrgRows("""
select count(*) from aihr_org_snapshot
""" + where + """
and (
person_name like '%?%'
or project_name like '%?%'
or dept_name like '%?%'
or position_name like '%?%'
)
""", args);
List<Object> rowArgs = new ArrayList<>(args);
rowArgs.add(safePageSize);
rowArgs.add(offset);
List<OrgPersonRow> rows = jdbcTemplate.query("""
select id, project_code, project_name, dept_name, ext_party_id, person_name,
position_name, position_level, employment_status,
date_format(snapshot_date, '%Y-%m-%d') snapshot_date
from aihr_org_snapshot
""" + where + """
order by project_name, dept_name, position_level, person_name
limit ? offset ?
""", (rs, rowNum) -> new OrgPersonRow(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("project_name"),
rs.getString("dept_name"),
rs.getString("ext_party_id"),
rs.getString("person_name"),
rs.getString("position_name"),
rs.getString("position_level"),
rs.getString("employment_status"),
rs.getString("snapshot_date")
), rowArgs.toArray());
return new OrgSnapshotResponse(total, active, departed, phoneLinked, suspectText, safePageNum, safePageSize, orgProjects(), orgLevels(), rows);
}
private List<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages, List<String> warnings) {
try {
@@ -131,7 +203,7 @@ public class AihrOrgSyncService {
Map<String, String> query = new TreeMap<>();
query.put("resource_type", resourceType);
query.put("page", String.valueOf(page));
query.put("page_size", String.valueOf(pageSize));
query.put("limit", String.valueOf(pageSize));
putIfNotBlank(query, "group_id", req.groupId());
putIfNotBlank(query, "company_id", req.companyId());
putIfNotBlank(query, "department_id", req.departmentId());
@@ -190,16 +262,16 @@ public class AihrOrgSyncService {
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(HTTP_TIMEOUT)
.header("Accept", "application/json");
String requestBody = body == null ? "" : body;
if (!clean(token).isBlank()) {
builder.header("Authorization", bearer(token));
sign(builder, method, uri.getRawPath(), queryString, requestBody);
}
String requestBody = body == null ? "" : body;
if ("POST".equals(method)) {
builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(requestBody));
} else {
builder.GET();
}
sign(builder, method, uri.getRawPath(), queryString, requestBody);
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(HTTP_TIMEOUT)
.build()
@@ -218,20 +290,21 @@ public class AihrOrgSyncService {
}
private void sign(HttpRequest.Builder builder, String method, String path, String queryString, String body) throws Exception {
String signingSecret = clean(configuredSigningSecret);
String signingSecret = firstNonBlank(configuredSigningSecret, configuredClientSecret);
String clientId = clean(configuredClientId);
if (signingSecret.isBlank() || clientId.isBlank()) {
return;
}
String timestamp = Instant.now().toString();
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString();
String plain = method + "\n" + path + "\n" + queryString + "\n" + sha256(body) + "\n" + timestamp + "\n" + nonce;
String plain = method + "\n" + path + "\n" + queryString + "\n" + sha256(signatureBody(body)) + "\n" + timestamp + "\n" + nonce;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
builder.header("X-Client-Id", clientId)
.header("X-Client-Secret", clean(configuredClientSecret))
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", Base64.getEncoder().encodeToString(mac.doFinal(plain.getBytes(StandardCharsets.UTF_8))));
.header("X-Signature", hex(mac.doFinal(plain.getBytes(StandardCharsets.UTF_8))));
}
private void saveRows(List<OrgRow> rows, boolean replaceExisting) {
@@ -241,13 +314,14 @@ public class AihrOrgSyncService {
}
jdbcTemplate.batchUpdate("""
insert into aihr_org_snapshot
(tenant_id, project_code, project_name, dept_name, ext_party_id, person_name,
(tenant_id, project_code, project_name, dept_name, ext_party_id, person_phone, person_name,
position_name, position_level, employment_status, snapshot_date, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
on duplicate key update
project_code = values(project_code),
project_name = values(project_name),
dept_name = values(dept_name),
person_phone = values(person_phone),
person_name = values(person_name),
position_name = values(position_name),
position_level = values(position_level),
@@ -281,16 +355,18 @@ public class AihrOrgSyncService {
deptName,
"组织架构"
);
String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", "title"), "员工");
String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle",
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工");
String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName));
return new OrgRow(
projectCode,
projectName,
deptName,
extPartyId,
phone(employee),
firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"),
positionName,
positionLevel,
positionLevel(positionLevel, positionName),
status(text(employee, "status", "employment_status", "employmentStatus")),
LocalDate.now()
);
@@ -325,6 +401,70 @@ public class AihrOrgSyncService {
return map;
}
private String orgSnapshotWhere(String keyword, String projectCode, String positionLevel, String status, List<Object> args) {
StringBuilder where = new StringBuilder(" where tenant_id = ?");
args.add(TENANT_ID);
String keywordValue = clean(keyword);
if (!keywordValue.isBlank()) {
String like = "%" + keywordValue + "%";
where.append("""
and (person_name like ? or ext_party_id like ? or person_phone like ?
or position_name like ? or dept_name like ? or project_name like ?)
""");
args.add(like);
args.add(like);
args.add(like);
args.add(like);
args.add(like);
args.add(like);
}
String project = clean(projectCode);
if (!project.isBlank()) {
where.append(" and project_code = ?");
args.add(project);
}
String levelValue = clean(positionLevel);
if (!levelValue.isBlank()) {
where.append(" and position_level = ?");
args.add(levelValue);
}
String statusValue = clean(status);
if (!statusValue.isBlank()) {
where.append(" and employment_status = ?");
args.add(statusValue);
}
return where.toString();
}
private int countOrgRows(String sql, List<Object> args) {
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, args.toArray());
return count == null ? 0 : count;
}
private List<OrgProjectOption> orgProjects() {
return jdbcTemplate.query("""
select project_code, max(project_name) project_name
from aihr_org_snapshot
where tenant_id = ?
group by project_code
order by project_name
limit 200
""", (rs, rowNum) -> new OrgProjectOption(
rs.getString("project_code"),
rs.getString("project_name")
), TENANT_ID);
}
private List<String> orgLevels() {
return jdbcTemplate.query("""
select distinct position_level
from aihr_org_snapshot
where tenant_id = ? and position_level is not null and position_level <> ''
order by position_level
limit 20
""", (rs, rowNum) -> rs.getString("position_level"), TENANT_ID);
}
private void requireSnapshotTable() {
try {
Integer count = jdbcTemplate.queryForObject("""
@@ -335,11 +475,35 @@ public class AihrOrgSyncService {
if (count == null || count == 0) {
throw new IllegalArgumentException("请先执行 aihr_org_snapshot_mysql8.sql 初始化组织人员快照表");
}
ensureSnapshotColumn("person_phone", "ALTER TABLE aihr_org_snapshot ADD COLUMN `person_phone` varchar(30) DEFAULT NULL COMMENT '手机号(仅用于登录身份映射)' AFTER `ext_party_id`");
ensureSnapshotIndex("idx_aihr_org_phone", "ALTER TABLE aihr_org_snapshot ADD KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`)");
} catch (DataAccessException e) {
throw new IllegalStateException("组织人员快照表检查失败: " + e.getMessage(), e);
}
}
private void ensureSnapshotColumn(String column, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' AND COLUMN_NAME = ?
""", Integer.class, column);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private void ensureSnapshotIndex(String index, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' AND INDEX_NAME = ?
""", Integer.class, index);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private static String status(String value) {
String text = clean(value).toLowerCase();
if (text.isBlank() || text.equals("1") || text.equals("active") || text.equals("enabled")
@@ -364,10 +528,58 @@ public class AihrOrgSyncService {
return "一线";
}
private static String positionLevel(String rawLevel, String positionName) {
String inferred = level(positionName);
String level = clean(rawLevel);
if (level.isBlank() || (("一线".equals(level) || "员工".equals(level)) && !"一线".equals(inferred))) {
return inferred;
}
return level;
}
private static String phone(JsonNode employee) {
for (String field : PHONE_FIELDS) {
String normalized = normalizeMobilePhone(text(employee, field));
if (!normalized.isBlank()) {
return normalized;
}
}
return "";
}
static String normalizeMobilePhone(String value) {
String digits = clean(value).replaceAll("\\D", "");
if (digits.length() == 13 && digits.startsWith("86")) {
digits = digits.substring(2);
}
return digits.matches("1[3-9]\\d{9}") ? digits : "";
}
static boolean hasUnusablePhoneCandidate(JsonNode employee) {
for (String field : PHONE_FIELDS) {
String value = text(employee, field);
if (!value.isBlank() && normalizeMobilePhone(value).isBlank()) {
return true;
}
}
return false;
}
private static boolean hasSuspectText(OrgRow row) {
return hasQuestionMark(row.projectName())
|| hasQuestionMark(row.deptName())
|| hasQuestionMark(row.personName())
|| hasQuestionMark(row.positionName());
}
private static boolean hasQuestionMark(String value) {
return clean(value).contains("?");
}
private static String text(JsonNode node, String... fields) {
for (String field : fields) {
JsonNode child = node.path(field);
if (child.isValueNode()) {
if (child.isValueNode() && !child.isNull()) {
String value = clean(child.asText());
if (!value.isBlank()) {
return value;
@@ -417,6 +629,14 @@ public class AihrOrgSyncService {
private static String sha256(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
return hex(bytes);
}
private static String signatureBody(String value) {
return clean(value).isBlank() ? "{}" : value;
}
private static String hex(byte[] bytes) {
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
@@ -454,6 +674,7 @@ public class AihrOrgSyncService {
String projectName,
String deptName,
String extPartyId,
String personPhone,
String personName,
String positionName,
String positionLevel,
@@ -462,7 +683,7 @@ public class AihrOrgSyncService {
) {
Object[] args() {
return new Object[] {
TENANT_ID, projectCode, projectName, deptName, extPartyId, personName,
TENANT_ID, projectCode, projectName, deptName, extPartyId, personPhone, personName,
positionName, positionLevel, employmentStatus, snapshotDate
};
}
@@ -38,6 +38,15 @@ public class AihrPracticeLlmService {
) {
}
public record PracticeTurn(
String customerText,
int emotion,
int trust,
String coachHint,
boolean redFlag
) {
}
/**
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
*/
@@ -64,6 +73,61 @@ public class AihrPracticeLlmService {
.filter(text -> !text.isBlank());
}
/**
* 生成下一句业主回应,并让模型同步给出回合情绪、信任、红线和教练提示;解析失败由调用方回退 seed。
*/
public Optional<PracticeTurn> customerTurn(
String personaTraits,
String project,
String goal,
List<DialogueResponse> dialogue,
String traineeText,
String nextBeat,
int defaultEmotion,
int defaultTrust,
String defaultCoachHint
) {
return customerTurn(personaTraits, project, goal, dialogue, traineeText, nextBeat, defaultEmotion, defaultTrust, defaultCoachHint, "");
}
public Optional<PracticeTurn> customerTurn(
String personaTraits,
String project,
String goal,
List<DialogueResponse> dialogue,
String traineeText,
String nextBeat,
int defaultEmotion,
int defaultTrust,
String defaultCoachHint,
String styleInstruction
) {
String styleRule = styleInstruction == null || styleInstruction.isBlank()
? ""
: "\n 6. 本次只调整 coachHint 风格:" + styleInstruction.trim();
String system = """
你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。
人设特征:%s
场景:%s
隐性诉求:%s
规则:
1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。
2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。
3. emotion/trust 为 0-100 整数;员工承接越好 trust 越高,推责或对抗则 redFlag=true。
4. coachHint 给员工下一步改进建议,不超过60字。
5. 剧情推进方向(不要照抄原句):%s
%s
只输出 JSON,不要输出其他文字:
{"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false}
""".formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, styleRule, defaultEmotion, defaultTrust);
String user = "对话记录:\n" + renderDialogue(dialogue)
+ "\n员工刚才说:" + traineeText
+ "\n默认教练提示:" + defaultCoachHint
+ "\n请输出 JSON。";
return modelService.tryChat(system, user, CUSTOMER_TEMPERATURE)
.flatMap(content -> parseTurn(content, defaultEmotion, defaultTrust, defaultCoachHint));
}
/**
* /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。
*/
@@ -111,6 +175,26 @@ public class AihrPracticeLlmService {
}
}
private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) {
try {
JsonNode root = objectMapper.readTree(extractJson(content));
String customerText = cleanCustomerLine(root.path("customerText").asText(""));
if (customerText.isBlank()) {
return Optional.empty();
}
return Optional.of(new PracticeTurn(
truncateText(customerText, MAX_CUSTOMER_CHARS * 2),
clampScore(root.path("emotion").asInt(defaultEmotion)),
clampScore(root.path("trust").asInt(defaultTrust)),
truncateText(root.path("coachHint").asText(defaultCoachHint), 120),
root.path("redFlag").asBoolean(false)
));
} catch (Exception e) {
log.warn("practice turn parse failed, falls back to seed: {}", e.getMessage());
return Optional.empty();
}
}
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
private static int dimension(JsonNode root, String field, int fallback) {
JsonNode node = root.path(field);
@@ -6,6 +6,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackItemResponse;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackRequest;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackResponse;
import org.dromara.aihr.domain.AihrSopDto.AnswerFeedbackReviewResponse;
import org.dromara.aihr.domain.AihrSopDto.CardObjection;
import org.dromara.aihr.domain.AihrSopDto.CardStep;
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
@@ -23,6 +27,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
@@ -60,6 +66,7 @@ import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
@@ -68,9 +75,11 @@ import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
@@ -89,6 +98,19 @@ public class AihrSopSeedService {
private static final String LOCAL_EMBEDDING_MODEL = "local-hash-v1";
private static final String LOCAL_EMBEDDING_BASE_URL = "local";
private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
private static final int MAX_EVIDENCE_SNIPPETS = 3;
private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据";
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1";
private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;"
+ "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。";
private static final String SOP_ANSWER_USER_TEMPLATE = """
员工的问题:{{queryText}}
检索命中的 SOP 片段:
{{context}}
""";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final Map<String, SopSeed> seeds = buildSeeds();
@@ -98,6 +120,9 @@ public class AihrSopSeedService {
private final String importRootConfig;
private final ScheduledExecutorService scheduledExecutorService;
private final AihrVideoService videoService;
private volatile boolean knowledgeGapTableReady;
private volatile boolean sopReviewTableReady;
private volatile boolean answerFeedbackTableReady;
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
@Value("${aihr.import.root:}") String importRootConfig,
@@ -114,23 +139,133 @@ public class AihrSopSeedService {
String requestedCategory = request == null ? "" : request.category();
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
return dbResponse;
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
String source = normalizeSearchSource(request == null ? null : request.source());
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
if (isBlank(queryText)) {
return withReviewId(noEvidenceResponse("", category), source);
}
return new SearchResponse(
queryText,
seed.category(),
seed.answer(),
seed.reference(),
seed.docs(),
seed.snippets(),
seed.training(),
records(),
List.of(),
List.of()
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
if (dbResponse != null) {
if (isNoEvidenceAnswer(dbResponse.answer())) {
recordKnowledgeGap(queryText, category, position, source);
}
return withReviewId(dbResponse, source);
}
recordKnowledgeGap(queryText, category, position, source);
return withReviewId(noEvidenceResponse(queryText, category), source);
}
public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) {
if (id == null) {
return null;
}
ensureSopReviewTable();
int updated = jdbcTemplate.update("""
UPDATE aihr_sop_answer_review
SET usable = ?, reviewer = ?, note = ?, status = '已评审', reviewed_time = ?, update_time = ?
WHERE tenant_id = ? AND id = ?
""",
request != null && Boolean.TRUE.equals(request.usable()),
firstNonBlank(request == null ? null : request.reviewer(), "人工评审"),
firstNonBlank(request == null ? null : request.note(), ""),
Timestamp.valueOf(java.time.LocalDateTime.now()),
Timestamp.valueOf(java.time.LocalDateTime.now()),
TENANT_ID,
id
);
return updated == 0 ? null : sopReview(id);
}
public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request) {
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
String queryNorm = normalizeFeedbackQuery(queryText);
if (isBlank(queryNorm)) {
throw new ServiceException("请先输入问题");
}
String verdict = normalizeFeedbackVerdict(request == null ? null : request.verdict());
if (isBlank(verdict)) {
throw new ServiceException("反馈类型只支持 up/down");
}
List<Long> fragmentIds = feedbackFragmentIds(request);
if (fragmentIds.isEmpty()) {
throw new ServiceException("缺少片段ID");
}
ensureAnswerFeedbackTable();
String reviewStatus = "down".equals(verdict) ? "待复核" : "已复核";
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
int saved = 0;
for (Long fragmentId : fragmentIds) {
saved += jdbcTemplate.update("""
INSERT INTO aihr_knowledge_answer_feedback
(tenant_id, query_text, query_norm, fragment_id, verdict, category, position, source, review_status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
query_text = VALUES(query_text),
verdict = VALUES(verdict),
category = VALUES(category),
position = VALUES(position),
source = VALUES(source),
review_status = VALUES(review_status),
update_time = VALUES(update_time)
""",
TENANT_ID,
truncate(queryText, 1000),
queryNorm,
fragmentId,
verdict,
firstNonBlank(request.category(), "sop"),
firstNonBlank(request.position(), "生活顾问"),
normalizeSearchSource(request.source()),
reviewStatus,
now,
now
);
}
return new AnswerFeedbackResponse(saved, verdict, reviewStatus);
}
public List<AnswerFeedbackItemResponse> answerFeedbackList(String status, int limit) {
ensureAnswerFeedbackTable();
String reviewStatus = firstNonBlank(status, "待复核");
int limitValue = Math.max(1, Math.min(limit, 200));
return jdbcTemplate.query("""
SELECT id, query_text, query_norm, fragment_id, verdict, category, position, source, review_status, create_time, update_time
FROM aihr_knowledge_answer_feedback
WHERE tenant_id = ?
AND (? = '' OR review_status = ?)
ORDER BY create_time DESC, id DESC
LIMIT ?
""", (rs, rowNum) -> new AnswerFeedbackItemResponse(
rs.getLong("id"),
rs.getString("query_text"),
rs.getString("query_norm"),
rs.getLong("fragment_id"),
rs.getString("verdict"),
rs.getString("category"),
rs.getString("position"),
rs.getString("source"),
rs.getString("review_status"),
formatTimestamp(rs.getTimestamp("create_time")),
formatTimestamp(rs.getTimestamp("update_time"))
), TENANT_ID, reviewStatus, reviewStatus, limitValue);
}
public AnswerFeedbackReviewResponse reviewAnswerFeedback(Long id) {
if (id == null) {
return null;
}
ensureAnswerFeedbackTable();
int updated = jdbcTemplate.update("""
UPDATE aihr_knowledge_answer_feedback
SET review_status = '已复核', update_time = ?
WHERE tenant_id = ? AND id = ?
""",
Timestamp.valueOf(java.time.LocalDateTime.now()),
TENANT_ID,
id
);
return updated == 0 ? null : new AnswerFeedbackReviewResponse(id, "已复核");
}
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
@@ -1624,12 +1759,272 @@ public class AihrSopSeedService {
if (hits.isEmpty()) {
return null;
}
return responseFromHits(category, queryText, hits);
FeedbackFilterResult filtered = filterDownFeedback(queryText, hits);
return responseFromHits(category, queryText, filtered.hits(), filtered.cautions());
} catch (DataAccessException e) {
return null;
}
}
private void recordKnowledgeGap(String queryText, String category, String position, String source) {
if (isBlank(queryText)) {
return;
}
try {
ensureKnowledgeGapTable();
jdbcTemplate.update("""
INSERT INTO aihr_knowledge_gap
(tenant_id, query_text, category, position, source, status, create_time)
VALUES (?, ?, ?, ?, ?, '待补充', ?)
""",
TENANT_ID,
queryText.trim(),
firstNonBlank(category, "sop"),
firstNonBlank(position, "生活顾问"),
normalizeSearchSource(source),
Timestamp.valueOf(java.time.LocalDateTime.now())
);
} catch (DataAccessException ignored) {
// Gap logging is additive and must not break SOP search fallback.
}
}
private SearchResponse withReviewId(SearchResponse response, String source) {
Long reviewId = skipSopReview(source) ? null : createSopReview(response, source);
return new SearchResponse(
response.queryText(),
response.category(),
response.answer(),
response.reference(),
response.docs(),
response.snippets(),
response.training(),
response.records(),
response.keyPoints(),
response.cautions(),
reviewId
);
}
static boolean skipSopReview(String source) {
return "verify_demo_questions".equals(source);
}
private Long createSopReview(SearchResponse response, String source) {
try {
ensureSopReviewTable();
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement("""
INSERT INTO aihr_sop_answer_review
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, TENANT_ID);
ps.setString(2, truncate(response.queryText(), 1000));
ps.setString(3, firstNonBlank(response.category(), "sop"));
ps.setString(4, truncate(response.answer(), 2000));
ps.setString(5, truncate(response.reference(), 1000));
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
ps.setString(7, normalizeSearchSource(source));
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
ps.setTimestamp(8, now);
ps.setTimestamp(9, now);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
return key == null ? null : key.longValue();
} catch (DataAccessException e) {
return null;
}
}
private SopReviewResponse sopReview(Long id) {
List<SopReviewResponse> rows = jdbcTemplate.query("""
SELECT id, usable, status, reviewed_time
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND id = ?
LIMIT 1
""", (rs, rowNum) -> {
Timestamp reviewedTime = rs.getTimestamp("reviewed_time");
return new SopReviewResponse(
rs.getLong("id"),
rs.getObject("usable") == null ? null : rs.getBoolean("usable"),
rs.getString("status"),
reviewedTime == null ? "" : reviewedTime.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
);
}, TENANT_ID, id);
return rows.isEmpty() ? null : rows.get(0);
}
public SopReviewStats sopReviewStats() {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID);
}
public void prepareSopReviewStats() {
ensureSopReviewTable();
}
public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND reviewed_time >= ? AND reviewed_time < ?
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")),
TENANT_ID, Timestamp.valueOf(startTime), Timestamp.valueOf(endTime));
}
private void ensureSopReviewTable() {
if (sopReviewTableReady) {
return;
}
synchronized (this) {
if (sopReviewTableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '问题',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`answer_text` varchar(2000) DEFAULT NULL COMMENT '回答',
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
`note` varchar(500) DEFAULT NULL COMMENT '评审备注',
`status` varchar(30) DEFAULT '待评审' COMMENT '状态',
`reviewed_time` datetime DEFAULT NULL COMMENT '评审时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
""");
ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`");
sopReviewTableReady = true;
}
}
private void ensureAnswerFeedbackTable() {
if (answerFeedbackTableReady) {
return;
}
synchronized (this) {
if (answerFeedbackTableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '问题原文',
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
`fragment_id` bigint NOT NULL COMMENT '知识片段ID',
`verdict` varchar(10) NOT NULL COMMENT '反馈: up/down',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`review_status` varchar(30) DEFAULT '待复核' COMMENT '复核状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`),
KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`),
KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈';
""");
answerFeedbackTableReady = true;
}
}
private void ensureKnowledgeGapTable() {
if (knowledgeGapTableReady) {
return;
}
synchronized (this) {
if (knowledgeGapTableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '未命中问题',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`status` varchar(30) DEFAULT '待补充' COMMENT '状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志';
""");
knowledgeGapTableReady = true;
}
}
private void ensureColumn(String table, String column, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
""", Integer.class, table, column);
if (exists == null || exists == 0) {
jdbcTemplate.execute(ddl);
}
}
private String normalizeSearchSource(String value) {
if (isBlank(value)) {
return "knowledge_search";
}
String source = value.trim().replaceAll("[^A-Za-z0-9_-]", "_");
if (source.length() > 50) {
source = source.substring(0, 50);
}
return source.isBlank() ? "knowledge_search" : source;
}
private static String normalizeFeedbackQuery(String value) {
return truncate(Optional.ofNullable(value).orElse("")
.trim()
.toLowerCase(Locale.ROOT)
.replaceAll("\\s+", " ")
.trim(), 500);
}
private static String normalizeFeedbackVerdict(String value) {
String verdict = Optional.ofNullable(value).orElse("").trim().toLowerCase(Locale.ROOT);
return "up".equals(verdict) || "down".equals(verdict) ? verdict : "";
}
private static List<Long> feedbackFragmentIds(AnswerFeedbackRequest request) {
LinkedHashSet<Long> ids = new LinkedHashSet<>();
if (request != null && request.fragmentIds() != null) {
request.fragmentIds().stream()
.filter(id -> id != null && id > 0)
.forEach(ids::add);
}
if (ids.isEmpty() && request != null && request.fragmentId() != null && request.fragmentId() > 0) {
ids.add(request.fragmentId());
}
return ids.stream().toList();
}
private static String formatTimestamp(Timestamp timestamp) {
return timestamp == null ? "" : timestamp.toLocalDateTime().format(TIME_FORMATTER);
}
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
int limitValue = limitValue(limit);
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
@@ -1637,6 +2032,41 @@ public class AihrSopSeedService {
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
}
private FeedbackFilterResult filterDownFeedback(String queryText, List<KnowledgeHit> hits) {
Set<Long> rejected = downFeedbackFragmentIds(queryText);
if (rejected.isEmpty()) {
return new FeedbackFilterResult(hits, List.of());
}
List<KnowledgeHit> filtered = hits.stream()
.filter(hit -> hit.fragmentId() == null || !rejected.contains(hit.fragmentId()))
.toList();
if (filtered.isEmpty()) {
return new FeedbackFilterResult(
hits,
List.of("该问题下命中的片段均曾被点踩,本次保留原结果避免无答案。")
);
}
return new FeedbackFilterResult(filtered, List.of());
}
private Set<Long> downFeedbackFragmentIds(String queryText) {
String queryNorm = normalizeFeedbackQuery(queryText);
if (isBlank(queryNorm)) {
return Set.of();
}
try {
return new LinkedHashSet<>(jdbcTemplate.queryForList("""
SELECT fragment_id
FROM aihr_knowledge_answer_feedback
WHERE tenant_id = ?
AND query_norm = ?
AND verdict = 'down'
""", Long.class, TENANT_ID, queryNorm));
} catch (DataAccessException ignored) {
return Set.of();
}
}
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
@@ -1661,7 +2091,8 @@ public class AihrSopSeedService {
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit) {
return jdbcTemplate.query("""
select coalesce(a.name, i.name) as title,
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
i.name as category,
coalesce(i.description, '') as description,
f.doc_id,
@@ -1677,6 +2108,7 @@ public class AihrSopSeedService {
order by score desc, f.idx asc
limit ?
""", (rs, rowNum) -> new KnowledgeHit(
rs.getLong("fragment_id"),
rs.getString("title"),
rs.getString("category"),
rs.getString("description"),
@@ -1699,7 +2131,8 @@ public class AihrSopSeedService {
whereParts.add("f.content like ? escape '\\\\'");
}
String sql = """
select coalesce(a.name, i.name) as title,
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
i.name as category,
coalesce(i.description, '') as description,
f.doc_id,
@@ -1723,6 +2156,7 @@ public class AihrSopSeedService {
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
args.add(limit);
return jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
rs.getLong("fragment_id"),
rs.getString("title"),
rs.getString("category"),
rs.getString("description"),
@@ -1735,7 +2169,8 @@ public class AihrSopSeedService {
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match) {
List<KnowledgeHit> rows = jdbcTemplate.query("""
select coalesce(a.name, i.name) as title,
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
i.name as category,
coalesce(i.description, '') as description,
f.doc_id,
@@ -1749,6 +2184,7 @@ public class AihrSopSeedService {
and f.idx = ?
limit 1
""", (rs, rowNum) -> new KnowledgeHit(
rs.getLong("fragment_id"),
rs.getString("title"),
rs.getString("category"),
rs.getString("description"),
@@ -1786,27 +2222,102 @@ public class AihrSopSeedService {
}
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
KnowledgeHit first = hits.get(0);
return responseFromHits(category, queryText, hits, List.of());
}
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits, List<String> extraCautions) {
List<KnowledgeHit> evidenceHits = evidenceHits(queryText, hits);
KnowledgeHit first = evidenceHits.get(0);
String responseCategory = isBlank(category) ? first.category() : category;
List<SnippetResponse> snippets = hits.stream()
.map(hit -> new SnippetResponse("第 " + hit.idx() + " 段:" + hit.title(), hit.content()))
List<SnippetResponse> snippets = evidenceHits.stream()
.map(hit -> new SnippetResponse("第 " + hit.idx() + " 段:" + hit.title(), displaySnippetText(hit.content()), hit.fragmentId()))
.toList();
Map<String, DocResponse> docs = new LinkedHashMap<>();
for (KnowledgeHit hit : hits) {
for (KnowledgeHit hit : evidenceHits) {
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
}
SearchDigest digest = searchDigest(queryText, hits);
SearchDigest digest = searchDigest(queryText, evidenceHits);
if (digest == null) {
return rawHitResponse(responseCategory, queryText, List.copyOf(docs.values()), snippets, extraCautions);
}
if (isNoEvidenceAnswer(digest.answer())) {
return noEvidenceResponse(queryText, responseCategory, appendCautions(digest.cautions(), extraCautions));
}
return new SearchResponse(
queryText,
responseCategory,
digest != null ? digest.answer() : "根据已入库 SOP:" + first.content(),
digest.answer(),
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
List.copyOf(docs.values()),
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records(),
digest != null ? digest.keyPoints() : List.of(),
digest != null ? digest.cautions() : List.of()
digest.keyPoints(),
appendCautions(digest.cautions(), extraCautions),
null
);
}
static List<KnowledgeHit> evidenceHits(String queryText, List<KnowledgeHit> hits) {
if (hits == null || hits.isEmpty()) {
return List.of();
}
KnowledgeHit first = hits.get(0);
if (queryMentionsTitle(queryText, first.title())) {
List<KnowledgeHit> sameTitle = hits.stream()
.filter(hit -> Objects.equals(hit.title(), first.title()))
.limit(MAX_EVIDENCE_SNIPPETS)
.toList();
if (!sameTitle.isEmpty()) {
return sameTitle;
}
}
return hits.stream().limit(MAX_EVIDENCE_SNIPPETS).toList();
}
private static boolean queryMentionsTitle(String queryText, String title) {
String query = evidenceText(queryText);
String docTitle = evidenceText(title);
return query.length() >= 4 && docTitle.length() >= 4 && (query.contains(docTitle) || docTitle.contains(query));
}
private static String evidenceText(String value) {
return Optional.ofNullable(value).orElse("")
.toLowerCase(Locale.ROOT)
.replaceFirst("\\.[a-z0-9]{1,8}$", "")
.replaceAll("[^\\p{IsHan}\\p{Alnum}]+", "");
}
static String displaySnippetText(String text) {
String value = Optional.ofNullable(text).orElse("").strip();
if (!value.contains("【语音转写】")) {
return value;
}
return value
.replaceAll("[🎼♪♫♬]+", " ")
.replaceAll("(?iu)\\b(?:yeah|the|uh|um)\\b[.!。!?,,\\s]*", " ")
.replaceAll("[ぁ-ゟァ-ヿ]+[.!。!?,,\\s]*", " ")
.replaceAll("\\s+", " ")
.trim()
.replace("【语音转写】 ", "【语音转写】\n");
}
static SearchResponse rawHitResponse(String category, String queryText, List<DocResponse> docs, List<SnippetResponse> snippets, List<String> extraCautions) {
if (snippets == null || snippets.isEmpty()) {
return noEvidenceResponse(queryText, category, extraCautions);
}
return new SearchResponse(
queryText,
firstNonBlank(category, "sop"),
"已找到相关资料:" + snippetPreview(snippets.get(0).text()),
"引用 " + (docs == null ? 0 : docs.size()) + " 份文档 / " + snippets.size() + " 个片段",
docs == null ? List.of() : docs,
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records(),
List.of(),
appendCautions(List.of("未生成结构化概要,已返回命中原文片段。"), extraCautions),
null
);
}
@@ -1826,16 +2337,19 @@ public class AihrSopSeedService {
.append(truncate(hit.content(), 1500)).append("\n\n");
}
try {
String content = callDigestModel(runtime.get(), queryText, context.toString());
DigestModelResult result = callDigestModel(runtime.get(), queryText, context.toString());
String content = result.content();
JsonNode root = objectMapper.readTree(jsonObjectText(content));
String answer = root.path("answer").asText("");
if (isBlank(answer)) {
return null;
}
log.info("sop answer prompt_version={}", result.promptVersion());
return new SearchDigest(
truncate(answer, 200),
jsonStrings(root.path("keyPoints"), 6, 120),
jsonStrings(root.path("cautions"), 3, 120)
jsonStrings(root.path("cautions"), 3, 120),
result.promptVersion()
);
} catch (Exception e) {
log.warn("search digest failed, keep raw answer: {}", e.getMessage());
@@ -1843,7 +2357,8 @@ public class AihrSopSeedService {
}
}
private String callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception {
private DigestModelResult callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception {
PromptTemplate prompt = sopAnswerPrompt();
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("temperature", 0);
@@ -1852,19 +2367,11 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 是对问题的直接回答(不超过60字,含关键数字/时限);keyPoints 是3到6条可执行要点(每条一句话,含具体数字);"
+ "cautions 是0到3条注意事项或红线。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。");
system.put("content", prompt.systemPrompt());
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
员工的问题:%s
检索命中的 SOP 片段:
%s
""".formatted(queryText, context));
user.put("content", renderPrompt(prompt.template(), queryText, context));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -1885,7 +2392,35 @@ public class AihrSopSeedService {
if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("digest response missing choices");
}
return choices.get(0).path("message").path("content").asText();
return new DigestModelResult(choices.get(0).path("message").path("content").asText(), prompt.promptVersion());
}
private PromptTemplate sopAnswerPrompt() {
try {
List<PromptTemplate> rows = jdbcTemplate.query("""
SELECT system_prompt, template, version
FROM aihr_prompt_template
WHERE tenant_id = ? AND code = 'sop_answer' AND enabled = 1
ORDER BY version DESC, id DESC
LIMIT 1
""", (rs, rowNum) -> new PromptTemplate(
firstNonBlank(rs.getString("system_prompt"), SOP_ANSWER_SYSTEM_PROMPT),
firstNonBlank(rs.getString("template"), SOP_ANSWER_USER_TEMPLATE),
"sop_answer:" + firstNonBlank(rs.getString("version"), "v1")
), TENANT_ID);
if (!rows.isEmpty()) {
return rows.get(0);
}
} catch (DataAccessException ignored) {
// prompt table is optional in demo fallback; built-in prompt keeps SOP search usable.
}
return new PromptTemplate(SOP_ANSWER_SYSTEM_PROMPT, SOP_ANSWER_USER_TEMPLATE, SOP_ANSWER_PROMPT_VERSION);
}
private static String renderPrompt(String template, String queryText, String context) {
return firstNonBlank(template, SOP_ANSWER_USER_TEMPLATE)
.replace("{{queryText}}", firstNonBlank(queryText, ""))
.replace("{{context}}", firstNonBlank(context, ""));
}
private String callSummaryCardModel(ChatRuntime runtime, String queryText, String context) throws Exception {
@@ -1901,7 +2436,8 @@ public class AihrSopSeedService {
你是物业一线员工的SOP信息图总结助手。只返回 JSON,不要 markdown、代码块或解释。
输出格式固定为:
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
steps 必须且只允许 3 条,label 依次固定为「问题」「建议」「标准」:问题写当前要处理的核心问题;建议写下一步动作;标准写SOP依据或红线。
字段约束:title ≤14字;每个 desc ≤32字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
""");
@@ -1953,6 +2489,20 @@ public class AihrSopSeedService {
return List.copyOf(values);
}
private static List<String> appendCautions(List<String> base, List<String> extra) {
if ((base == null || base.isEmpty()) && (extra == null || extra.isEmpty())) {
return List.of();
}
List<String> values = new ArrayList<>();
if (base != null) {
values.addAll(base);
}
if (extra != null) {
values.addAll(extra);
}
return List.copyOf(values);
}
private static List<CardStep> jsonCardSteps(JsonNode node) {
if (!node.isArray()) {
return List.of();
@@ -1989,7 +2539,13 @@ public class AihrSopSeedService {
return List.copyOf(values);
}
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions, String promptVersion) {
}
private record DigestModelResult(String content, String promptVersion) {
}
private record PromptTemplate(String systemPrompt, String template, String promptVersion) {
}
private static String dbCategory(String category) {
@@ -2038,6 +2594,37 @@ public class AihrSopSeedService {
return score <= 0 ? "片段" : String.format("%.2f", score);
}
private static String snippetPreview(String text) {
String cleaned = Optional.ofNullable(text).orElse("")
.replaceAll("\\s+", " ")
.trim();
return truncate(firstNonBlank(cleaned, "请参考下方命中片段。"), 180);
}
private static boolean isNoEvidenceAnswer(String answer) {
return NO_CLEAR_SOP_EVIDENCE.equals(Optional.ofNullable(answer).orElse("").trim());
}
private static SearchResponse noEvidenceResponse(String queryText, String category) {
return noEvidenceResponse(queryText, category, List.of());
}
private static SearchResponse noEvidenceResponse(String queryText, String category, List<String> cautions) {
return new SearchResponse(
queryText,
firstNonBlank(category, "sop"),
NO_CLEAR_SOP_EVIDENCE,
"未找到可引用片段",
List.of(),
List.of(),
List.of(),
records(),
List.of(),
cautions,
null
);
}
private KnowledgeConfig knowledgeConfig(String category) {
List<KnowledgeConfig> rows = jdbcTemplate.query("""
select id, coalesce(text_block_size, 800) as block_size, coalesce(overlap_char, 120) as overlap_char
@@ -2758,7 +3345,10 @@ public class AihrSopSeedService {
) {
}
private record KnowledgeHit(String title, String category, String description, String docId, String content, int idx, double score) {
record KnowledgeHit(Long fragmentId, String title, String category, String description, String docId, String content, int idx, double score) {
}
private record FeedbackFilterResult(List<KnowledgeHit> hits, List<String> cautions) {
}
private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) {
@@ -2824,6 +3414,9 @@ public class AihrSopSeedService {
) {
}
public record SopReviewStats(int total, int usable) {
}
private static final class CategorySummary {
private final String name;
private int files;
@@ -2869,7 +3462,7 @@ public class AihrSopSeedService {
}
private KnowledgeHit toHit() {
return new KnowledgeHit(hit.title(), hit.category(), hit.description(), hit.docId(), hit.content(), hit.idx(), score);
return new KnowledgeHit(hit.fragmentId(), hit.title(), hit.category(), hit.description(), hit.docId(), hit.content(), hit.idx(), score);
}
}
}
@@ -28,7 +28,7 @@ import java.util.UUID;
public class AihrSpeechService {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
private static final String DEFAULT_TTS_VOICE = "anna";
private final AihrModelSeedService modelService;
@@ -0,0 +1,19 @@
package org.dromara.aihr.service;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class AihrCandidateMaterialServiceTest {
@Test
@Tag("dev")
void candidateIdMustComeFromLoginOrExplicitParameter() {
assertEquals("13900000001", AihrCandidateMaterialService.requireCandidateId(" 13900000001 "));
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(""));
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(null));
}
}
@@ -0,0 +1,53 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class AihrCaseServiceTest {
@Test
@Tag("dev")
void uploadMasksSensitiveTranscriptBeforeReturningAndSaving() {
AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) {
@Override
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
return Optional.of("张三先生住在12栋1201,手机号13900001111,说2-304漏水");
}
};
CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate);
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1");
assertEquals("张*先生住在12栋****,手机号1390****1111,说2-****漏水", response.transcript());
assertEquals(response.transcript(), jdbcTemplate.updateArgs[6]);
assertEquals(response.transcript(), jdbcTemplate.updateArgs[7]);
assertFalse(response.transcript().contains("13900001111"));
assertFalse(response.transcript().contains("1201"));
assertFalse(response.transcript().contains("2-304"));
}
private static final class CaseJdbcTemplate extends JdbcTemplate {
private Object[] updateArgs = new Object[0];
@Override
public void execute(String sql) {
// Table DDL is not relevant for this unit test.
}
@Override
public int update(String sql, Object... args) {
this.updateArgs = args;
return 1;
}
}
}
@@ -0,0 +1,42 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
public class AihrMobileSeedServiceTest {
@Test
public void homeMetricsUsePracticeCountsWithoutSeedOffsets() {
AihrPracticeSeedService practice = mock(AihrPracticeSeedService.class);
when(practice.mobileCompletedCount()).thenReturn(4);
when(practice.mobilePendingReviewCount()).thenReturn(3);
when(practice.mobileAverageScore()).thenReturn(81);
when(practice.mobileCompletedPeopleCount()).thenReturn(4);
when(practice.mobilePilotPeopleCount()).thenReturn(5);
when(practice.mobilePendingAssignmentCount()).thenReturn(2);
when(practice.mobileLowScoreCount()).thenReturn(1);
AihrMobileSeedService service = new AihrMobileSeedService(practice);
HomeResponse user = service.home("user");
var metrics = user.metrics().stream()
.collect(Collectors.toMap(item -> item.title(), item -> item.value()));
assertEquals("4", metrics.get("训练"));
assertEquals("81", metrics.get("能力"));
assertEquals("3", metrics.get("待复盘"));
HomeResponse supervisor = service.home("supervisor");
assertEquals("4人已完成 · 1人待跟进", supervisor.team().progressText());
assertEquals(80, supervisor.team().progress());
assertEquals(3, supervisor.todos().get(0).count());
assertEquals(2, supervisor.todos().get(1).count());
assertEquals("1", supervisor.team().stats().get(2).value());
}
}
@@ -0,0 +1,97 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
@Tag("dev")
public class AihrOrgSyncServiceTest {
@Test
public void normalizeMobilePhoneOnlyKeepsUsableMobileNumber() {
assertEquals("13900001111", AihrOrgSyncService.normalizeMobilePhone("+86 139-0000-1111"));
assertEquals("", AihrOrgSyncService.normalizeMobilePhone("139****1111"));
assertEquals("", AihrOrgSyncService.normalizeMobilePhone("025-88888888"));
}
@Test
public void unusablePhoneCandidateDetectsMaskedPhone() throws Exception {
ObjectMapper mapper = new ObjectMapper();
assertTrue(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"phone\":\"139****1111\"}")));
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"phone\":\"13900001111\"}")));
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"name\":\"张三\"}")));
}
@Test
public void dryRunNeverTouchesSnapshotSchemaWhenExternalFetchFails() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl", "http://127.0.0.1:1/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
assertThrows(IllegalStateException.class, () -> service.sync(
new SyncRequest(true, true, 1, 1, null, null, null)
));
verifyNoInteractions(jdbcTemplate, transactionTemplate);
}
@Test
public void successfulDryRunNeverTouchesSnapshotStorage() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/open/v1/sync/snapshot", exchange -> {
boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee");
String items = employee
? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]"
: "[]";
byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0)
+ ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl",
"http://127.0.0.1:" + server.getAddress().getPort() + "/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null));
assertTrue(response.dryRun());
assertEquals(1, response.employeeCount());
assertEquals(0, response.syncedCount());
assertEquals(1, response.phoneLinked());
verifyNoInteractions(jdbcTemplate, transactionTemplate);
} finally {
server.stop(0);
}
}
}
@@ -0,0 +1,86 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrSopDto;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class AihrSopSeedServiceTest {
@Test
@Tag("dev")
public void rawHitResponseKeepsSnippetsWhenDigestUnavailable() {
List<AihrSopDto.DocResponse> docs = List.of(new AihrSopDto.DocResponse("印章管理办法.pdf", "制度文件", "命中 3.00", "pill-success"));
List<AihrSopDto.SnippetResponse> snippets = List.of(new AihrSopDto.SnippetResponse("第 1 段:印章管理办法.pdf", "印章使用前必须取得审批,并保留用印记录。", 100310L));
AihrSopDto.SearchResponse response = AihrSopSeedService.rawHitResponse("sop", "印章怎么使用?", docs, snippets, List.of());
assertFalse(response.docs().isEmpty());
assertEquals(100310L, response.snippets().get(0).fragmentId());
assertTrue(response.answer().contains("印章使用前必须取得审批"));
assertTrue(response.reference().contains("1 份文档 / 1 个片段"));
assertTrue(response.cautions().contains("未生成结构化概要,已返回命中原文片段。"));
}
@Test
@Tag("dev")
public void verifyDemoQuestionsSourceSkipsSopReviewSample() {
assertTrue(AihrSopSeedService.skipSopReview("verify_demo_questions"));
assertFalse(AihrSopSeedService.skipSopReview("knowledge_search"));
}
@Test
@Tag("dev")
public void evidenceHitsKeepsNamedDocumentOnly() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
hit(1L, "生活顾问的一天.mp4"),
hit(2L, "生活顾问的一天.mp4"),
hit(3L, "信息安全管理培训.pptx")
);
List<AihrSopSeedService.KnowledgeHit> evidence = AihrSopSeedService.evidenceHits("生活顾问的一天做什么", hits);
assertEquals(2, evidence.size());
assertTrue(evidence.stream().allMatch(hit -> "生活顾问的一天.mp4".equals(hit.title())));
}
@Test
@Tag("dev")
public void evidenceHitsCapsGenericQueries() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
hit(1L, "投诉处理 SOP.pdf"),
hit(2L, "催缴沟通 SOP.pdf"),
hit(3L, "报修跟进 SOP.pdf"),
hit(4L, "保洁合同.pdf")
);
List<AihrSopSeedService.KnowledgeHit> evidence = AihrSopSeedService.evidenceHits("生活顾问怎么处理业主问题", hits);
assertEquals(3, evidence.size());
assertEquals(3L, evidence.get(2).fragmentId());
}
@Test
@Tag("dev")
public void displaySnippetTextCleansVideoAsrNoise() {
String text = "【语音转写】\n[00:00] 🎼加油。🎼 The。Yeah.う。今天的汇报结束。";
String cleaned = AihrSopSeedService.displaySnippetText(text);
assertTrue(cleaned.contains("【语音转写】\n[00:00] 加油。"));
assertTrue(cleaned.contains("今天的汇报结束。"));
assertFalse(cleaned.contains("🎼"));
assertFalse(cleaned.contains("The"));
assertFalse(cleaned.contains("Yeah"));
assertFalse(cleaned.contains("う"));
}
private static AihrSopSeedService.KnowledgeHit hit(Long fragmentId, String title) {
return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0);
}
}
@@ -75,9 +75,9 @@ public class SysLogininforServiceImpl implements ISysLogininforService {
// 打印信息到日志
log.info(s.toString(), logininforEvent.getArgs());
// 获取客户端操作系统
String os = userAgent.getOs().getName();
String os = userAgent == null ? "Unknown" : userAgent.getOs().getName();
// 获取客户端浏览器
String browser = userAgent.getBrowser().getName();
String browser = userAgent == null ? "Unknown" : userAgent.getBrowser().getName();
// 封装对象
SysLogininforBo logininfor = new SysLogininforBo();
logininfor.setTenantId(logininforEvent.getTenantId());
+1 -1
View File
@@ -49,4 +49,4 @@ CREATE TABLE IF NOT EXISTS `aihr_model_config` (
-- INSERT INTO `aihr_model_provider` (`tenant_id`, `provider_name`, `provider_code`, `provider_desc`, `api_host`, `api_key`, `status`, `sort_order`, `create_time`)
-- VALUES ('000000', 'OpenAI 兼容接口', 'custom_api', 'OPENAI兼容格式', 'https://api.example.com/v1', 'sk-xxx', '0', 0, NOW());
-- INSERT INTO `aihr_model_config` (`tenant_id`, `category`, `model_name`, `provider_code`, `model_describe`, `model_show`, `enabled`, `create_time`)
-- VALUES ('000000', 'chat', 'gpt-4o-mini', 'custom_api', 'Demo 对话与 RAG 生成模型', 'Y', 1, NOW());
-- VALUES ('000000', 'chat', 'gpt-4o-mini', 'custom_api', '通用对话与 RAG 生成模型', 'Y', 1, NOW());
+25 -23
View File
@@ -11,6 +11,7 @@ CREATE TABLE `aihr_org_snapshot` (
`project_name` varchar(100) NOT NULL COMMENT '项目名称',
`dept_name` varchar(100) DEFAULT NULL COMMENT '部门/条线',
`ext_party_id` varchar(100) NOT NULL COMMENT '外部主体ID(只存ID,不回写外部系统)',
`person_phone` varchar(30) DEFAULT NULL COMMENT '手机号(仅用于登录身份映射)',
`person_name` varchar(100) DEFAULT NULL COMMENT '姓名(Demo 快照允许,生产走脱敏闸门G3)',
`position_name` varchar(100) DEFAULT NULL COMMENT '岗位',
`position_level` varchar(30) DEFAULT NULL COMMENT '岗位层级(一线/主管/项目经理)',
@@ -19,33 +20,34 @@ CREATE TABLE `aihr_org_snapshot` (
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `ext_party_id`),
KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`),
KEY `idx_aihr_org_project` (`tenant_id`, `project_code`, `position_level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)';
INSERT INTO `aihr_org_snapshot`
(`tenant_id`, `project_code`, `project_name`, `dept_name`, `ext_party_id`, `person_name`, `position_name`, `position_level`, `snapshot_date`)
(`tenant_id`, `project_code`, `project_name`, `dept_name`, `ext_party_id`, `person_phone`, `person_name`, `position_name`, `position_level`, `snapshot_date`)
VALUES
-- 项目一:翡翠湾(住宅,试点项目)
('000000', 'PRJ-FCW', '翡翠湾项目', '项目管理', 'EMP-1001', '王敏', '项目经理', '项目经理', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1002', '李婷', '客服主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1003', '张倩', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1004', '刘洋', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1005', '陈雪', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1006', '赵磊', '客服专员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1007', '孙强', '秩序主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1008', '周军', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1009', '吴斌', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '工程条线', 'EMP-1010', '郑伟', '工程主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '工程条线', 'EMP-1011', '冯健', '维修工', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '环境条线', 'EMP-1012', '褚芳', '保洁员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '环境条线', 'EMP-1013', '卫红', '保洁员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '项目管理', 'EMP-1001', '13900000101', '王敏', '项目经理', '项目经理', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1002', '13900000102', '李婷', '客服主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1003', '13900000103', '张倩', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1004', '13900000104', '刘洋', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1005', '13900000105', '陈雪', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '客服条线', 'EMP-1006', '13900000106', '赵磊', '客服专员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1007', '13900000107', '孙强', '秩序主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1008', '13900000108', '周军', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '秩序条线', 'EMP-1009', '13900000109', '吴斌', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '工程条线', 'EMP-1010', '13900000110', '郑伟', '工程主管', '主管', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '工程条线', 'EMP-1011', '13900000111', '冯健', '维修工', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '环境条线', 'EMP-1012', '13900000112', '褚芳', '保洁员', '一线', '2026-07-03'),
('000000', 'PRJ-FCW', '翡翠湾项目', '环境条线', 'EMP-1013', '13900000113', '卫红', '保洁员', '一线', '2026-07-03'),
-- 项目二:云山府(住宅,试点项目)
('000000', 'PRJ-YSF', '云山府项目', '项目管理', 'EMP-2001', '蒋涛', '项目经理', '项目经理', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2002', '沈丽', '客服主管', '主管', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2003', '韩雨', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2004', '杨柳', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2005', '朱鹏', '客服专员', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '秩序条线', 'EMP-2006', '秦峰', '秩序主管', '主管', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '秩序条线', 'EMP-2007', '尤浩', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '工程条线', 'EMP-2008', '许刚', '维修工', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '环境条线', 'EMP-2009', '何梅', '保洁员', '一线', '2026-07-03');
('000000', 'PRJ-YSF', '云山府项目', '项目管理', 'EMP-2001', '13900000201', '蒋涛', '项目经理', '项目经理', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2002', '13900000202', '沈丽', '客服主管', '主管', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2003', '13900000203', '韩雨', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2004', '13900000204', '杨柳', '物业管家', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '客服条线', 'EMP-2005', '13900000205', '朱鹏', '客服专员', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '秩序条线', 'EMP-2006', '13900000206', '秦峰', '秩序主管', '主管', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '秩序条线', 'EMP-2007', '13900000207', '尤浩', '秩序维护员', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '工程条线', 'EMP-2008', '13900000208', '许刚', '维修工', '一线', '2026-07-03'),
('000000', 'PRJ-YSF', '云山府项目', '环境条线', 'EMP-2009', '13900000209', '何梅', '保洁员', '一线', '2026-07-03');
+375
View File
@@ -17,8 +17,11 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
@@ -30,3 +33,375 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`turn_index` int NOT NULL COMMENT '话术序号',
`source_text` varchar(1000) DEFAULT NULL COMMENT '原话',
`issue_text` varchar(1000) DEFAULT NULL COMMENT '问题点',
`rewrite_text` varchar(1000) DEFAULT NULL COMMENT '推荐改写',
`sop_point` varchar(1000) DEFAULT NULL COMMENT '命中或漏掉的SOP点',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_annotation` (`tenant_id`, `session_id`, `turn_index`),
KEY `idx_aihr_practice_annotation_session` (`tenant_id`, `session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练逐句标注';
CREATE TABLE IF NOT EXISTS `aihr_practice_audio` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`turn_index` int NOT NULL COMMENT '话术序号',
`role` varchar(30) NOT NULL COMMENT '音频角色',
`oss_id` bigint DEFAULT NULL COMMENT 'OSS文件ID',
`audio_url` varchar(1000) DEFAULT NULL COMMENT '音频URL',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_audio` (`tenant_id`, `session_id`, `turn_index`, `role`),
KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频';
CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体或手机号',
`trainee_name` varchar(100) DEFAULT NULL COMMENT '员工姓名',
`scenario_id` varchar(80) NOT NULL COMMENT '场景ID',
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`),
KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务';
CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`reviewer` varchar(100) DEFAULT NULL COMMENT '校准人',
`original_score` int DEFAULT NULL COMMENT 'AI原始总分',
`corrected_score` int DEFAULT NULL COMMENT '人工校准总分',
`corrected_dimensions_json` text DEFAULT NULL COMMENT '校准维度JSON',
`reason` varchar(500) DEFAULT NULL COMMENT '校准原因',
`status` varchar(30) DEFAULT '已记录' COMMENT '状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_calibration_session` (`tenant_id`, `session_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 评分校准记录';
CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
`scenario_name` varchar(100) NOT NULL COMMENT '场景名称',
`position` varchar(80) NOT NULL COMMENT '岗位',
`scenario_type` varchar(50) NOT NULL COMMENT '场景类型',
`project_type` varchar(80) DEFAULT NULL COMMENT '项目类型',
`difficulty` tinyint DEFAULT 1 COMMENT '难度',
`customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设',
`opening_text` varchar(500) DEFAULT NULL COMMENT '开场白',
`explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求',
`hidden_need` varchar(300) DEFAULT NULL COMMENT '隐性诉求',
`irritants` varchar(500) DEFAULT NULL COMMENT '雷点',
`delights` varchar(500) DEFAULT NULL COMMENT '爽点',
`sop_refs` varchar(500) DEFAULT NULL COMMENT 'SOP引用',
`success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件',
`strategy` varchar(500) DEFAULT NULL COMMENT '训练策略',
`goal` varchar(500) DEFAULT NULL COMMENT '训练目标',
`trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员',
`trust_start` int DEFAULT 60 COMMENT '起始信任',
`trust_end` int DEFAULT 80 COMMENT '目标信任',
`rounds_json` text DEFAULT NULL COMMENT '回合seed JSON',
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
`sort_order` int DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`),
KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景';
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码',
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
`rubric_name` varchar(100) NOT NULL COMMENT 'Rubric名称',
`version` varchar(30) DEFAULT 'v1' COMMENT '版本',
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
`description` varchar(500) DEFAULT NULL COMMENT '说明',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`),
KEY `idx_aihr_practice_rubric_scenario` (`tenant_id`, `scenario_code`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分标准';
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码',
`dimension_code` varchar(50) NOT NULL COMMENT '维度编码',
`dimension_name` varchar(50) NOT NULL COMMENT '维度名称',
`weight` decimal(5,2) NOT NULL COMMENT '权重',
`description` varchar(500) DEFAULT NULL COMMENT '评分说明',
`sort_order` int DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`),
KEY `idx_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分维度';
CREATE TABLE IF NOT EXISTS `aihr_prompt_template` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`code` varchar(80) NOT NULL COMMENT '模板编码',
`name` varchar(100) NOT NULL COMMENT '模板名称',
`category` varchar(50) NOT NULL COMMENT '业务分类',
`output_format` varchar(50) NOT NULL COMMENT '输出文体',
`system_prompt` text DEFAULT NULL COMMENT '系统提示词',
`template` text NOT NULL COMMENT '用户提示词模板',
`variables` varchar(1000) DEFAULT NULL COMMENT '变量清单',
`version` varchar(30) DEFAULT 'v1' COMMENT '版本',
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
`description` varchar(500) DEFAULT NULL COMMENT '说明',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_prompt_template` (`tenant_id`, `code`, `output_format`, `version`),
KEY `idx_aihr_prompt_template_enabled` (`tenant_id`, `code`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 提示词模板';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '未命中问题',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`status` varchar(30) DEFAULT '待补充' COMMENT '状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志';
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '问题',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`answer_text` varchar(2000) DEFAULT NULL COMMENT '回答',
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
`note` varchar(500) DEFAULT NULL COMMENT '评审备注',
`status` varchar(30) DEFAULT '待评审' COMMENT '状态',
`reviewed_time` datetime DEFAULT NULL COMMENT '评审时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`query_text` varchar(1000) NOT NULL COMMENT '问题原文',
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
`fragment_id` bigint NOT NULL COMMENT '知识片段ID',
`verdict` varchar(10) NOT NULL COMMENT '反馈: up/down',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`review_status` varchar(30) DEFAULT '待复核' COMMENT '复核状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`),
KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`),
KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈';
INSERT INTO `aihr_practice_scenario`
(`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`,
`customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`,
`success_criteria`, `strategy`, `goal`, `trainee_name`, `trust_start`, `trust_end`, `rounds_json`, `enabled`, `sort_order`, `create_time`, `update_time`)
VALUES
('000000', 'complaint-water', '客服应对投诉', '生活顾问', '投诉处理', '住宅', 2,
'高情绪、追问进度、质疑服务的业主', '你们说今天处理,到现在都没人联系我,这个车库还怎么停?', '投诉车库积水未处理', '想确认物业是否持续跟进', '被敷衍、只讲流程、推给工程', '先安抚、给责任人、给首次反馈时间', '投诉处理SOP;高情绪沟通话术',
'先承接情绪,确认点位,给责任人与首次反馈时间,说明升级路径', '仅在漏关键步骤时提示', '训练学员先安抚、再确认诉求,并给出责任人和首次反馈时间。', '张三', 68, 82,
'[{"customer":"你们说今天处理,到现在都没人联系我,这个车库还怎么停?","coachHint":"命中安抚、责任人、首次反馈时间后继续追问处理承诺。","emotion":76,"trust":75},{"customer":"我不要流程,我要知道如果今天还处理不好怎么办。","coachHint":"补上升级路径和兜底承诺,可以进入评分。","emotion":64,"trust":82}]', 1, 10, NOW(), NOW()),
('000000', 'complaint-noise', '楼上噪音投诉', '生活顾问', '投诉处理', '住宅', 2,
'长期受扰、睡眠受影响、对物业不信任的业主', '楼上每天晚上拖椅子,你们物业到底管不管?', '投诉夜间噪音', '希望物业持续跟踪而不是一次性劝说', '让业主自己协商、说没有执法权、不给反馈节点', '先共情睡眠影响、承诺核实、给回访时间', '噪音投诉SOP;邻里协调SOP',
'记录时间段和房号,说明核实方式,约定首次沟通和回访时间', '提示不要先讲权限边界', '训练生活顾问在权限边界内给出可执行跟进。', '张三', 60, 80,
'[{"customer":"楼上每天晚上拖椅子,你们物业到底管不管?","coachHint":"先承接睡眠受影响,再收集时间段和楼栋房号。","emotion":78,"trust":66},{"customer":"你别又说去问问,我要看到结果。","coachHint":"给出上门/电话沟通和回访节点,避免空泛承诺。","emotion":70,"trust":80}]', 1, 20, NOW(), NOW()),
('000000', 'complaint-attitude', '服务态度投诉', '生活顾问', '投诉处理', '住宅', 1,
'对前台态度不满、要求道歉的业主', '你们前台刚才那是什么态度?我要投诉。', '投诉服务态度', '希望被尊重并看到内部改进', '替同事辩解、追问业主责任、让业主冷静', '先道歉、复述问题、承诺核查和反馈', '服务投诉SOP;客户情绪安抚',
'表达歉意,记录经过,明确核查责任人和反馈时限', '提示先道歉不等于先定责', '训练生活顾问把道歉、核查和后续反馈拆开说清。', '张三', 62, 82,
'[{"customer":"你们前台刚才那是什么态度?我要投诉。","coachHint":"先为体验道歉,再记录时间、地点和涉及岗位。","emotion":74,"trust":68},{"customer":"你是不是想包庇你同事?","coachHint":"强调会客观核查并按节点反馈,不做现场定责。","emotion":66,"trust":82}]', 1, 30, NOW(), NOW()),
('000000', 'fee-collection', '催缴沟通技巧', '生活顾问', '催费/停车费', '住宅', 2,
'拒绝缴费、质疑服务、要求先整改的业主', '服务做成这样还让我缴费?你先把问题解决了再说。', '物业费催缴', '希望服务问题被负责到底', '压缴费、忽略服务问题、威胁停服务', '承接问题、争议复核、缴费与整改并行', '催缴沟通SOP;费用争议处理',
'拆分费用和服务问题,说明复核、整改跟进和缴费节点', '提示服务改进与缴费并行', '训练学员把费用沟通和服务整改拆开处理,避免激化争议。', '张三', 61, 76,
'[{"customer":"服务做成这样还让我缴费?你先把问题解决了再说。","coachHint":"表达方向正确,但要补争议项复核和责任人。","emotion":72,"trust":68},{"customer":"那我怎么知道你不是随便记一下?","coachHint":"补上主管复核和节点同步,可以进入评分。","emotion":66,"trust":76}]', 1, 40, NOW(), NOW()),
('000000', 'fee-parking', '停车费催缴', '生活顾问', '催费/停车费', '住宅', 2,
'对停车费涨价敏感、担心权益受损的业主', '停车费又催,我车位入口问题一直没解决,凭什么交?', '停车费缴费提醒', '希望停车体验问题同步解决', '只重复收费规则、否认问题、催得太硬', '先记录停车问题、说明费用依据、给处理节点', '停车费催缴SOP;停车场服务SOP',
'说明费用依据,记录入口问题,给整改反馈时间和缴费办理方式', '提示先服务后收费说明', '训练收费季停车费沟通时的共情和政策表达。', '张三', 58, 78,
'[{"customer":"停车费又催,我车位入口问题一直没解决,凭什么交?","coachHint":"先确认入口问题,再说明费用依据和复核方式。","emotion":75,"trust":65},{"customer":"你说的优惠和截止时间到底是什么?","coachHint":"把折扣、截止时间和办理路径一次说清。","emotion":62,"trust":78}]', 1, 50, NOW(), NOW()),
('000000', 'fee-prepay-discount', '预交折扣沟通', '生活顾问', '催费/停车费', '住宅', 2,
'精打细算、关注优惠边界和历史欠费的业主', '你们说预交有折扣,那我以前欠的怎么算?', '预交折扣咨询', '希望弄清优惠、欠费和办理路径', '含糊优惠口径、承诺无法兑现、回避欠费', '先说明以系统账单为准、再给办理路径', '预交折扣SOP;历史欠费沟通',
'解释预交折扣、历史欠费处理口径和办理材料', '提示金额必须以系统账单为准', '训练收费季预交优惠和历史欠费的稳妥表达。', '张三', 65, 84,
'[{"customer":"你们说预交有折扣,那我以前欠的怎么算?","coachHint":"先说明金额以系统账单为准,避免口头承诺具体金额。","emotion":60,"trust":72},{"customer":"我要今天就知道能省多少钱。","coachHint":"给出查询路径和反馈时间,不现场编金额。","emotion":58,"trust":84}]', 1, 60, NOW(), NOW()),
('000000', 'repair-leak-followup', '漏水报修跟进', '生活顾问', '报修跟进', '住宅', 2,
'家中受损、担心维修拖延和赔偿的业主', '我家天花还在滴水,报修两天了怎么没人来?', '漏水报修跟进', '希望明确上门时间和后续责任', '只说已派单、不给时限、回避损失记录', '确认安全、给上门时间、记录受损情况', '报修跟进SOP;漏水应急SOP',
'确认漏水现状,安排上门节点,记录损失并同步责任人', '提示先排安全风险', '训练报修跟进中的时限、责任人和证据记录。', '张三', 57, 81,
'[{"customer":"我家天花还在滴水,报修两天了怎么没人来?","coachHint":"先确认是否仍在漏水和用电安全,再给上门安排。","emotion":77,"trust":64},{"customer":"如果家具泡坏了谁负责?","coachHint":"说明先记录现场和照片,后续按责任认定处理。","emotion":69,"trust":81}]', 1, 70, NOW(), NOW()),
('000000', 'repair-elevator-delay', '电梯故障跟进', '生活顾问', '报修跟进', '住宅', 3,
'住高层、老人出行受影响、要求明确恢复时间的业主', '电梯坏了一上午,老人怎么下楼?你们给个准话。', '电梯故障跟进', '希望知道预计恢复和临时协助', '只说维保在路上、没有临时方案、不给播报', '先共情出行影响、说明维保进展、提供临时协助', '电梯故障SOP;应急协助SOP',
'说明维保进展、预计反馈时间、老人出行临时协助和公告节奏', '提示不要承诺未经确认的恢复时间', '训练高影响报修的进展播报和临时协助。', '张三', 55, 80,
'[{"customer":"电梯坏了一上午,老人怎么下楼?你们给个准话。","coachHint":"先承接老人出行影响,再说明维保进展和下一次反馈。","emotion":80,"trust":62},{"customer":"你就说几点能好。","coachHint":"不能编恢复时间,要承诺确认后反馈并给临时协助。","emotion":72,"trust":80}]', 1, 80, NOW(), NOW()),
('000000', 'repair-public-light', '公区照明维修', '生活顾问', '报修跟进', '住宅', 1,
'晚上回家担心安全、希望小问题快处理的业主', '楼道灯坏了三天,晚上黑漆漆的。', '公区照明维修', '希望确认维修时间', '轻视小问题、不给工单、让业主等', '记录点位、给工单和处理时限', '公区维修SOP;夜间安全巡查',
'记录楼栋楼层点位,说明派单和反馈时间', '提示给出工单闭环', '训练轻量报修也要闭环表达。', '张三', 66, 86,
'[{"customer":"楼道灯坏了三天,晚上黑漆漆的。","coachHint":"记录准确点位,并给派单和预计反馈时间。","emotion":62,"trust":76},{"customer":"别又登记完没下文。","coachHint":"补充工单编号或责任人和回访时间。","emotion":56,"trust":86}]', 1, 90, NOW(), NOW()),
('000000', 'emergency-water-outage', '突发停水求助', '生活顾问', '突发求助', '住宅', 2,
'家中有老人小孩、对突发停水焦虑的业主', '家里突然没水了,孩子还要洗澡,怎么回事?', '突发停水求助', '希望知道原因和临时供水办法', '不知道原因就乱猜、只让等待、不给通知渠道', '先核实范围、同步排查、提供临时取水信息', '停水应急SOP;突发通知SOP',
'核实是否区域性停水,说明排查进展、通知渠道和临时方案', '提示未知原因时说排查中', '训练突发问题的原因核实和临时安置表达。', '张三', 60, 83,
'[{"customer":"家里突然没水了,孩子还要洗澡,怎么回事?","coachHint":"先核实楼栋范围,不要猜测原因。","emotion":73,"trust":68},{"customer":"那现在怎么办?总不能一直等。","coachHint":"给临时取水点或下一次公告时间。","emotion":65,"trust":83}]', 1, 100, NOW(), NOW()),
('000000', 'emergency-elder-fall', '老人摔倒求助', '生活顾问', '突发求助', '住宅', 3,
'情绪慌张、需要立即协助的家属', '我爸在楼下摔倒了,你们快来帮忙!', '老人摔倒求助', '希望物业迅速到场并联系救助', '先追责、慢吞吞登记、忽略急救安全', '先确认位置和意识状态、派人到场、建议急救电话', '突发救助SOP;急救协助SOP',
'确认位置和安全状态,派人到场,提醒拨打急救电话并联系家属', '提示物业协助不替代专业急救', '训练高紧急求助中的优先级和措辞。', '张三', 52, 79,
'[{"customer":"我爸在楼下摔倒了,你们快来帮忙!","coachHint":"先问清准确位置和意识状态,同时安排人员到场。","emotion":88,"trust":60},{"customer":"你们到了能不能处理?","coachHint":"说明会协助保护现场和联系急救,不替代医生判断。","emotion":78,"trust":79}]', 1, 110, NOW(), NOW()),
('000000', 'emergency-security', '陌生人尾随求助', '生活顾问', '突发求助', '住宅', 3,
'夜间回家、感到被尾随、需要安全协助的业主', '我刚进小区感觉有人跟着我,你们保安在哪里?', '安全求助', '希望马上获得陪同和安全确认', '要求业主自己确认、轻描淡写、泄露巡逻盲区', '先确认位置、安排安保、保持通话或引导到安全点', '安全巡逻SOP;突发求助SOP',
'确认实时位置,安排安保接应,引导到明亮公共区域并保留记录', '提示不要透露安防盲区', '训练夜间安全求助的接警式沟通。', '张三', 54, 82,
'[{"customer":"我刚进小区感觉有人跟着我,你们保安在哪里?","coachHint":"先确认当前位置和是否安全,安排安保接应。","emotion":86,"trust":62},{"customer":"我要不要直接回家?","coachHint":"引导到明亮公共区域或岗亭,保持联系。","emotion":76,"trust":82}]', 1, 120, NOW(), NOW())
ON DUPLICATE KEY UPDATE
`scenario_name` = VALUES(`scenario_name`),
`position` = VALUES(`position`),
`scenario_type` = VALUES(`scenario_type`),
`project_type` = VALUES(`project_type`),
`difficulty` = VALUES(`difficulty`),
`customer_persona` = VALUES(`customer_persona`),
`opening_text` = VALUES(`opening_text`),
`explicit_need` = VALUES(`explicit_need`),
`hidden_need` = VALUES(`hidden_need`),
`irritants` = VALUES(`irritants`),
`delights` = VALUES(`delights`),
`sop_refs` = VALUES(`sop_refs`),
`success_criteria` = VALUES(`success_criteria`),
`strategy` = VALUES(`strategy`),
`goal` = VALUES(`goal`),
`trainee_name` = VALUES(`trainee_name`),
`trust_start` = VALUES(`trust_start`),
`trust_end` = VALUES(`trust_end`),
`rounds_json` = VALUES(`rounds_json`),
`enabled` = VALUES(`enabled`),
`sort_order` = VALUES(`sort_order`),
`update_time` = VALUES(`update_time`);
INSERT INTO `aihr_practice_rubric`
(`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`)
SELECT
`tenant_id`,
CONCAT('rubric-', `scenario_code`),
`scenario_code`,
CONCAT(`scenario_name`, ' Rubric'),
'v1',
1,
'生活顾问试点四维评分标准',
NOW(),
NOW()
FROM `aihr_practice_scenario`
WHERE `tenant_id` = '000000'
ON DUPLICATE KEY UPDATE
`rubric_name` = VALUES(`rubric_name`),
`scenario_code` = VALUES(`scenario_code`),
`enabled` = VALUES(`enabled`),
`description` = VALUES(`description`),
`update_time` = VALUES(`update_time`);
INSERT INTO `aihr_practice_rubric_dimension`
(`tenant_id`, `rubric_code`, `dimension_code`, `dimension_name`, `weight`, `description`, `sort_order`, `create_time`, `update_time`)
SELECT
r.`tenant_id`,
r.`rubric_code`,
d.`dimension_code`,
d.`dimension_name`,
CASE
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'compliance' THEN 0.30
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'marketing' THEN 0.20
ELSE d.`weight`
END,
d.`description`,
d.`sort_order`,
NOW(),
NOW()
FROM `aihr_practice_rubric` r
JOIN `aihr_practice_scenario` s
ON s.`tenant_id` = r.`tenant_id` AND s.`scenario_code` = r.`scenario_code`
JOIN (
SELECT 'compliance' AS `dimension_code`, '合规' AS `dimension_name`, 0.35 AS `weight`, 'SOP关键点、权限边界、承诺口径' AS `description`, 10 AS `sort_order`
UNION ALL SELECT 'communication', '沟通', 0.25, '诉求确认、信息结构、反馈节点', 20
UNION ALL SELECT 'emotion', '情绪', 0.25, '共情承接、降温、信任恢复', 30
UNION ALL SELECT 'marketing', '营销', 0.15, '收费季转化、政策解释、办理引导', 40
) d
WHERE r.`tenant_id` = '000000'
ON DUPLICATE KEY UPDATE
`dimension_name` = VALUES(`dimension_name`),
`weight` = VALUES(`weight`),
`description` = VALUES(`description`),
`sort_order` = VALUES(`sort_order`),
`update_time` = VALUES(`update_time`);
INSERT INTO `aihr_prompt_template`
(`tenant_id`, `code`, `name`, `category`, `output_format`, `system_prompt`, `template`, `variables`, `version`, `enabled`, `description`, `create_time`, `update_time`)
VALUES
('000000', 'summary_card', 'SOP总结卡', 'knowledge', 'json_card',
'你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。',
'员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。',
'queryText,context', 'v1', 1, '总结卡固定问题/建议/标准三段式', NOW(), NOW()),
('000000', 'sop_answer', 'SOP问答直给', 'knowledge', 'json_answer',
'你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。',
'员工的问题:{{queryText}}\n\n检索命中的 SOP 片段:\n{{context}}',
'queryText,context', 'v1', 1, 'SOP问答结论先行+编号步骤+引用后置', NOW(), NOW()),
('000000', 'mentor_rewrite', '导师改写', 'practice', 'text',
'你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。',
'场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。',
'scenarioName,goal,traineeText', 'v1', 1, '训练后标杆话术生成', NOW(), NOW()),
('000000', 'review_annotation', '逐句标注', 'practice', 'json_annotation',
'你是物业AI陪练复盘教练。只返回JSON数组,每项包含quote、problem、rewrite、sopPoint。',
'场景:{{scenarioName}}。Rubric:{{rubric}}。对话:{{dialogue}}。请只标注员工关键句,指出问题、推荐改写和命中或漏掉的SOP点。',
'scenarioName,rubric,dialogue', 'v1', 1, 'finish后逐句标注', NOW(), NOW()),
('000000', 'prep_card', '训练前预习卡', 'practice', 'json_prep',
'你是物业培训教练。只返回JSON,字段固定为keyPoints、redLines、scripts。',
'场景:{{scenarioName}}。SOP引用:{{sopRefs}}。成功条件:{{successCriteria}}。请生成训练前预习卡:要点3条、红线3条、可说话术2条。',
'scenarioName,sopRefs,successCriteria', 'v1', 1, '训练前卡固定三栏结构', NOW(), NOW()),
('000000', 'supervision_image_analysis_maintenance', '图片督导分析', 'supervision', 'json_card',
'你是物业现场督导助手。只返回JSON,字段固定为problem、suggestion、standard。',
'图片/视频描述:{{visualText}}。相关标准:{{standard}}。请按问题、建议、标准三段输出,不要扩写无依据内容。',
'visualText,standard', 'v1', 1, '督导图片分析三段式', NOW(), NOW())
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`category` = VALUES(`category`),
`system_prompt` = VALUES(`system_prompt`),
`template` = VALUES(`template`),
`variables` = VALUES(`variables`),
`enabled` = VALUES(`enabled`),
`description` = VALUES(`description`),
`update_time` = VALUES(`update_time`);
@@ -0,0 +1,277 @@
# uni-app 用户侧前端重建计划
> 日期: 2026-07-08
> 结论: 新建并行工程 `mobile-uni/`, 按真实页面栈重建用户侧。旧 `mobile/` 保留为当前演示版本, 不在原 4000 行单页上继续堆功能。
## 1. 重建判断
现有 H5 的主要问题不是单个按钮或样式,而是信息架构不成立: 三端、多个功能和临时面板被揉在一个 `App.vue` 里,靠锚点、状态开关和手写 history 模拟页面。它能撑住 MVP 演示,但不适合继续承载员工端 APP/H5/小程序。
本轮重建目标:
- 用 uni-app Vue3 先做 H5,后续保留 APP 和小程序转换空间。
- 用户、主管、候选人按角色拆页面,不是同一长页面内切锚点。
- API 优先接真实后端,不新增前端 mock 数据;后端已有 seed fallback 只作为服务端降级链保留。
- 每个页面只解决一类任务,让用户得到明确反馈,减少心智负担。
## 2. 工程结构
并行目录:
```text
mobile-uni/
src/
pages/
auth/login/
user/today/
user/sop/
user/practice/
user/profile/
supervisor/index/
supervisor/team/
supervisor/assign/
supervisor/reviews/
supervisor/review/
candidate/index/
candidate/interview/
candidate/materials/
candidate/progress/
candidate/study/
services/
api.ts
auth.ts
home.ts
knowledge.ts
types/
api.ts
styles/
base.css
pages.json
manifest.json
```
关键约束:
- `App.vue` 只放应用级生命周期和全局样式,不承载业务页面。
- 页面跳转使用 uni-app 页面栈和 tabBar,不手写锚点切换。
- 认证令牌沿用当前 H5 的 `aihr_mobile_access_token` 和 `aihr_mobile_client_id`。
- 开发代理沿用 `/dev-api -> https://wygj-api.localhost`,显式 `VITE_PROXY_TARGET` 时可覆盖。
## 3. 三端页面计划
### 用户端
第一优先级。首页按“数字师傅”心智组织:
- `pages/user/today`: 今日安排、首要训练、待办、常用入口。
- `pages/user/sop`: 问师傅/SOP 检索、答案卡、引用、打勾打叉、总结卡。
- `pages/user/practice`: 场景练习、每日三题、训练前预习和 SOP 求助,已接 `/api/train/practice/start|turn|finish`。
- `pages/user/profile`: 训练历史、能力画像、个人信息。
### 主管端
不做员工端同款 tab。主管目标是看人、复盘、派活:
- 团队概览: 完训率、低分项、预警。
- 待复盘: 对练记录、逐句标注、标记已复盘。
- 指派训练: 按岗位和短板派发专项。
- 团队画像: 个人趋势和短板热力图。
### 候选人端
候选人是低频线性漏斗,不做复杂 tab:
- 面试任务。
- 补充资料。
- 岗前预习。
- 进度通知。
## 4. 开发范围
第一批已从“用户侧最小集合”扩展到三端主链路闭环。当前已完成:
1. `mobile-uni/` 工程初始化,可 H5 构建。
2. 手机号验证码登录,接真实 `/resource/sms/code` 和 `/auth/mobile/sms-login`。
3. 员工今日页,接真实 `/api/aihr/mobile/home/user`。
4. 问师傅页,接真实 `/api/knowledge/search`。
5. 答案反馈,接真实 `/api/knowledge/answer-feedback`。
6. 总结卡,接真实 `/api/knowledge/summary-card`。
7. 员工对练、每日三题、个人画像和案例素材。
8. 主管团队概览、复盘、派专项和员工回读。
9. 候选人面试、资料上传、HR 审核状态回读和岗前预习。
仍不做:
- 不迁移管理端。
- 不把旧 `mobile/src/App.vue` 直接拆搬到新工程。
- 不新增前端 mock 数据掩盖后端接口问题。
## 5. 验收口径
第一批完成标准:
- `npm --prefix mobile-uni run build:h5` 通过。
- 本地后端启动后,能用 dev 固定验证码登录。
- 今日页能展示后端返回的用户首页数据;接口失败时明确报错,不白屏。
- 问师傅页能检索正式资料,展示答案、要点、引用和 caution。
- 打勾/打叉提交后页面锁定反馈状态,失败时保留原状态。
- 总结卡显式点击才生成,失败可重试。
## 6. 当前基线与验证记录
截至 2026-07-10,`mobile-uni/` 已是用户、主管、候选人三端的现役页面栈,旧 `mobile/` 仅作 MVP 兜底。三端均以登录身份和真实 API 驱动:员工完成训练、问师傅和成长回读;主管按后端组织范围进行团队、复盘和专项指派;候选人按真实面试/资料状态进入独立线性页面。H5 构建、真实浏览器主链路和接口回读均已完成验证,不使用前端 mock 掩盖业务状态。
正式试点尚未通过:组织同步 dry-run 的 3474 名上游员工中仅 1 名具备可用于移动端身份映射的手机号,另有 8 条疑似乱码;严格门禁在窗口 `2026-07-07..2026-07-10` 只识别到 1 名正式人员且无人完成 10 次训练。下一阶段优先补齐上游正式组织数据并执行联合试点验收,不继续把功能堆回首页或旧单页。
- 第一批用户侧主链路已落地到 `mobile-uni/`: 登录、今日页、问师傅、答案反馈、总结卡、对练页、个人页。
- 本机开发入口已切到 portless: `https://wygj-mobile-uni.localhost/h5/`。
- README 和本地运行手册已对齐新用户侧工程: `mobile-uni/` 作为当前施工/测试入口,旧 `mobile/` 仅保留为 MVP 演示兜底;线上 H5 发布命令改为同步 `mobile-uni/dist/build/h5/`。
- 演示验收和 API 对接文档已切到 `mobile-uni` 路由;`scripts/demo-check.sh` 的移动端预检默认检查 `https://wygj-mobile-uni.localhost/h5/` 与新工程 marker,不再用旧 `mobile/src/App.vue` 证明当前用户侧。
- 快速启动口径已补齐: `./scripts/dev.sh` 默认通过 portless 启动后端、管理端和当前 `mobile-uni` 用户侧;旧移动端兜底只在 `PORTLESS=0` 固定端口调试时启动。
- 主管端已接入最小复盘闭环: 团队概览、待复盘列表、复盘详情、标记已复盘。
- 候选人端已接入最小闭环: 候选首页、结构化面试、最近面试记录、补充资料列表、文件上传、审核状态展示。
- 主管到员工的训练闭环已接入: 主管指派专项、员工今日任务、每日三题提交与反馈。
- 三端登录返回已修正: 从主管/候选人/员工子页进入登录后返回原任务页,已有登录态进入登录页时自动回跳。
- 登录页已补方法级重复请求保护: 发验证码和登录除按钮禁用外,函数入口也会挡住重复触发。
- H5 默认入口已改为用户今日页: `/h5/#/` 直接进入今日,登录页只在认证动作需要时出现。
- H5 旧入口已补兼容重定向: `/h5/user`、`/h5/candidate`、`/h5/supervisor` 及 employee/admin/user 子路径会跳到对应 uni hash 页面,避免旧验收链接都落到员工首页。
- H5 案例素材旧入口已补兼容重定向: `/h5/user/cases` 会跳到 `/pages/user/cases/index`,避免新增案例页直链落回默认首页。
- 未实现入口已接通建设中整页: 今日页未迁移入口不再只 toast,会进入明确反馈页。
- 三端页头基础样式已修正: `page-title/page-subtitle` 统一块级显示,避免 uni `text` 默认内联导致标题和副标题粘连。
- H5 导航标题已统一 custom: 不再同时显示 uni 原生导航标题和页面内页头,避免“主管/主管”“候选人/候选人”这类重复标题。
- 主管团队画像已补最小版: 团队预警接入 `/api/aihr/mobile/practice/alerts`,低分预警可带入专项派发。
- 候选人面试记录已按当前候选人隔离: 启动面试传当前手机号/候选人名,最近面试按 `candidateId` 查询,不再共用内置李娜记录。
- 候选人端已补进度通知和岗前预习: 进度由面试记录与资料审核状态计算,岗前预习复用正式 `/api/knowledge/search`。
- 候选人端已移除“填参考”开发辅助按钮: 正式候选人流程只允许提交本人真实回答,避免样例答案污染面试数据。
- 候选人端顶部主任务卡已改为真实状态派生: 按面试记录和资料审核状态显示待面试/待补资料/审核中/已通过,不再固定显示首页 seed 的 `0/1`。
- 候选人面试重启状态已收敛: 重新生成题目前清空旧题和旧会话,提交评分增加会话状态保护,避免启动失败后残留不可编辑旧题。
- 员工端“我”页已接成长证据包: 复用 `/api/aihr/mobile/profile/promotion-evidence`,展示等级、测评分、训练分钟、贡献度和证据条目。
- 员工端“我”页已展示成长路径: 复用 `/api/aihr/mobile/profile` 的 `growthPath`,展示初/中/高级进度与达成依据,不自动给晋升结论。
- 主管端复盘已接人工贡献分: 复用 `ReviewAdviceRequest.incentivePoint`,由主管手填后进入员工成长证据包,不自动给晋升结论。
- 主管端已补团队画像: 复用真实待复盘记录和训练指派数据聚合个人风险、待复盘、待训练和最近得分,可一键带入专项指派。
- 主管端从团队画像派专项时已保留员工姓名和外部 ID: 指派记录不再把已选员工统一写成“手机用户xxxx”。
- 主管端复盘已支持顺手派下一次训练: 复用 `ReviewAdviceRequest.nextScenarioId/nextReason`,标记已复盘时可同步生成专项训练。
- 主管端团队预警的聚合动作已改为进入独立团队画像页: 预警本身不含员工标识,具体派发仍从画像员工行进入,避免一键误派给默认手机号。
- 主管端指派专项已收敛操作面板: 默认不再把主管本人手机号当指派对象,必须选择员工或手动输入;历史记录只展示最近专项,不混入每日三题流水。
- 主管端团队画像待训练口径已收敛: 只统计主管指派专项,不再把员工每日三题误算进主管待训练。
- 主管端概览低分口径已收敛: 顶部低分预警直接跟随后端 `low_score` 预警数量,不再用当前画像截断列表推导高风险人数。
- 主管端复盘详情已补回放承载: 对话片段存在 `audioUrl` 时展示原生音频控件;当前本地样本无音频 URL,浏览器已验证详情/标注/派发区域无回归。
- 主管端写操作已补方法级重复提交保护: 指派专项和标记复盘除按钮禁用外,方法入口也会挡住重复调用。
- 员工端每日三题已修正参考答案泄露: 新任务只存题干,旧数据返回时清洗 `reason/feedback`,未完成题只提示“提交后显示反馈与参考答案”,完成后才单独展示参考答案。
- 员工端每日三题排序已改为按题目生成顺序展示: 后端 assignments 查询对 `source=daily` 按 ID 升序返回,不再把第 3 题排在最前。
- 员工端专项训练已补完成回写: 主管指派返回真实专项 ID,员工从专项入口完成对练后对应 `aihr_practice_assignment` 会置为已完成,不再长期卡在“待训练”。
- 员工端成长证据贡献度已收紧: 无训练记录且无主管人工加分时贡献度为 0,不再给空用户默认 20 分。
- 员工端空画像已收紧: 无移动端对练记录时综合能力、维度分、测评分和成长路径均为 0,AI 等级为“未评级”,不再沿用演示 seed 的 82 分/初级/30%。
- 员工端训练前预习卡已接入: 新 uni-app 对练页复用 `/api/train/practice/scenarios/{id}/prep-card`,开始前展示要点、红线和可说话术。
- 员工端训练前预习卡与页头已收敛展示: 隐藏内部 promptVersion,训练卡标题跟随当前专项场景,页头不再固定写“投诉接待”。
- 员工端训练中已接 SOP 求助: 新 uni-app 对练页复用 `/api/knowledge/search`,展示正式检索答案和引用,失败明确报错不兜假答案。
- 员工学习页已按正式 assignment 驱动专项检索: 先读取员工今日任务,优先用未完成的非 daily 场景名检索知识并加载对应预习卡;assignment 失败时明确报错,仍用岗位默认主题和默认场景继续加载,不补前端假数据。
- 员工学习页和案例素材页已补“返回今日”子页闭环: 正常页面栈返回来源页,直链进入时回到员工今日页。
- 当前“学”页仍是今日任务、SOP 检索和训练前预习的辅助入口,不是课程/考试系统;当前范围不包含课程编排、章节进度、题库考试或证书能力。
- 员工端对练建议已接调节按钮: 老师傅建议生成后可按“再严肃/再温柔/更细致/更专业”调用 `turn` 的 `regenerate/style`,只替换建议不重复计分。
- 员工端训练中已展示情绪/信任/红线纠偏: 新 uni-app 对练页直接渲染 `turn` 返回的 `emotion/trust/redFlag`,让员工看到话术对业主状态的影响。
- 员工端红线话术已改为同轮重说: `redFlag=true` 时不推进下一轮、不结束评分,提示员工按老师傅建议重说当前轮;纠正提交会替换本轮旧话术,与后端覆盖语义一致。
- 员工练习页已收敛重复触发: 每日三题提交中会禁用其他提交按钮,对练非空闲状态会禁用专项开始,避免重复请求覆盖当前训练会话。
- 员工端训练闭环已用真实浏览器跑通: 登录员工开始练习、提交回应、结束评分后写入训练记录,个人页成长摘要/证据包/最近练过同步更新;每日三题提交后原地显示得分、反馈和参考答案。
- 员工练习页写操作已补方法级 guard: 开始练习、SOP求助、建议调节、提交回应和结束评分除按钮状态外,函数入口也会挡住重复触发。
- 员工今日页“我的地图”已改为优先渲染 `/api/aihr/mobile/home/user` 返回的工具项;SOP复习入口会把复习标题带入问师傅页,不再因标题关键词不匹配落到建设中。
- 员工今日页已移除前端硬编码工具 fallback: 工具地图只渲染后端返回项,接口无工具时显示空态,不再用“问/练/学/报”假数据兜底。
- 员工今日页师傅气泡已移除固定姓名 fallback: 未登录显示“同事”,登录后按手机号尾号提示,不再把 seed 姓名暴露给真实用户。
- 员工今日页“查看全部”和小贴士箭头已接通动作: 分别进入练习页和带问题进入问师傅,不再保留看起来可点但无反馈的入口。
- 员工今日页未登录视图已收敛: 公开 home 的 `primary/review` 不再渲染成私人“今日安排”,改为登录提示;工具区未登录时显示为“常用入口”。
- 员工今日页问答入口文案已收敛: 当前 uni-app 首页只负责进入问师傅页,主按钮改为“问老师傅”,不再用“长按说话”暗示已实现录音交互。
- 员工今日页训练文案已收敛: 首页主任务从“3分钟语音练习”改为“3分钟情景练习”,不再承诺当前 uni-app 尚未提供的语音对练入口。
- 问师傅页已补登录守卫: 未登录检索会保留当前问题并跳登录,不再直接暴露“认证失败”。
- 移动端认证清理已收敛到共享 `clearAuth()`: 主动退出和 401/403 会同时清掉 token、clientId、手机号和登录回跳,避免旧手机号残留。
- 员工“我”页已补过期登录态反馈: 401/403 清理后会同步刷新当前页面为“未登录/去登录”,不再继续显示旧手机号和退出按钮。
- 员工“我”页未登录视图已收敛: 最近练过面板只在登录后展示,未登录不再出现“暂无训练记录”私人空态。
- 主管端已补过期登录态反馈: 加载、指派、打开复盘和标记复盘遇到 401/403 后会收起团队面板并提示重新登录。
- 主管端过期登录态已清理表单草稿: 指派对象、复盘建议、贡献分和复盘后专项随私有数据一起重置,避免换主管登录后残留上一轮操作。
- 候选人端、案例素材页、问师傅页已同步过期登录态反馈: 资料/面试/预习/案例/检索接口 401/403 后清空私有数据并提示重新登录。
- 移动端上传和 XHR 响应已复用共享认证错误处理: 候选人资料、案例音频和普通请求遇到非 JSON 的 401/403 也会同步清登录态,不再绕过过期处理。
- 成功态草稿已收敛: 案例素材入库后清空已选音频,主管专项指派成功后清空指派对象和原因,避免用户误点重复提交。
- 问师傅页已补未登录入口面板: 未登录或登录过期时直接显示“需要登录/去登录”,不再只留一条提示文案。
- 问师傅页已收敛重复检索: 检索中快捷问题会禁用,`search()` 和总结卡生成都有 loading 守卫,避免并发请求互相覆盖结果。
- 问师傅页待问问题已停止未登录自动跳转: 从首页带问题进入时先展示问师傅页和登录面板,用户点查询/去登录后再保留问题回登录,减少突然跳页。
- 问师傅页待问问题登录返回已补自动续查: 未登录时不提前清掉暂存问题,登录成功回到问师傅后再清理并自动检索。
- 员工练习页已补未登录和过期态反馈: 未登录不再显示“暂无今日任务”假空态,401/403 后清空当前对练状态并回登录。
- 员工练习页过期登录态已清理训练草稿: 对话、草稿、SOP求助答案、情绪/信任/红线状态和专项选择会一起重置,避免旧训练上下文残留。
- 候选人端未登录视图已收敛: 主任务卡改为登录提示,最近面试和补充资料私有空态只在登录后展示,不再把公开 home seed 当真实任务。
- 主管端和候选人端未登录页头已隐藏无意义刷新按钮: 未登录只保留登录入口,避免点击后无业务反馈。
- 候选人岗前预习文案已收敛: 未登录按钮显示“登录查看”,登录后显示“生成预习”,空态说明从正式知识库生成要点,不再用“加载”暗示不明确动作。
- 主管端和候选人端页头已停止直接渲染公开 home 的 seed 问候语,避免固定演示姓名出现在真实用户入口。
- 主管端真实复盘/指派数据已去掉公开 home 依赖: 页面不再因未使用的首页 seed 请求失败而阻断团队画像、待复盘和专项列表。
- 员工端已接案例素材页: `案例学习/提交素材` 入口进入 `/pages/user/cases/index`,复用 `/api/knowledge/case/upload|organize|curate|records` 完成语音转写、结构化整理、入库和最近案例展示。
- 案例素材页已用真实接口和浏览器回读跑通: 临时语音经 ASR 返回 `real-asr`,整理返回 `real-llm`,入库后案例页“最近案例”显示已入库记录。
- 案例素材页未登录刷新已改为跳登录: 最近案例不再出现点击无反馈的刷新按钮,空态提示登录后查看。
- 案例素材页已补方法级重复触发保护: 处理流程中不能替换音频,最近案例加载中不会重复刷新。
- 案例入库提示已从“预渲染样片”改为真实案例库口径,避免员工端继续暴露旧演示文案。
- 员工训练/画像/证据包已移除前端 `mobile-user` 兜底身份: 个人数据必须来自登录手机号,缺登录信息时明确提示登录,不再读写共享假身份。
- 候选人端认证边界已收紧: APP 候选人资料、面试启动和面试记录以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;HR 资料审核接口保留管理端角色口径。
- 候选人资料上传-审核-回读闭环已用真实 multipart 走通: APP 候选人上传后写 `sys_oss/aihr_candidate_material`,管理端 HR 审核通过后,候选人端资料列表回读为 `已通过`;本地 dev 验证记录保留用于回归追踪。
- 候选人资料进度已改为按最新资料行判定: 重新上传并通过后,历史驳回记录不再覆盖当前“资料已通过/等待通知”状态。
- 候选人端已移除前端 `mobile-candidate` 兜底身份: 缺登录手机号时明确提示登录,不再读写共享候选人身份。
- 移动端登录态判断已收紧为 token/clientId/手机号三者齐全: 旧缓存缺手机号时不再显示已登录,避免训练、画像和候选人资料读到空身份。
- 候选人端写操作已补方法级重复触发保护: 资料上传、岗前预习检索和面试提交除按钮禁用外,函数入口也会挡住重复调用。
- 候选人端主任务动作已按进度分流: 待补资料/资料驳回时主按钮走上传资料,不再把“重新提交资料”误导到重新面试。
- 候选人端未登录主动作已改为可点击跳登录: 开始面试、岗前预习、上传资料不再因 disabled 造成无反馈。
- 候选人端主链路已从 home seed 解耦: 公开首页接口异常时仍展示面试主动作,登录后的面试记录和资料列表继续走真实接口加载,不把非关键 home 错误暴露给用户。
- 候选人端审核中主动作已收敛: 资料待审核/已通过时主按钮只刷新进度,不再提示“重新面试”造成阶段误导;待补资料/资料驳回仍走上传。
- 主管端派专项已补场景自动匹配: 从团队画像选择员工时按投诉/报修/噪音/电梯/催缴等关键词选对应专项,不再把非催费低分默认派成催费沟通。
- 主管端最近专项回读已修正: 后端专项列表改为非 daily 优先,避免每日三题占满前 10 条导致手工指派成功后页面仍显示 0 条。
- 主管端团队画像派专项已用真实浏览器跑通: 从画像低分员工预填对象和场景后提交,最近专项立即回读新指派记录;低分预警进入独立团队画像页,不再依赖首页锚点。
- 候选人端页头称呼已收敛: 已登录副标题改为手机号尾号,不再和页面标题拼出“候选人候选人0001”。
- 候选人端未登录主任务卡已收起: 未登录只保留登录面板,不再同时出现两处“去登录”入口。
- 主管端低分预警已落到可操作对象: 待复盘列表优先返回低分记录,团队概览用全量预警数展示,避免“有低分预警但画像看不到低分员工”。
- 主管端关键动作已补可见落点: 团队画像“派专项”会带到已预填的指派面板,待复盘记录会带到复盘详情;复盘后同时派专项时成功提示明确为“已复盘并派专项”。
- 员工今日页未登录状态已停止加载公开 home: 未登录只显示登录提示和问师傅入口,不再渲染公开工具地图或空工具区。
- 员工练习页未登录状态已收起训练卡: 未登录只保留登录提示,不再展示“今日小训/开始练习”业务卡。
- 案例素材页未登录状态已收起最近案例面板: 未登录只保留登录提示,不再重复显示“登录查看”按钮和私人案例空态。
- 问师傅页登出态已清理上次答案: `onShow` 发现未登录会清空答案、总结卡和反馈锁定态,避免已登录检索结果残留在未登录页面。
- 问师傅页引用依据已兼容真实后端字段: `/api/knowledge/search` 返回的 `snippets.title/text` 会在服务层归一为 `docTitle/content`,移动端不再显示空白引用;长 ASR/文档片段只展示摘要,避免视频转写铺满页面。
- 问师傅总结卡已补全展示字段: 页面现在渲染 `objections` 的“异议回应”,不再丢掉模型返回的常见异议和回应话术;真实浏览器已验证“业主投诉噪音怎么安抚”总结卡可见异议区。
- 候选人端面试提交已收紧为全部题目必答: 真实浏览器验证只填 1/3 题时提示“请填写全部 3 题回答”,不生成评分记录。
- 候选人端面试主链路已用真实 API 跑通: dev 手机号登录后 `/api/recruit/interview/start` 返回 `source=real-llm` 题目,3 题答完后 `/finish` 生成评分,`/records` 可回读最新面试记录。
- 候选人端资料上传链路已用真实 multipart 跑通: dev 手机号登录后上传资料写入 `sys_oss/aihr_candidate_material`,候选人资料列表立即回读同一记录,状态为 `待审核`。
- 候选人资料 HR 审核/候选人回读已用真实 API 复跑: 管理端 token 调 `/api/aihr/hr/candidate/materials/{id}/review` 审核通过后,候选人重新登录调用 `/api/aihr/mobile/candidate/materials` 回读到 `已通过`。
- 员工端练习结束评分已补边界: 未提交回应前不能结束评分,红线纠偏时需先重说;真实浏览器验证提交一轮后可结束评分并写入主管待复盘。
- 员工端每日三题提交/回读已用真实 API 跑通: 新手机号首次加载生成 3 条 daily 任务,提交一题后状态变 `已完成`,回读到用户答案、反馈和参考答案;未提交前参考答案为空。
- 主管复盘-派专项-员工回读闭环已用真实 API 跑通: 员工完成一场对练生成 `待复盘`,主管读取复盘详情可见对话和标注,标记已复盘并带 `nextScenarioId` 后该记录不再出现在待复盘列表,员工专项列表立即回读复盘后手工专项。
- PC 成长激励和组织权限页已收口真实数据边界: 派专项必须输入真实员工手机号或 extPartyId,不再写入 `mobile-user`;无项目范围接口时只显示空态,不再展示假项目样例。
- `mobile-uni` 本地 dev 代理已对齐 portless:默认 `/dev-api -> https://wygj-api.localhost`,不再依赖裸 `8080`;真实浏览器登录链路已复跑通过。
- 员工今日页“今日安排”已优先回读真实 `/api/aihr/mobile/practice/assignments`;有待训练专项或每日三题时覆盖公开 home seed,真实浏览器已验证 `13900000998` 首页显示当天 daily 任务并可跳到练习页。
- 员工今日页到练习页已补任务上下文传递:首页点“开始”会把当前 assignment 带到练习页,daily 任务高亮并提示填写,主管专项可直接启动,减少跳页后的二次选择。
- 移动端 hash 路由滚动复位已补:员工/候选人/主管/登录/建设中页面激活时回到顶部,真实浏览器验证从长页切回今日页后 `scrollY=0`,避免首屏被旧滚动位置截断。
- 员工练习页已补语音输入入口:回复区支持浏览器录音转写和选择音频转写,ASR 返回文本进入原提交框且音频 URL/OssId 随 turn 提交供主管复盘回放;真实浏览器验证按钮在 390px 手机视口不换行,文本 turn 回归可进入第 2 轮。
- 主管复盘音频回放已补同源下载链路:移动端新增 `/api/aihr/mobile/oss/{ossId}` 下载代理,复盘详情返回 `audioOssId`,主管页用当前 `apiUrl` 生成音频地址;portless 验证 ASR 生成的 WAV 可经 `wygj-api.localhost` 下载,新训练记录详情可回读 `audioOssId`。
- H5 桌面预览已收进移动端壳:宽屏访问 `wygj-mobile-uni.localhost` 时主体和底部 tab 固定为 430px 居中,390px 手机视口仍全宽。
- 员工端视觉扫尾已补:今日页“去复习”按钮和案例页“已入库”状态不再被 flex 挤成窄高文本,真实 390px 视口扫描无横向溢出。
- H5 本地验证噪音已收敛:关闭 uni 统计自动上报并复用现有静态图作为 favicon,真实 390px 浏览器扫描无 `tongji-collector`/`WebTrack.gif`/`favicon.ico` 坏资源。
- 员工练习页场景说明已跟随真实预习卡/主管指派:场景卡优先展示 `/prep-card` 返回的要点,其次展示专项原因,不再固定写“投诉服务或设施问题”。
- 员工端岗位确认提示已对齐正式组织数据边界:组织快照无法按手机号匹配时,明确提示可能是手机号未开放或已脱敏,再让用户手选岗位。
- 主管端已完成真实页面栈拆分: 概览、团队画像、专项指派、复盘列表、复盘详情分别落到独立路由,首页只保留指标、预警和待办摘要,不再把功能揉成长页面或用锚点模拟导航。
- 主管端团队数据已接正式范围接口: `GET /api/aihr/mobile/practice/team` 一次返回组织成员、全状态训练记录和非 daily 专项;后端先按租户、主管项目范围和移动端模式过滤,再做列表限制。
- 主管权限已以后端组织快照为准: 只有在职“主管/项目经理”可访问团队、预警、复盘和指派接口;普通员工真实浏览器验证只显示“无主管权限”及返回员工端/切换账号,不泄露团队指标和表单草稿。
- 主管复盘写入已补事务和幂等: 仅 `待复盘 -> 已复盘` 的首次状态转换创建后续专项;并发重复请求通过行锁读取最终状态,不重复派发。
- 主管端已用真实项目经理账号验证组织范围: 本地正式快照返回 19 名团队成员、1 条待复盘、2 项待训练和 1 条低分预警;团队页、复盘页和指派页均读取真实接口,不补前端 mock。
- 正式组织快照中的不可读问号姓名在用户侧回退为手机号尾号或外部 ID 末尾标识,真实 ID 保持不变;开放组织 dry-run 已确认源接口自身仍返回 8 条疑似乱码,需由上游修复后再同步。
- 候选人端已完成线性页面栈拆分: 原 750 行长页收敛为候选首页、面试任务、补充资料、进度通知和岗前预习 5 个路由;首页只展示真实状态和办理入口,不再原地展开所有表单和结果。
- 候选人流程状态已抽成共享纯函数: 只根据当前候选人的正式面试记录和最新资料状态派生“待面试/待补资料/审核中/已通过”,不读取公开 home seed,不补前端 mock。
- 候选面试页继续复用真实 `/api/recruit/interview/start|answer|finish|records`: 浏览器以当前移动账号验证 `/start` 返回 3 道岗位题,未提交答案和评分,避免产生额外正式面试记录。
- 候选资料页继续复用真实 multipart 上传和本人资料列表: 本轮浏览器只验证正式空态与上传入口,未上传测试文件污染资料库;已有上传-审核-回读证据继续有效。
- 候选进度页按正式记录展示三步办理状态和证据,面试未完成时明确停在第 1 步,不把空数据渲染成已办理。
- 候选岗前预习页改为自然业务问题检索正式 SOP: 浏览器验证投诉预习返回正式答案、关键要点、注意事项,引用命中 `投诉处理 SOP v1.0`、`生活顾问的一天.mp4` 和正式课件,无前端示例答案。
- 演示预检已跟随页面栈更新: `scripts/demo-check.sh` 改为检查主管复盘详情和候选 4 个子路由,不再在旧主管首页查复盘表单标记;本地数据库连接不可用时非严格模式明确跳过样本、严格模式继续失败。
- 本地连接耗尽已处置: 清理未被 portless 使用的旧后端实例并重启本地 MySQL 后,连接稳定在约 `11/151`,当前 API HTTP 200;`dev.sh stop/restart` 已改为安全终止 portless 进程树,避免旧 Java/Vite 子进程继续占用连接池。
- 试点严格预检已改为正式批次口径: 必须指定起止日期,训练、校准和 SOP 评审只统计该时间窗;人员通过唯一手机号映射到在职组织,排除重复手机号和身份碰撞,完训定义为每人至少 10 次。`2026-07-07..2026-07-10` 实测开发活动身份 50 人、10 次完训 0 人,但正式组织仍为 `org_login=1/3001`、`org_pilot=1/20`、`org_ten_sessions=0/1`,所以严格模式正确失败。开放组织 dry-run 返回 `employees=3474 phone_linked=1 masked_phone=3473 suspect_text=8 synced=0`,且 dry-run 不访问本地表结构或写库,证明手机号和 8 条问号数据来自上游且本轮未覆盖快照。
- 管理端试点 CSV 已与严格预检统一口径: 必须选择起止日期,只导出窗口内唯一匹配正式组织的训练明细,完训按每人至少 10 次,校准必须关联正式试点会话。真实 API 返回正式试点 1 人/1 项目、10 次完训 `0/1`、校准 `0/20`、窗口内 SOP `2/2`,总闸门为 `false`;此前开发会话中的 20 条校准不再计入正式验收。
- 发布会正式候选题已重新走查: Q01-Q12 `12/12` 返回有效答案和依据,来源告警 `0`,向量状态 `450/450`;仍有 119 条 SOP 问答待业务评审,不把开发方走查替代业务签字。
- 下一阶段进入三端联合发布门槛: 复跑员工-主管-候选人角色切换、清理本地验证数据、核对正式组织姓名编码并完成 H5 发布前验收;不把本次页面拆分等同一期生产验收完成。
## 7. 风险
- uni-app npm tag 不是普通语义化版本,依赖必须固定同一 `vue3` release train。
- H5 路由和将来小程序路径不完全一致,页面命名要早期克制。
- 旧 H5 在 Alpha 前仍承担演示兜底,新工程未完成替换前不能下线旧入口。
- 如果后端某些接口仍要求管理端 token,需要后端补移动端认证口径,不能在新前端绕过认证。
- 当前本地组织快照存在部分不可读姓名,前端已做可识别展示兜底,但正式试点前仍需从开放组织同步源修复编码并重跑快照同步。
@@ -0,0 +1,914 @@
---
title: "企业内部AI培训学习APP产品需求与发布会内容讨论"
author: "Get达人"
date: "2026-07-08"
---
# 📑 智能总结
## 录音信息
- **时长**:约 0小时 48 分钟
- **参与人数**:约 3 人
- **内容类型**:工作会议
## 录音总结
本次会议讨论面向物业一线员工的AI培训问答APP的功能定位、内容完善与发布会准备,明确了核心功能模块、知识库梳理要求与开发排期,确定本周内出测试版本用于后续发布会演示。
**发布会演示内容框架讨论**
* **初始演示框架提案**:最初提出3套可选框架,分别为从新人到高手的成长路径、生活顾问的一天、一线员工晋升通道。最终确定选择「生活顾问的一天」作为发布会演示核心框架。
* **师带徒模块调整**:最初提出带徒弟加分晋升的方案,因80%以上项目生活顾问为初级,难以满足带徒要求,最终调整为开放问答问题榜,设置有奖问答沉淀经验,不再保留定向师带徒设置。
* **发布会演示功能设想**:提出演示拍照识别巡检点位数值的功能,若能实现会超出参会人员预期;当前仅确定先做对话互练演示,巡检扫码功能后续再推进。
**APP核心功能模块规划**
* **核心四模块定义**:最终确定四个核心模块,分别为「学」「练」「问」「报」,其中学对应专业知识学习与考试,练对应AI互动日常训练,问对应查询SOP与话术,报对应上报个人处理案例与经验。
* **互动训练功能设计**:练模块要求生活顾问每天完成5分钟训练,AI会自动生成不同场景的模拟对话;用户可以调节AI回复的语气、专业度,获取适配需求的新回复。
* **问答经验沉淀机制**:设置问题榜,开放给全公司人员提问与回答,提问和回答均可获得奖励,后台会筛选最佳答案置顶;用户可以对AI给出的答案打勾或打叉,打叉的信息后续不再推送。
* **产品整体定位规划**:当前第一步定位为企业内部AI知识库与培训工具,远期目标是做成员工端的专业AI师傅,集合公告推送、工单巡检、报事报修等全流程功能。
**多产品端架构设计**
* **拆分方案选择**:王总要求将所有功能整合到一个APP,开发方认为整合后产品过重,修改升级会影响其他模块,最终确定采用多端跳转方案。
* **具体跳转实现方式**:保留现有不同业务的独立系统,整合在企业微信统一入口,点击对应模块后跳转到独立小程序打开,既保证员工使用便捷,又降低开发修改的复杂度。
**APP知识库内容完善要求**
* **资料收集范围**:需要收集全公司全业态各部门的最新制度、业务手册、流程资料,旧的废弃制度不需要放入,已经确认错误的内容需要提前删除。
* **现有资料初筛进展**:已完成2022版生活顾问手册的初筛,删除了已经停止的业务(如智慧美家、享乐一安)和离职人员相关的错误内容,部分业务类错误需要业务部门进一步筛选。
* **可接受资料格式**:PDF手册、文字版制度、PPT、语音录音、流程视频都可以接收,不需要提前调整知识颗粒度,全部给到AI训练即可。
**开发与测试排期规划**
* **资料交付截止时间**:要求参会业务方在当周周三下班之前,将整理好的资料交付给开发方。
* **测试版本发布时间**:开发方承诺当周周五出第一个可测试版本,参会人员在周五到周日完成测试。
* **发布会准备要求**:发布会演示的问题会提前预设,确保能输出符合预期的结果,后续再逐步更新完善APP内容。
**员工晋升机制对接**
* **学分绑定规则**:将APP使用与员工晋升绑定,员工日常训练、回答问题、上报案例都可以获得对应学分。
* **晋升考试要求**:学分满足要求后才可参加晋升统考,日常训练通过APP完成,统考仍保留线下统一考试的形式。
**电子名片附加功能**
* **项目额外说明**:开发方提到团队还做了电子名片功能,属于独立的附加功能,定位和现有培训APP不一致,单独作为一个产品存在。
# 📅 章节概要
[00:00:00](https://getnotes.seek:0) **初始演示框架讨论**
本次发布会面向几十位参会人员,其中大部分为区域总及以上级,仅少部分为生活顾问与项目经理。最初提出三个可选演示框架,分别是新人到高手成长路径、生活顾问的一天、一线员工晋升,最终确定选择「生活顾问的一天」作为演示核心。
[00:01:47](https://getnotes.seek:107) **师带徒方案调整讨论**
最初提出带徒弟可获得加分用于晋升,参会人员提出反对:目前80%以上项目的生活顾问都是初级,自身还未完全熟练,且没有设置固定带徒岗位、也没有额外报酬,不适合做成固定模块。最终调整思路,改为开放问题榜,设置有奖问答,全公司人员都可以提问回答,沉淀经验,避免定向师带徒的误解。
[00:06:02](https://getnotes.seek:362) **问答模块机制细化**
提出打造行业经验知识库的目标,希望每个生活顾问最少一个月提交一个有价值的个人案例。问答模块支持提问和回答都获得奖励,回答后后台会筛选最佳答案置顶,用户也可以对AI给出的答案打勾打叉,打叉的内容后续不会再推送。全公司所有人员都可以参与回答,不同层级人员的答案会分类呈现。
[00:12:35](https://getnotes.seek:755) **APP整体定位讨论**
开发方向业务方提出,目前已收到20多个G的资料,但还需要补充更多业务相关资料用于AI训练。参会人员讨论APP定位:如果定位为员工端,需要集成公告推送、AI提取个性化信息、工单巡检、报事报修等全流程功能;如果第一步定位为企业知识库培训工具,则先只完成培训问答相关内容。最终确定分步走,第一步先做企业AI知识库培训工具,远期再整合全流程功能。
[00:18:27](https://getnotes.seek:1107) **产品架构方案确认**
王总要求将所有功能整合到一个APP,开发方提出反对,认为整合后产品过重,修改某一块功能会影响其他模块,升级不方便。最终确定方案:保留不同业务的独立系统,在企业微信设置统一入口,点击对应模块后跳转到独立小程序打开,兼顾员工使用便捷性和开发灵活性。开发方提到还额外做了独立的电子名片功能,属于单独的产品。
[00:23:02](https://getnotes.seek:1382) **核心功能模块梳理**
确认四个核心模块:学、练、问、报。学模块对应专业知识学习与考试,目前还在开发中;练模块要求生活顾问每天完成5分钟AI互动训练;问模块对应查询SOP和工作话术;报模块对应上报个人处理案例与经验。四个模块会区分不同岗位,推送对应岗位的相关问题与知识,生活顾问需要覆盖四宝一图的全部内容。
[00:31:35](https://getnotes.seek:1895) **知识库资料要求说明**
开发方提出目前知识库内容比较欠缺,需要业务方补充更多资料。参会人员确认:资料需要提供全公司各职能模块的最新应知应会内容,旧的废弃制度不需要提供;PDF、文字版制度、PPT、语音、视频都可以接收,不需要提前调整知识颗粒度,全部给到AI训练即可。已经完成现有资料的初筛,删除了错误的过时内容。
[00:34:22](https://getnotes.seek:2062) **功能效果要求确认**
业务方提出要求,AI回答需要直给,按照一二三四五的步骤给出清晰操作指引,符合物业一线员工的使用习惯,不要推送大段资料。开发方表示只要文档中有对应内容,就可以按照要求调整输出格式。业务方提出需要在资料交付后帮忙测试APP效果,开发方确认了这一安排。
[00:39:23](https://getnotes.seek:2363) **开发排期确定**
业务方要求开发方给出明确排期,确认时间节点:当周周三下班前业务方交付整理好的资料,当周周五开发方出第一个测试版本,业务方在周五到周日完成测试。针对发布会演示,确定提前预设好演示问题,保证能输出符合预期的效果,后续再逐步更新完善内容。
[00:40:26](https://getnotes.seek:2426) **巡检演示功能讨论**
业务方提出,若要提升APP实用性与用户使用频次,最好能结合日常巡检场景演示:生活顾问到巡检点位拍照后,AI可以告知需要关注的风险点。开发方提出可以通过贴二维码的方式实现,扫二维码即可获取对应点位的关注内容。参会人员决定先喂足资料看AI回答效果,后续再推进该功能的开发。
[00:42:10](https://getnotes.seek:2530) **晋升机制对接确认**
讨论APP与员工晋升的对接方案:将APP使用获得的学分与晋升资格绑定,员工日常训练、回答问题、上报案例都可以获得学分,学分满足要求后才可参加晋升统考,统考仍然保留统一考试的形式,日常训练通过APP完成。
[00:46:10](https://getnotes.seek:2770) **后续资料整理安排**
确定资料整理分工:严总补充业务端资料,现有资料和开发方做交叉核对,涉及保密的内容不放入知识库,不同等级账号可以设置不同的访问权限。最终确认本次讨论结束,各方按照排期推进工作。
# ✨ 金句精选
- “我们只要炒作一个生活顾问,把它打造成标杆。那就没问题。” (战略洞见)
- “公司发的各种文件,不是所有的我都要看。我只差看其中两行。” (执行策略)
- “物业人大多想直给直拿,问人家你帮我操作一下,这就是直给,所以这个东西一定要做到能够直给。” (方法技巧)
# 📋 待办事项
- 业务方:当周周三下班前整理交付完善后的知识库资料给开发方
- 严总:补充业务端相关资料,和现有资料做交叉核对
- 开发方:当周周五输出第一个可测试的APP版本
- 业务方:周五到周日完成新版本测试,向开发方反馈使用问题
- 开发方:按照业务方要求调整AI回答格式,确保输出分步清晰的操作指引
# 💡 我的发言回顾
不对,该录音未标注说话人为`(我)`,所以上述的待办事项结束后就停止,这里不能有内容,我检查一下,输入里确实没有带(我)的说话人,所以到上面就结束。
# 原文
🟢 说话人1 [00:00:00]
对啊,那高程。我觉得是从是也是这种场景,然后再再也是什么,就是是是从两个两个通道来来讲这个事情。第一个就是从一个一个生人变成高手。
🟣 说话人2 [00:00:13]
第二个是伸手变熟手。
🟢 说话人1 [00:00:14]
对,第二个是从我我的我的一天去讲,嗯,就我我的一天就是我一天到底干几件事情。其实如果这样子快的话,然后第三步,第三步是就是讲一个。呃员工一线员工如何升值。就是如何不断的让自己升升级,变成变成资深专家,就是他的等级可以带图带图例。
🟣 说话人2 [00:00:40]
那我觉得我的一天是在里面是最简单的。
🔵 说话人3 [00:00:43]
不是这里面有一个问题,比如说你说从伸手到熟手,你的发布的对象应该是听的是生活顾问比较合适。但是我们开会的话他的现场都是区域总级以上连项目经理都没有少部分生活顾问我们这次有可能颁奖的时候会把。就是嗯会颁那个收费奖项,有可能会有生活顾问和项目经理,大概就几十个人。
🟣 说话人2 [00:01:06]
但是我觉得没关系,因为曲总在他们也是也了解生活顾问具体做什么。但是如果我们那个产品能显示出来,这个能帮助生活顾问实际的减轻他的个人负担,减轻项目经理的负担,这就可以了。对,就是但是你说的刚才三点,我说实话。你其实学一条就可以了。
🟢 说话人1 [00:01:25]
可以,就是,也也可以也可以,也就是都都可以,我这其实三个都可以。
🟣 说话人2 [00:01:29]
对,就是熟,伸手,新入职,从伸手到熟手,第二个就是生活顾问的一天,第三个就是生活顾问如果晋级打怪,就升职,就类似于这样子,晋晋升的那个通道。
🟢 说话人1 [00:01:42]
其实我我们第三个里面还有你可以再加一个,就是他带徒带徒。
🟣 说话人2 [00:01:45]
带徒,嗯。
🟢 说话人1 [00:01:47]
带徒弟一定要带徒弟才能升值。
🟣 说话人2 [00:01:49]
对我我是这样想的如果就是我不知道能不能实现这个其实如果最小的话最最合适的应该是生活顾问那个一天这一天里面会涉及到从最早的B I规。再到巡检,巡检你那个拍照,比如说这个仪器表,如如果假设今天能做到啊,那个他拍完照以后发现这个里面哪些有有问题,哪些是这个影响的,已经有了,这个有。对,这个可能在现场如果能够发布的话,其实我觉得徐总已经觉得很惊讶了,已经觉得很惊讶了,就比如说那个那个数值,消防棒的数值,一拍,在哪个方位,这个这个这个数值有没有问题?就就就就就已经非常非常ok了可以啊但我不知道就是我们现在的实力能做哪一项吗。还有现场发布也不会时间太长。
🟢 说话人1 [00:02:34]
就是现在从从这个我们先讲生活顾问。嗯,因为这个首先这个APP定位是包括生活顾问,生活顾问,然后再延伸到这个项目上的其他的可能是一些重要一些岗位。呃,比如说那个主管,对对对,保洁主管他也可以用。
🟣 说话人2 [00:02:50]
对对吧?你保安也可以。
🟢 说话人1 [00:02:52]
保安也可以用,因为你只要你想学习,想升职,想想去做的更好,我就提供你机会。对,所以这这个就是我的。一天等于分了几个点。第一呢,是就我现在定义的这个,就一天至少五分钟,就每天至少要用五分钟。五分钟干嘛呢?五分钟就是每每天有训练,就是要求他们每天必须要用五分钟。
🟣 说话人2 [00:03:13]
那你的你的我的一天和我你的你的理解。
🟢 说话人1 [00:03:16]
和我的理解,我就刚才讲,我现在就讲到了我的一天之之中的五分钟。他首先第一件事情,上班之前可以先花五分钟。来,我给你安排五分钟训练。这是我的一天中的其中一件事情。嗯,第啊,对,第二个呢,就是我我那里面不是有四个按钮吗?嗯,这个什么什么学练学练啊,学练问问学练问题。嗯,啊,这个是他一天中可以干的四件事情。嗯,啊,然后再干上你,加上你刚才讲的事情,然后再再加一个,就是他带图的事情。嗯,就是就是他一天干几件事情,跟我们就是。
🔵 说话人3 [00:03:52]
我觉得带徒弟先不要放进去,为什么呢?因为这个这是一个很虚幻的事情,他带徒弟他不是一个成套成体系的一个事情。
🟢 说话人1 [00:04:00]
他在徒弟怎么站呢?就我就想就就说到这个事情。
🔵 说话人3 [00:04:03]
这个东西是个很虚的事情,从前到后就没有生活顾问带徒弟,正儿八经能带出个什么样徒弟出来说。首先他生活顾问他们他们之间本身是一个,就是我们目前在所有的生活网格设置上面,并没有说有设置出来,你必须带一个什么样的徒弟,我们只是这样子去要求了,但是事实上并没有就是。大家都是负责不同的网格,干的活都是一样的。你说让他去带徒弟,那是也没有给他另外加工资,也没有给他赋予他更高的岗位,就协助,就帮忙。他是个主管,你才能说让他去带徒弟,你说让他一定要带徒弟,这不是所有项目都有的。首先他不是广府的,一定是这个项目有新的生活顾问进来了,或者说有实习生进来,即将要进入到生活顾问岗位,然后这个老师可以带他,他顶多是个入职引导人。然后这个老的生活顾问,他自己本身可能还是个半吊子,你怎么让他一定要去带徒弟呢?
🟣 说话人2 [00:05:00]
带徒弟这个有点太深奥了。
🔵 说话人3 [00:05:01]
带徒弟太深奥了,这个东西就是就是先别着急。王府场景下,我可以说现在的80%以上的项目,他都不能在项目上挑出一个人去带徒弟。我凭什么说?因为你现在从我们送货的级别,你就能看得出来,最低的80%以上的送货全部都是初级的,他带什么徒弟呢?自己还是半生不熟的。
🟣 说话人2 [00:05:22]
你的A P P就是他的未来的师傅。
🔵 说话人3 [00:05:24]
对喂。没事,我主要我主要是问你那个。
🟣 说话人2 [00:05:28]
所以这东西太高阶了。
🔵 说话人3 [00:05:30]
现在知道。你是九六七那个位置是吧嗯等会。哦哦哦好好好好嗯。哦哦哦没事没事我不打扰你了那你们好好。哦哦哦好。哼。快结束了嗯好嗯。拜拜,嗯嗯。
🟣 说话人2 [00:06:02]
你看你就感冒了。嗯。
🟢 说话人1 [00:06:05]
就就是就是我我想有点对标的是什么感觉啊?嗯,就像这个政府炒这个学雷锋一样。嗯,就是雷锋是个标杆,嗯,只有一个雷锋,没有别的雷锋。就我们也是这样,我们有800多个,我们800多个生活顾问,我们只要炒作一个生活顾问,把它打造成标杆。那那就没问题啊,标杆。你调整目的是什么?目的是为了让大家更多的是用那个里面最右下角那个题的事情。就是我,我,我,我,我,我希望最好每个生活顾问,哪怕你最迟最少一个月,你提供你提交一个你自己觉得你有心得或者有有你觉得有有价值的一个案例出来。嗯,就是要要往要往上报,就我们最终要把这个这个我们的我们的知识库变成一个数字人,就是行业经验知识库。就是什么经验都有碰到什么问题我都能知道有办法解决这是这是我们希望做的对对那就那就要鼓励他们吃东西他总裁。那问题呢,他有这个上进的。
🟣 说话人2 [00:07:02]
就上上上进的。那没问题,因为是这样子,生活顾问的晋级,其实我们可以设置一些学分。有了学分,就像这里面获得学分经验,我才能够升级。所以你这个提问就可以。
🟢 说话人1 [00:07:13]
这个。对,我的意思是多方面。嗯,学分是学分,比如提报,他提报出来一个有用的,那是那是有加分。对,然后呢带一个徒弟有加分,我是说鼓励他这么干。不是说一定让他带徒弟,不是一定他有能力的才能带。
🟣 说话人2 [00:07:28]
他也不是想带就带。这这个但在我我们我们先先今天先聚焦于生活顾问和这个人机。
🟢 说话人1 [00:07:35]
因为我我可能扯的比较多。
🟣 说话人2 [00:07:37]
因为我我你是想的全盘。
🟢 说话人1 [00:07:38]
我要全盘考虑,所以我只是把这个东西跟他说出来。对,就是我我刚才只是说我如果按照你说的就是围绕他的一天,我们就围绕他的一天的工作,确实可以去看一下。有哪几哪些功能可以演示需要去做。
🟣 说话人2 [00:07:51]
什么做对动作对但是你未来你想的是他能够。
🟢 说话人1 [00:07:54]
对对。
🟣 说话人2 [00:07:54]
对因为说你做。
🟢 说话人1 [00:07:56]
要要考虑全一点你不能说只考虑这一点就是我们。
🟣 说话人2 [00:08:00]
但是师带徒就像我我以为啊你这个APP做完以后,他实际上是生活顾问半个师傅。对啊,但我我认为就是可能人际之间的交往只是这个线下实操的,但是基本上一些工作日常的SOP都可以通过这个手机来够来实现了。所以你说你说你的师带徒是为了丰富未来这个APP的内容,对啊,然后让他获得一些经验值,对,获得一些这种。
🟢 说话人1 [00:08:26]
而且而且留痕,他在上面他在上面问问题,然后师傅回来以后。
🟣 说话人2 [00:08:29]
我们就讲哦,我明白你的意思了,你的意思是不是说。
🟢 说话人1 [00:08:32]
就刚才严总说了,我为什么要带徒弟?因为我,你问了我个问题,我可以不告诉你,因为我告诉你没什么好处。但是你如果通过这个系统上问,然后我是个我是个师傅,我帮你解决了,那你。
🟣 说话人2 [00:08:41]
我还奖励金。你是不是通过嗯你,他徒弟来问我这个师傅,然后我们之间的对话,然后把他经验沉淀下来。
🟢 说话人1 [00:08:49]
放到你的这个手,呃,就是这里面奖励金,就是我真的我回答了,然后我们。
🟣 说话人2 [00:08:54]
那你那你所谓的师带徒。
🔵 说话人3 [00:08:56]
啊,对,你可以可以改,你要换一种名称,对,你说师带徒。就会让人有误会,一对一,一对一。其实事实上是这样子的。专家库,专家可能是问答,他可能只是一个,甚至只是一个践行顾问,但是他的经验沉淀可能甚至比那个高级顾问更厉害。对,是广普开放让大家去提问渠道,对,然后有人去踊跃的去回答问题,然后回答问题给他奖励,对对对,那就是说问一问那种也行,对,然后我来答一答。然后我我这个没关系,他答错了也没关系,对,我们可以从后台去给他总结,哪些是好的,我们可以把它顶上去。对,那这个问题的最佳答案应该就是这个。
🟣 说话人2 [00:09:33]
留言板,就跟一个留言板一样。
🔵 说话人3 [00:09:35]
就是不要把它变成失败徒,失败徒就会就会有定向的感觉。
🟣 说话人2 [00:09:39]
那个不好。可以可以。嗯。
🟢 说话人1 [00:09:40]
反正我就说这个方向。
🟣 说话人2 [00:09:42]
对,就这这个没问题,这个我们肯定支持,希望大家能够把经验留存下来。而且这个东西我我也看了,就是我也做了一些小小的功课,就是如果我们未来做这个,实际上我们应该是,就是前期做这个经验值的时候,会邀请一些优秀生活顾问,一起对这件事情做出一些经验的萃取。然后再把它输入到。我们的AI这个里面,让它这个更加这个有进。
🔵 说话人3 [00:10:03]
你甚至可以把它设成一个,比如说今天你遇到了什么难题,请提问一问,然后看看同事们怎么回答。然后就是你可以让他去问一问,然后他就直接一点,对,就是那种名称啊,不要让人看不懂,就是能让人就是非常非常简单的那种文字。哎,今天你遇到什么困难了吗?你跟客户沟通遇到什么困难了吗?有什么难题今天让你觉得很难受,想要问一问,对,怎么做?对,然后你给他这样子的一个按钮切入口,他点进去,他说,哎,我今天遇到了什么个问题,这个同事们能不能给我一些解答,我觉得我很困惑。然后这个时候呢。你有一个人提问了,就像百度一样,我说,哎,有人提问了,就有人想回答,什么时候我们去推送,你们谁回答?回答可以,有奖,有奖。
🟢 说话人1 [00:10:46]
有奖全赏。嗯,领导,还可以,还可以有奖征问题。
🟣 说话人2 [00:10:50]
哎。
🔵 说话人3 [00:10:50]
对。嗯,问好。一个是有奖征问题,一个是一个是今天你遇到什么问题,就是你就是你在一个问题的入口里面可以点,第一是我遇到什么困难了。第二叫有奖征问题,有奖征问题是没事找事。然后呢,售后顾问呢就想,哎,让我想想。
🟢 说话人1 [00:11:05]
我多个什么问题出来。
🔵 说话人3 [00:11:07]
没事干时想一想,让我想一想。但是呢,有一种真的是有一种就是你会让售后顾问一眼就看到了,因为我遇到困难了,我找这个案例。这个这个更会,这个这个就跟滴滴一样。
🟢 说话人1 [00:11:17]
我们要先花钱养成习惯。对他可能会养成习惯。
🟣 说话人2 [00:11:21]
然后他就他就会,这个没问题,这个没问题,这个。
🔵 说话人3 [00:11:24]
因为这个回答问题不限于生活顾问,所有人都可以回答,我们全公司的所有人都可以回答。
🟢 说话人1 [00:11:29]
包括军总,之前那么多,他来。
🔵 说话人3 [00:11:31]
啊,都可以回答,然后你呢,从后台就是你抓取不同层级的人,他给的答案是什么?对对,这样子便于我们融合嘛,我们大家后期再来融合,比如说这个问题,哎,谁来给个正经?
🟣 说话人2 [00:11:42]
对吧大家从不同方面回答的。
🟢 说话人1 [00:11:44]
或者是问你里面回答的时候,我我从后台可以挑出来三个不同的人的意见建议。
🟣 说话人2 [00:11:49]
这个你准备放在哪个模块?还是说直接就是有推送就是A P.
🟢 说话人1 [00:11:55]
P上面这个问问你们问你们啊。
🟣 说话人2 [00:11:59]
这个没问题啊问。
🟢 说话人1 [00:12:00]
就问你们会,问你们可以再再再加一个,呃,可以再加一个小模块,就是我我我学我我问。
🔵 说话人3 [00:12:06]
练什么的?
🟢 说话人1 [00:12:07]
那个是一功能,还可以再加一个,就是叫,就你您刚说的那个名字叫类似于像百度叫贴发一样的。
🔵 说话人3 [00:12:15]
嗯,就就有叫叫叫有有讲问谈,不是。
🟢 说话人1 [00:12:18]
他说度。问问题榜,这里啊对,啊,搞个问题榜。
🔵 说话人3 [00:12:22]
但我以问前我经常在百度文库里面。
🟢 说话人1 [00:12:23]
会啊。
🔵 说话人3 [00:12:24]
我们搞个问题榜,对,有个问题榜。
🟣 说话人2 [00:12:27]
好,那行,那那关键我们现在反正这个功能呢已经很完善了,但现在就是我们当天怎么落地,怎么发布。
🟢 说话人1 [00:12:35]
那我这两天现在还不停还不停在改。所以你刚才我把那个你我觉得把是这样的。
🔵 说话人3 [00:12:39]
你这个落地发布已经是后一步了,我们得先研究一下你的这个APP里面到底有哪些功能,你准备在发布哪些。
🟢 说话人1 [00:12:47]
我提个要求,我提个要求,就是。你你已经给我发了一些资料嗯但还不够全就是比如说从这个严总在这个这个从运营。角度你们的一些规章制度也好标准也好或者是一些这个相关的就是能给我的用来去学学习这个发给机器学习的那个信息你看安排人给我一些他给我的二十多个G的资料那还可以增加一些这个业务方面业务上的啊然后你看他说还有没有什么相关的不是首先。
🟣 说话人2 [00:13:20]
我们我们我们现在。
🔵 说话人3 [00:13:21]
是集团所有的资料都都投。
🟣 说话人2 [00:13:23]
进去可以啊但是现在就是分辨不出来哪个是最新的。我们可以。
🔵 说话人3 [00:13:28]
他他这边可以。你们的人力资料如果今年没有更新,就放最,就放一直以来执行的那个资料。我们还好,应该每个部门都有自己的资料。
🟣 说话人2 [00:13:36]
我们还好,主要是你们资料太多。
🔵 说话人3 [00:13:38]
我们是业务上的资料,对。商务顾问的问题不仅限于业务,业务上的资料我会来安排,他该怎么,他他需要知道,投投入哪些,他他需要知道哪些。然后往年的废旧的制度就不用管它,把当年新的制度放进去,他需要知道哪些。然后呢送货的问题其实不仅仅是我用这个A P P的人你的对象是谁是不是。全员都能用,只要是针对项目上面的,全员都能用,那就是全员都能用,那就是各个部门的制度都要往里面哦。
🟣 说话人2 [00:14:07]
我们这个整会整理好。啊,除了你们部门的,其他我们一起统一加入。包括报销流程什么的,啊,这个放心。
🔵 说话人3 [00:14:13]
这个我们来整理。事实上是公司全业态,全全流程的一个所有的东西都应该在里头。对,然后呢,他问什么,你才能真正的给他给用起来。对,就是看你这个 APP 你到底你到底目标,你的目标,未来目标目标是要做成什么样?我。
🟢 说话人1 [00:14:29]
阶段性目标,就是目标就是做成我我百问百答。
🔵 说话人3 [00:14:33]
不,百分百答,我觉得不是一个最大的一个目标。你如果说百分百答,他只是做成了一个问答工具,他到底最未来。
🟢 说话人1 [00:14:41]
要把它做成未来就做成一个就是就是就专业AI师傅。
🟣 说话人2 [00:14:47]
就是可以这么说。不是,那我具体举个例子啊,比如说假设我们拿报销这个事情举例,我比如说你能说你的APP只是一个。
🔵 说话人3 [00:14:54]
学习工具吗?也不是说在公司的业态。
🟢 说话人1 [00:14:59]
引导引导师。
🔵 说话人3 [00:15:00]
那种不是我知道我就是想问你你这个A P P未来的定位是什么是员工端。还是只是一个我们人力培训的工具。嗯,是企业员工端还是人力工具?如果是员工端培训,只是其中的一块很少。各个部门未来发布的企业内部的公告制度,因为除了在我们以往在这个企业的群里面去发布以外,他会直接可以上公告。对啊,上公告所有的员工直接就所有员工都能收到推送信息。对他如果说是员工端的话,他还会跟我们的,比如说报时报修巡检,所有东西全部都要明白一起去。会啊,我就问你。
🟢 说话人1 [00:15:41]
你未来的定位到底是什么?而且员工端呢,它跟像起飞的这个员工端还不太一样,就是我们会增加AI的东西。比如说你发个通知,我可以给他增加功能,跟我相关的,告诉我有哪些。经常公司发的各种文件,不是所有的我都要看。对,不是你发的这个文件可能只。我只差看其中两行。
🟣 说话人2 [00:16:01]
嗯。
🟢 说话人1 [00:16:02]
标记出来。把发发的所有的文件,我点个 AI 提取,跟我相关的,你告诉我哪几条。
🔵 说话人3 [00:16:09]
他未来的例子,这个这个张丽,这个已经是后一步了。我我我我承认 AI 它能做很多的事情,我只要喂给他足够的喂养的资料,他都能从中提,把我所需要的需求,甚至就变成,特别是切合我的,直接给我推送我想要的。我就想说你的 APP,你这个你这个员工端,未来想要做成什么样子?现在现阶段,比如大家现在在讨论的,我能接受到的只是用作了培训。
🟣 说话人2 [00:16:35]
不是那天不是王总不是说了吗?一个是沟通渠道,一个是培训,还有一个是什么来着的?
🟢 说话人1 [00:16:40]
王总那天说的就是类似于这个。
🔵 说话人3 [00:16:43]
他其实就变成了一个学习巡检啊。
🟣 说话人2 [00:16:45]
对,工作相关。对,但巡检实际是对,就是工作相关嘛,包括日常的报事报修全都在这边嘛,集合嘛。对对对。喂,但是那天我们只能演示,一是巡检,第二个或者是培训,就这两个,已经很很好了。嗯,比如说我的一天啊,我在想我的一天。如果我们现场演练的时候,比如说第一步,我因为我我可能想的,我今天你们你们定位太远了,我想的就是我那天发布会上面怎么怎么去讲这个事情,我可能想的是。
🔵 说话人3 [00:17:14]
不是不是,事实上是这样子的,就是如果说你你发布会,你光想着发布会上要讲什么,你首先得要知道这个东西是干嘛的,它主要是用作于什么,他主要用作什么?如果说他只用作于培训的 ok 那我们这个现在就是把他定位为他就是个培训工具,不单单是,嗯,然后把我们就变成我们自己企业的一个知识库,我们以后所有的各层级的员工都能通过这个知识库能够,这是最小,这是一部分,掌握到自己的学习的。对,那因为你现在已经切入到最后一部分了,就是发布这一部分,我现在还不知道到底前面要做什么。
🟣 说话人2 [00:17:50]
那天王总说了呀,一发布,二日常的工具,第三个沟通,员工与公司的沟通的渠道,也就是说一个 CRM 系统,或者是说你所有的能用到系。只要通过这一个手机,这一个端就可以看到所有。但是我们现在能做的就是先把培训这块。
🔵 说话人3 [00:18:06]
你先告诉我能看到什么,我现在看到的只是人力的学问,闭眼还有什么东西能看到收小玉吗?能看到我给客户推送的工单闭环了没有吗?能看到我现在巡检的结果吗?
🟢 说话人1 [00:18:18]
这个都没有。
🔵 说话人3 [00:18:19]
对呀,所以你不是一个员工,但就是一个。
🟢 说话人1 [00:18:21]
我们袁总是这样的。第一讲就这个需求是王总上周才提的。
🟣 说话人2 [00:18:27]
他他是不是已经很快就做到这步了?
🟢 说话人1 [00:18:30]
上周他提的,上周是二提的。嗯,这这个这是这是一个,第二个呢是我们之前规划的其实是有有三四代 APP,不是一个。但王总现在提的只做成一个,我我是觉得有一点不是太合适的。
🔵 说话人3 [00:18:44]
你其实你不要用三四个,对于员工来讲,一个入口,一个入口解决所有问题。你看以前有一个银城的那个叫。瑞森生活你应该知道有个瑞森生活,瑞森生活上面有包吃包修,没有没有呃这个培训的这一块,就是现在我们不做改。改变嘛,我们可以继续切入。你这个就是我为什么要提前先把这些问清楚?首先我还不知道这到底是个什么东西,就是大概我知道了,我光看到了学问链,还有一个什么四个字,就跟都跟培训有关,对,全跟培训有关。它并不是。
🟣 说话人2 [00:19:16]
一个员工端。
🔵 说话人3 [00:19:17]
员工端。所以说就是我我我我是不是你现在目前已经只想解决培训的问题,那我们就就我我我看看,我看看。
🟢 说话人1 [00:19:25]
你可能从从。
🔵 说话人3 [00:19:28]
是不是就把它做成一个企业知识库就可以了。
🟢 说话人1 [00:19:31]
第一步我们一步一步。
🔵 说话人3 [00:19:33]
来第一步是企业知识库那下一步呢你看。
🟢 说话人1 [00:19:36]
我我就给你看一下。就我们另外搞了一个系统,已经把这个跟业务项目运营相关的都放在这里。
🟣 说话人2 [00:19:47]
那也就是说。
🟢 说话人1 [00:19:48]
就是其实我现在定位是300个。
🟣 说话人2 [00:19:50]
啊,可以集成成一个,呃,集不集成要考,就放在企业微信。
🟢 说话人1 [00:19:54]
或者是互相跳都行,就是集成一个我都觉得太重了,我是觉得太重了,因为我我改。工作的时候也会很累,对。这是我我觉得就是你们可能你们作为业务的人,你会有没有这种感?我们在开发的时候会感觉这个东西太重。
🟣 说话人2 [00:20:08]
嗯,你说的也对。啊,就是就是我从一个。
🟢 说话人1 [00:20:10]
就让我升级好升级。我我如果升级这个培训的,我就只要是改培训的地方,我就不用,不会影响其他东西。我如果改这个,你看我给你看,改这个员工,这个用户,这个这个业务,就项目管理端,我就只需要改项目项目所管理端。我就我就我就不会影响其他东西。
🟣 说话人2 [00:20:28]
这是一个新的新的步骤,呃。
🟢 说话人1 [00:20:30]
还没发布的。
🔵 说话人3 [00:20:31]
但是像美国在支持。
🟣 说话人2 [00:20:33]
那也就是说,呃,任务的工单可以在这,督导的管理在这,培训的有可能在下面。
🟢 说话人1 [00:20:40]
可以跳出来一个新的新的一个模块。
🟣 说话人2 [00:20:42]
可以跳,最好是跳。
🟢 说话人1 [00:20:44]
不要是一个一个我们改。
🟣 说话人2 [00:20:45]
对,但是跳等于就是你可以嵌入到这个里面,就像你看 CRM 系统,它可以嵌嵌入一个模块,然后一跳跳到这个里面,跳到跳到。
🟢 说话人1 [00:20:55]
打开新小程序。
🟣 说话人2 [00:20:57]
你你搞成一个,你改得很累,那那就可以做个你。关好,然后可以跳各种的。
🟢 说话人1 [00:21:01]
对,就就就就是这个意思。
🟣 说话人2 [00:21:03]
就是如果你你你就是一个真的态度。对对对。
🔵 说话人3 [00:21:06]
没问题,没关系啊,就是你你是要主动解决运费的问题,你可以跟他联系啊,这个没什么的,因为现在我也不负责他们那一块。
🟣 说话人2 [00:21:14]
你怎么一个人代表一个公司啊?你成立个艺人公司好了,做的也太快了啊。对啊。
🔵 说话人3 [00:21:20]
你这样的让他也是需要去了解的。
🟣 说话人2 [00:21:22]
不不,他是要问清楚他,因为他那天他他要问清楚这个到底这个端口怎么弄。
🟢 说话人1 [00:21:28]
就是就是我们我们因为我们还有东西还没给你看,我们还做了个电子名片的东西。那那又是另外一个功能了,就是它定位不能是一致的,而且你你你去过那个上海那个叫永永生吗?他们有好几十个产品,他不可能是一个。
🔵 说话人3 [00:21:42]
他有好几十个产品,他集中在了一个平台上面,他有一个他有一个中台,他有个数据中台,那是数据中台,什么两湖,一一一一什么的。
🟢 说话人1 [00:21:51]
那个那个那个数据中台就是一个产品,他不是一个,一个不是一个东西。
🔵 说话人3 [00:21:55]
我我跟你讲,他在一个他在一个,我知道他在一个就是电脑端上面。他可以看到所有的。
🟢 说话人1 [00:22:01]
我后后台肯定不是一个因为太。
🔵 说话人3 [00:22:04]
累了对他端他是。
🟢 说话人1 [00:22:05]
有如果是一个改起来。
🔵 说话人3 [00:22:06]
太痛苦了他他是买了那个新视窗的一个一个一个对一个一个中台一个一个底板然后呢他在上面做的接口就是他是做的这个他那个很全很全我看到很成熟嗯。
🟣 说话人2 [00:22:23]
那那你刚才给我看的那个端口,不就是也可以集成在一个端吗?也是一个端,只是它跳跳转到那个,就在一个,我我员工用的时候还是很简单,就跟企业微信一样。我这个这个要用这个模块的时候就点进这个,要用企业门户的时候就点企业门户,我要用这个,这么这么就还不一样。
🟢 说话人1 [00:22:41]
就有可能需要打开新的小程序,对,没关系。
🟣 说话人2 [00:22:44]
我来帮你串起来。对,就是还是在一个,那也就是说你就是集合员工端里面。
🟢 说话人1 [00:22:48]
加载负载啊。
🟣 说话人2 [00:22:49]
都会轻一些。对我通过这一个手机,我可以看到 CRM 系统,可以看到这个督导的平台,也可以看到这个培训的,就这三个就集中在一起。对,明白明白,那。那那那确实很强大了。
🟢 说话人1 [00:23:02]
可以调。就是就是刚才在讲回来这个,就是我我我觉得我们比如这两天要干的事情,确实就是要收集一些资料,资料是吧?啊,因为包括其实我昨天在刘总那边去演示一下,我说我就问了一个问题,我说你告诉我,哎,现在有人投诉这个,你你用了没?
🟣 说话人2 [00:23:19]
用了我用了就是会有个产品那个总结嘛。
🟢 说话人1 [00:23:22]
我给严总过一下,这个不用不用。不用你可以没感觉。
🟣 说话人2 [00:23:28]
但是有几个端口没有开放现在只是开放了那个这个问。嗯因为练我又练练不起来我发现。
🟢 说话人1 [00:23:37]
问的可能全一点。
🟣 说话人2 [00:23:39]
就是S O P那个对。
🟢 说话人1 [00:23:51]
业主反映楼道经常有屎,我该怎么处理?
🟢 说话人1 [00:24:00]
业主反映楼道经常有屎,我该怎么处理?啊,为啥失败了?什么信号?
🟣 说话人2 [00:24:12]
你不要,你可能他不太能接受这么污浊的事。不是不是,我最近生病的全是嗓子疼啊。对,那个,那个梁颖也是嗓子疼,后来我周围一圈他也嗓子开始疼了,为什么?不知道是不是什么某种新型的,类似于,没事,别担心。
🟣 说话人2 [00:26:23]
不是。
🟣 说话人2 [00:30:29]
学第三个是学岗前课程每日小训对那这个学和练有什么。
🔵 说话人3 [00:30:34]
区别学位啊不是。
🟢 说话人1 [00:30:35]
学位学还没搞呢就正在。
🔵 说话人3 [00:30:38]
搞那就是说好那还有第四个模块。
🟢 说话人1 [00:30:40]
是什么第四个是提是提啊是报是报啊报报是报什么报是报。
🟣 说话人2 [00:30:45]
上报案例拍照记录。
🟢 说话人1 [00:30:47]
我上报我的经验我上报的案例就是就是刚才说的就是就是我主动提我觉得。呃,我碰到的今天处理个什么事情,发生了什么事情,我怎么处理的?
🔵 说话人3 [00:31:00]
这这是报。啊,这个报的社会部分可能啊从字面意思上面会很容易,就还没找到合适的词,还没找到合适的词,对,这个词不合适,到时候未来再要去改嘛。那你现在目前,比如说我们现在知道了,呃,就是从那个学学目前还没有建立,对吧?对,学正在搞,还没搞完,然后练练就等于说接下来双钩会会面临有每天都有5分钟的任务,对,然后他要去练。问的话他现在也可以去问,那就基于这些我们给资料,对,看看,比如说给什么样的资料,资料可能给的多,他就会给双钩去提问。
🟢 说话人1 [00:31:35]
对对对对,谢谢。
🔵 说话人3 [00:31:36]
他会针对生活顾问这个身份来给他问适合他这个身份的问题吗?会会会的是吧?
🟢 说话人1 [00:31:42]
就是会会分呃大概会分四宝一图这种方向。
🔵 说话人3 [00:31:46]
四宝一图的方向,那生活顾问其实是从他的这个角色定位上面来讲,他其实四宝一图里面内容他都得要知道。对对吧?包括专业知识,他其实都得要知道。对对。
🟢 说话人1 [00:31:57]
就可以你其实要设不同的岗去。去,就回答你呃对应的知识点,就是要要叫问题要跟你的岗位相关。
🔵 说话人3 [00:32:06]
那现在第一步就是我们完善一下里面的资料库。对。
🟢 说话人1 [00:32:09]
这个这个现在比较缺。
🔵 说话人3 [00:32:11]
他现在现在知识库你们计划放哪些知识库呢?是把企业所有的,就是全流程知识库都要放进去。
🟣 说话人2 [00:32:17]
各个各个各个职能模块,应知应会。对,我已经把各个我能找到的都,今年所有发布的全部都发。没有没有没有,那个像运营那边很多东西我下载不了,我就没有吧。我说运营,就是我们其他的,除了运营以外,运营他们自己负责。财务啊,什么呃那个,财务放的什么?财务方的什么收不抵支流程报销,然后现金。
🔵 说话人3 [00:32:42]
是PPT还是PDF?
🟣 说话人2 [00:32:44]
手册PDF.
🔵 说话人3 [00:32:46]
报销手册。
🟣 说话人2 [00:32:47]
PPT他可能吸收不了吧?
🔵 说话人3 [00:32:48]
管理制度业务招待,你这里面是不是PPT没有用,必须用文字版的PPT很。
🟣 说话人2 [00:32:54]
PPT他,但是PPT有一些结构化的这种用图形化的,有可能他没办法一一对应哦。
🔵 说话人3 [00:32:59]
他就是图片。
🟣 说话人2 [00:33:00]
PPT少量文字嗯,是吧?能对能对应的,我用的那个小AI也是可以对应的。嗯,那到时候看。
🔵 说话人3 [00:33:08]
主要不啊,哪怕发出来,到时候回答问题就给他丢个PPT也可以啊。
🟢 说话人1 [00:33:12]
对,包括语音都行,包括就是连声音也行。
🟣 说话人2 [00:33:15]
有一段的声音哦,没问题。
🟢 说话人1 [00:33:17]
包括录音的文件页给我,我也要练练。
🟣 说话人2 [00:33:20]
我我我录音的什么?对我们开会,你怎么知道我录音了?
🟢 说话人1 [00:33:24]
我有这个习惯,我也是希望录。
🟣 说话人2 [00:33:27]
我看你录了就没录,对对对对对。
🟢 说话人1 [00:33:29]
要不然我记不住啊。
🟣 说话人2 [00:33:30]
对对对对,因为AI会帮我训练一下这个到底要做什么啊,是的。
🔵 说话人3 [00:33:37]
就是,那现在其实就是一个知识库要充实,现在把企业的所有知识库给它丰富了就可以了。
🟣 说话人2 [00:33:43]
那那我我有,你们你们问我有几个问题啊,首先就是这个几个模块,我有一个疑问就是问,我们现在知道了,反正就是查 SOP 查那个话术,但是你刚才说的那个悬赏那个功能不应不应该放在问里面,不不不,对吧?那是一个单独的模块,对,对对对。然后练习和学,我觉得这两个有点相像。因为你目前如果场景模拟训练、错题重练和岗前课程每日小训,那这两个可能是不是不一样的?比如说学就是这个是知识内容的一个学习,或者是一个什么学。我我知道了,是不是这样说?练是互动,学和考学是单项。
🟢 说话人1 [00:34:22]
学和考是一体。嗯,计划的这个位置是学和考,就是你经常我我们我我我我我前年你给我考的那个东西。嗯,就是考试,我们不是有一个这个经理晋升的那个考试嘛,对对对,就这个是准备放专业性的学习和考试。
🟣 说话人2 [00:34:38]
那那对,那学是不是相当于我能不能这样理解?练是互动,AI 互动,对。然后学就是单项,单项我推送,然后你考试,对。那这这这两个就没问题了,然后那个报,实际上就是换个名字,就是如果你未来有什么东西,案例,好的案例,想一个名词吧其实你那个可以跟你这个悬赏放在一起为什么不可以呢那就叫赏对吧。跟你,或者到时候我们再换一个换一个字,好。那行,这个我能理解了。
🟣 说话人2 [00:35:04]
接下来呢,我就想问一下,因为我我最终的效果是我生活顾问要用,用的是一个什么样的一个效果?比如说我现场演示的时候,虽然我讲的那个,因为我还是以当天演示的,因为你再怎么训练,我不知道训练最终的一个结果是什么样的。比如说当天我练习的时候,呃,有个人来问我,AI 会跟我对话,AI 对我对话,比如说呃说收费时候遇到一个什么什么什么什么样的一个,就像你刚才说的,这个楼道里面有垃圾,这怎么弄?然后我生活顾问回答,那我这个这个这个几个场景,那天王总说了,我要设计几个场景,这个场景是我提前要怎么给你?还是说 AI 它会直接训练这个场景?就比如说收费的时候会遇到A种人B种人C种人D种人嗯嗯对吧啊你拿拿拿会有A就是收费的时候我们会遇到A B C D人那。我们在练的时候就会把这个 ABCD 人的这种讲话方式,或者是他的这个问答方式来跟我们练习。那这个的话,我要给你什么样的资料,他才能达到这种练的这种效果?
🟢 说话人1 [00:36:11]
这个我我们会主动通过 AI 来控制。
🟣 说话人2 [00:36:15]
就是这个这个你你这个我就不用担心。
🟢 说话人1 [00:36:17]
你你控制不了,我们通过 AI 来主动的控制,然后现在还加了个东西,加了个啥呢?加我加了一个,比如说我问了个问题,然后他回他回答我一个方案,那我我我底下会有几个按钮。这个再再严再再凶一点,再温柔一点,再再再精细化一点,再专业一点,就会有不同的这种调节的这个方案,告诉 AI 让他再再给我新的这个回复。
🟣 说话人2 [00:36:43]
那行,等于就是说对话这块的内容是由你们这边来完成,那我们给你足够多的资料,他就,然后最终你实行效果的时候,我们看看调是不是 ok 对吧?那这块我们就不用担心,那我现在除了给你资料以外,我还需要给你什么?
🟢 说话人1 [00:37:00]
呃,你后面,你需要干的事情可能比如说周周五或者周六周日我发出来的时候,你就帮忙去测测,嗯,就行。
🟣 说话人2 [00:37:08]
但是我们也要考虑到生活,比如说还有一个具体的,就是我就不讲业务的,因为业务的我不是很懂,那假设我讲财务的,我说的是,我这边有一个考证的发票,我应该通过,就是我我我要考证,然后考证完了以后有一个什么培训费用的发票,我应该报的哪个门,那个门科目里面,就这个,他能不能一步步去走。
🟢 说话人1 [00:37:29]
咱们那个文档里有有这个内容就能抓出来。
🟣 说话人2 [00:37:32]
嗯,那比如说他会给我的,因为企业微信它内置的那个智能机器人,我当我问他,我说考证的这个科目是培训费的时候,报哪个,他可能给我推送一堆。像你这,你的推送的话是一节一节能够112345 SOP 往往下走的,还是说就是一堆资料?
🟢 说话人1 [00:37:49]
按照你这个要求,你只要告诉你要求,我们就能改。
🟣 说话人2 [00:37:52]
对我觉得因为那个我我那天也试了一下我觉得好像是不是有点短就是我感觉感觉他提炼的内容太。新了还是什么?有点短,就是不知道怎么操作了,是吧?对,就是没有那种一二三四五,他但是他他会很简单,就是呃很你打开,比如说打开金蝶,然后你然后报销名录,你再写什么,必须是得知道一二三四五,你告诉我怎么做,这就是我们对物业人对我们物业人大多是什么呢?为什么他们公司的制度很多,但他们为什么不喜欢看?就在于他们想直给直拿,问人家你帮我操作一下,这就是直给,所以这个东西一定要做到能够直给,他他是最好的。嗯。
🟢 说话人1 [00:38:33]
就是你们不断的给我提要求。
🟣 说话人2 [00:38:35]
我们就能做得更好。哇,那你那你那那等于就是我们现在只要搜集各个各家政。
🟢 说话人1 [00:38:41]
你们对需要你们测呢?因为我那天王总也讲了,我我们是我们的思路,我们有个很简单的事情。
🟣 说话人2 [00:38:48]
但是我们也可以要求就请生活顾问帮忙去测也可以。因为比如说如果我我假设我我我来当生活顾问,我第一个可能会遇到一些入职以后该知道哪些知识这些东西,但这些东西我是。这个要梳理,比如说我要从生活顾问入职,应该要知道哪些东西,我要按照这种类型梳理给你,还是说我知道花给你,反正你会根据一个人。
🟢 说话人1 [00:39:10]
我们我们先第一先给我全部,先给你,然后第二步我们在逐步的梳理生活顾问,他应知应会,从头到尾的排啊,然后然后给过去以后,那个系统再再抓的时候会更清晰。
🟣 说话人2 [00:39:23]
那你现在是不是应该对我们这个项目做一个排期啊?因为如果我们现在给你资料,肯定要在什么时间段给你,然后你要给我们验证一下效果,然后这个。
🟢 说话人1 [00:39:31]
排期今天周三。嗯。对吧,你你今天下班之前把资料给我,周五我们先可以出一个版本。
🟣 说话人2 [00:39:40]
嗯,然后第二个的话就是现场演示,因为我反正我都一我一定是看到现场演示嘛。如果说只是跟企业微信那种方式,你问他回答,那其实经验程度不够高。那员工端那个展示,比如说我生活部正常,我每天都要做巡检,我到楼道到天台,那是不是?我可以到了楼道的时候,我拍张照片就会告诉我我应该取件哪些地方。就这个你目前你能不能做到?然后我到天台,他会告诉我我可能应该关注哪些位置。然后到了那个位置以后,我可能再拍张照片,比如说屋顶盖有没有什么遮挡或者什么之类的。他就是我们那天演示到底演示什么,就是会会让大家觉得哦,这个东西确实有帮助。还还是说那天我们只要演示对话互练这个动动作。
🟢 说话人1 [00:40:26]
这个这个互练。就是我如果到了天台。呃,能现在能贴码吗?啊,现在能贴二维码吗?
🟣 说话人2 [00:40:38]
应该不知道,这个应该。
🟢 说话人1 [00:40:40]
现在可以贴个二维码,我只要扫这个二维码就可以告诉他周围你需要关注什么。嗯,这样是不是可以?
🔵 说话人3 [00:40:48]
那你这个就回到了以前的瓶颈了。
🟢 说话人1 [00:40:50]
以前瓶颈我不知道他他要你说生活顾问要关注什么。
🟣 说话人2 [00:40:53]
对,就是我我肯定是从生活顾问岗位一天的实操,我肯定从早上。对吧。这个 BI 规范,然后再到我日常要巡检,每天要干什么内容,对于干完那个内容,他的专业知识肯定要要啊,嗯,他肯定要正常巡检,巡检要关注哪些危险、风险源,或者是安全点什么这些他都都要关注。然后再然后再涉及到收费,然后怎么为业主服务,这些就是肯定要根据他岗位上日常做的一些事情,明白,123456,然然后我们整合他所需要的知识库,明白。但是如果说你脱离了他实实际岗位,就是只是让他来问,他主动去问,你跟他去训练,这个东西会,就就就对于他岗位实际实操来说不够有用,因为我们就嗯知识库就提的在理论,但是我们必须得能让他。如果你想增加他这个使用的频次和率,那一定是他在日常巡检的时候他就可以用起来,或者是他日常有什么问题就可以可以直接问。嗯可能会这样会更好一些但我不知道我就大家对于这个A P P每人设想不一样这是我的设想我不知道。你们是怎么想的如果单纯嗯只是A I训练因为A I训练。
🟢 说话人1 [00:42:04]
方向去努力嗯嗯。
🟣 说话人2 [00:42:07]
那就看严总你这边呢。
🔵 说话人3 [00:42:10]
那个什么,整再整理一些资料给你吧。先看看资料放进去。
🟣 说话人2 [00:42:15]
之后什么效果,什么什么效果吧。
🔵 说话人3 [00:42:17]
那行,那我们因为现在想不清楚,你得资料放进去喂足了,然后你问AI看AI给你回答的问题。
🟣 说话人2 [00:42:23]
到底到什么程度。
🟢 说话人1 [00:42:24]
到底到什么程度。其实这两天先,这周先把那个资料放进去,然后能够把这个他回答的这个方式,就跟你们刚才讲的,能够按照你们现在的感觉,能够先。改改好,这是首先第一步。嗯,啊,然后然然后再加上那个刚才说的呃一天的工作中还能再还能再尽快加上哪些东西,我觉得就就就就差不多了。
🟣 说话人2 [00:42:47]
嗯,那行,那那那那这样,那因为比如说像生活顾问手册,呃,因为是2222年的版本了,这个我们。我们也把它放进去了。
🔵 说话人3 [00:42:58]
但是不需要去换版本因为它一。
🟣 说话人2 [00:43:00]
不是,它里面会有设计,智慧美家的一些错误的信息,这个我们就会把它删掉。嗯,就是还有不少错误的东西。嗯,就是它 ai 它没办法,就比如一个正确的知识,一个错误的知识,一旦融合的时候,它 ai 就没办法分辨出来哪个正确哪个错误,因为它这个不是联网的,它是我们联,是我们内部的。知识,现在我们那天放进去以后发现叫智慧美家,还会出现像那个摇波。离职那个人。
🔵 说话人3 [00:43:28]
嗯,这个现在这个要系统性的梳理。对,那我现在我现在部门的小孩全部都出去拼件去了。
🟣 说话人2 [00:43:36]
对,但是我们可以这样,就是把不合适的先删除,就是我们那边做的,对,就要需要有个人去做。
🔵 说话人3 [00:43:44]
嗯,或者你们先梳理也可以。
🟣 说话人2 [00:43:47]
我们已经,我能把我能看出来的我都对。
🔵 说话人3 [00:43:49]
业务知识没什么太大问题,然后也有。
🟣 说话人2 [00:43:52]
因为之前考试的时候就经常有人跟我反馈那个有问题,说意思可能现在已经不做了或者怎么样,但是因为比如说像祥乐一安这个东西,他。比如说什么享乐一安,然后还有什么,对对对对,就是然后他也是一大很大的一个篇幅,我也给删了,就能看出来的我都给删了。但有的我们能识别的,我们已经第一步把它初筛筛掉了。但是有的业务,比如说呃以前用的系统现在不用的,但是还在。以前的巡检还是呃以前的什么督导方法还是什么的,因为之前有生活部门跟我提过,说意思是错的,我让我后来看一下,是因为手册里面就写的是这样子。嗯,所以就是我我们能筛一部分,但是有一部分我们筛不出来,对。嗯。只要跟公司其他政策相关的,我知道的我都能删。但是其他的确实我们删不出来。
🟣 说话人2 [00:44:37]
如果这个机器人跟之前那个,因为我之前企微也搞了一个小机器人嘛,然后他也因为就是业务不是有的,就是我给你的那个生活顾问手册里面有的内容是错的,然后错的之后呢,我问了之后,因为我问了那个问题,他就一直是识别的那个错误的东西,答案给我们,包括我把那个手册给更换掉了,他还是会给我识别那个错误答案。然后我就去问企微的客服,他们说。这个东西是没办法,因为A I训练A I就是这样说意思,他只能就是他得有之前的记忆,就是你只要在一个对话框里面,他就永远是错的。我说那你这个怎么办呢?你这不算bug吗?他说这现在不算bug,因为这是他的学习功能。那那我们现在也可以。
🟢 说话人1 [00:45:14]
我们可以加,就是这就是可以允许你在这个答案上面右下角可以打勾和打叉。如果你打叉这条信息以后就不再用哦。
🟣 说话人2 [00:45:23]
还有一个就是我们给你的知识的颗粒度要多少因为我一直在讲。
🟢 说话人1 [00:45:27]
不需要你就给就行因为现在让你在做太多工作你根本吃。
🟣 说话人2 [00:45:31]
不消ok ok那更好那那行那我们再把所有的知识的颗粒度嗯对他意思就是说就是反正别人写的怎么写的怎么实操的哎那我问你啊比如说采购他们有一些采购平台的流程视频流程这个我们也可以给你。对吧这个也可以给他反正他现金操作的也就是说售后过一旦采购怎么弄直接那个视频就。
🟢 说话人1 [00:45:51]
登出来视频链接发给他们看。
🟣 说话人2 [00:45:54]
嗯可以可以那我们就再整整理整理那也就是说我们整理其他部门的那严总那边你们可能。分工一下,就是你也可以交给我们一些工作,我们能帮你弄的弄弄一弄。但是有一些我们识别不了的,确实不知道他现行的是什么。
🔵 说话人3 [00:46:10]
我能看见那天给我女孩的有哪些吧,然后我再给你增加一些资料。
🟣 说话人2 [00:46:15]
我们要我们也可以先把我们。上传的或者给给张工的这个资料给跟严总对一下也行,就严总那天给我的,全都给了哦。行,那我们就其他没有,那我们就看看其他部门的。嗯,有一些有一些资料可能不太合适放的,那我们就不放了。嗯嗯,对,就是一些涉及涉及到一些保密的或者什么的。
🟢 说话人1 [00:46:37]
那那种时段都问出来,麻烦对。
🟣 说话人2 [00:46:41]
但是你要说但你要说跟人晋升有关那那多多少少还有还是有一些有关的。
🟢 说话人1 [00:46:46]
啊你对吧对最后需要设一些等级比如说严总他们这种级别的可以问一些其他的问题嗯当然可以再再再限制。
🟣 说话人2 [00:46:56]
还有一个就是这个东西出来以后我们那个。生活困境急怎么办?晋级正常晋级没问题,但是那个学分。
🔵 说话人3 [00:47:05]
就是你这个知识,难道就会变成什么呢?那其实嗯也不是所有知识都能问的。他想了解的不是所有都能问到,对吧?
🟣 说话人2 [00:47:17]
那那那同样我们也可以去问问到项目上跟岗看看是吧,生活问到底想问什么?那学分就可以下接这个平台,对他每天陪练练多少,你才能参加晋级考试多少合格才能参加级,就是我们不用单单条线,对大家一起考试同考。那肯定还是要统考的嘛。
🟢 说话人1 [00:47:38]
就分开考,怎么考?
🟣 说话人2 [00:47:41]
那通通考只是晋级中的一环,就不需要日常的训练。对,日日常的就通过系统,嗯,系统推送,然后你回答,就这样。那行啊,那就这样。
🔵 说话人3 [00:47:52]
那我们先,我再去我再去完善一下。
🟣 说话人2 [00:47:54]
那那你。
🟢 说话人1 [00:47:55]
我再给你一些资料,我觉得你休息,我再给你一些资料,看你都头疼,不行。
🟣 说话人2 [00:48:01]
张工,那你到时候可能要给我们排个期,不是吗?如果这个项目真的你嗯最迟什么时候给你,二十四、二十五吧。七月二十四。
🟢 说话人1 [00:48:12]
二十五,二十四。嗯,我们先定个周期,就是这周五先出一个版本。
🟣 说话人2 [00:48:18]
对,如果实在短期的,对,如果实在实在是都到,反正反正汇报的现场所有的题目肯定是提前预点好的。嗯嗯,到时候他只要能回答出我们想要的那个结果,让人家觉得哦,这个是实用的就行了,然后后面我们再慢慢更新嘛。对,行,那就这样。
🟢 说话人1 [00:48:34]
三千四。
🟣 说话人2 [00:48:38]
嗯,我们我我再回去看一下,再去看看下再去增加一些啊行Ook K你也去也他我这东西怎么啊差吓死我了。
@@ -0,0 +1,73 @@
# 发布会演示候选题单
> 生成时间:2026-07-08
> 来源口径:基于已入库正式资料生成,不使用本地 smoke 题作为验收依据。
> 状态说明:本文件是开发方候选题单,最终题单需业务方确认后锁定。
> 最近复核:2026-07-10 运行 `node scripts/verify-demo-questions.mjs --prefix=Q`,Q01-Q12 结果 `12/12`、来源告警 `0`、向量 `450/450`。
## 生成依据
- 正式资料目录:`/Users/yuanjiantsui/workspace/项目-物业AI/补充资料`
- 已入库知识库:`aihr_knowledge_attach` 共 106 个附件,`aihr_knowledge_fragment` 共 450 个片段。
- 当前向量状态:450/450 片段已使用 `BAAI/bge-m3` 向量化,Qdrant 维度匹配。
- 已处理视频:`生活顾问的一天.mp4` 已转写并可检索。
- 暂不作为主依据:其余 27 个 MP4 仍为 `pending-transcribe`,转写前不纳入现场演示题主池。
- 复跑命令:`node scripts/verify-demo-questions.mjs --prefix=Q`;业务测试扩展题可用 `--prefix=E` 验证。
## 现场实操优先题
以下 12 题适合发布会现场“问”模块演示,已于本地接口验证 12/12 可检索、可生成直给答案。
| 编号 | 场景 | 候选问题 | 主要来源 | 验证状态 |
|---|---|---|---|---|
| Q01 | 生活顾问一天 | 生活顾问的一天有哪些关键动作,早晚分别要做什么? | `生活顾问的一天.mp4`、`陈明- 银城物业新员工培训25.2.pptx` | 已验证 |
| Q02 | 投诉接待 | 接到业主投诉保洁不满意,生活顾问第一步应该怎么处理? | `生活顾问的一天.mp4`、`投诉处理 SOP v1.0` | 已验证 |
| Q03 | 漏水拒缴 | 业主家楼上漏水还想拒交物业费,生活顾问应该怎么答? | `楼上漏水,能拒缴物业费吗?.pptx` | 已验证 |
| Q04 | 楼道堆物 | 楼道堆物影响通行和消防,生活顾问应该怎么处理并留痕? | `楼道杂物添“堵”,物业公司公告后清理被起诉.pptx`、`放在楼道里的东西丢了,找物业赔偿,法院支持吗?.pptx` | 已验证 |
| Q05 | 违规装修 | 发现业主违规装修,生活顾问怎么劝阻、上报和闭环? | `客户违规装修.pptx` | 已验证 |
| Q06 | 电梯舆情 | 电梯维保问题在业主群引发舆论,生活顾问应该先做什么? | `电梯维保引发业主群舆论.pptx` | 已验证 |
| Q07 | 地面摔伤 | 地砖或路面破损导致业主摔伤,生活顾问现场处理步骤是什么? | `小区红线内骑车摔倒.pptx`、`物业未及时扫雪导致业主摔伤.pptx` | 已验证 |
| Q08 | 消防通道 | 消防通道被车辆或杂物占用,生活顾问应该怎么处理? | `消防通道堵塞.pptx`、`楼道杂物添“堵”,物业公司公告后清理被起诉.pptx` | 已验证 |
| Q09 | 报修跟进 | 业主报修后反复催问进度,生活顾问应该怎么跟进? | `报修跟进 SOP v1.0`、`客户违规装修.pptx` | 已验证 |
| Q10 | 催缴沟通 | 催缴物业费时业主情绪抵触,生活顾问应该怎么开场沟通? | `催缴沟通 SOP v1.0`、`楼上漏水,能拒缴物业费吗?.pptx` | 已验证 |
| Q11 | 群内曝光 | 业主在微信群曝光他人不当行为,生活顾问应该如何引导? | `微信群内曝光业主不当行为.pptx` | 已验证 |
| Q12 | 高空坠物 | 高空抛物或外立面部件坠落砸车,生活顾问要做哪些动作? | `高空抛坠物损害责任纠纷.pptx` | 已验证 |
## 业务测试扩展题
以下题目适合 07-10 到 07-12 业务方实测使用。建议业务方选出高频真实问题后,再进入正式演示锁题。
| 编号 | 场景 | 候选问题 | 主要来源 | 建议用途 |
|---|---|---|---|---|
| E01 | 公共收益 | 小区公共收益能不能直接用于公共设施维修,生活顾问怎么解释? | `小区内公共收益使用.pptx` | 问答准确性 |
| E02 | 收费调整 | 物业公司能否自行调整物业收费标准,业主质疑时怎么答? | `物业公司未经法定程序随意调整物业收费标准.pptx` | 问答准确性 |
| E03 | 停水群诉 | 停水造成业主群诉时,生活顾问先做哪些动作? | `停水造成业主群诉.pptx` | 投诉场景 |
| E04 | 返水财损 | 管道堵塞或厨房污水返流造成室内财产损失,生活顾问怎么处理? | `管道堵塞造成室内返水.pptx`、`一楼厨房污水返流财产受损事件.pptx` | 投诉场景 |
| E05 | 车库财损 | 车库被淹或风管脱落砸坏车辆,生活顾问如何响应和留痕? | `车库被淹车辆受损.pptx`、`车库风管脱落砸坏车辆.pptx` | 风险处置 |
| E06 | 道闸事故 | 道闸损坏造成伤人伤车,现场应该如何处理? | `道闸损坏伤人伤车.pptx` | 风险处置 |
| E07 | 楼道灯损坏 | 楼道灯损坏导致业主摔伤,项目应承担什么处置动作? | `楼道灯损坏导致他人摔伤.pptx` | 风险处置 |
| E08 | 雪天摔伤 | 雪天小区内业主摔倒骨折,生活顾问怎么处理? | `雪天小区内业主摔倒骨折要求物业赔偿.pptx` | 风险处置 |
| E09 | 流浪动物 | 流浪猫狗伤人或投喂引发纠纷,生活顾问怎么处理? | `流浪猫狗出没伤人.pptx`、`流浪猫伤人,投喂者是否承担责任.pptx` | 风险处置 |
| E10 | 电动车火灾 | 电动车引发火灾导致他人财产损失,生活顾问怎么应对? | `电动车引发火灾导致他人财产损失.pptx` | 消防风险 |
| E11 | 烟花火灾 | 燃放烟花爆竹引发火灾,物业现场要做什么? | `燃放烟花爆竹引发火灾.pptx` | 消防风险 |
| E12 | 充电桩纠纷 | 业主申请安装充电桩产生纠纷,生活顾问怎么解释和引导? | `充电桩安装的纠纷.pptx` | 客户沟通 |
| E13 | 加装电梯 | 加装电梯受到相邻楼栋反对,生活顾问怎么说明边界? | `依法加装电梯的业主有权请求相邻楼栋业主停止妨害加装电梯的行为.pptx` | 客户沟通 |
| E14 | 采购比价 | 一万元以内采购是否需要三方比价,定标表怎么用? | `中层级采购培训-25.6.pptx`、`附件一:三方比价定标表.docx` | 内控测试 |
| E15 | 供应商推荐 | 新增供应商推荐需要填写哪些信息,谁来审批? | `附件二:供应商推荐表.xlsx`、`采供内容补充.pptx` | 内控测试 |
| E16 | 服务采购 | 保洁、安保、绿化、特种设备服务合同履约中,项目要盯哪些验收点? | `日常保洁服务合同-202309版.docx`、`日常安保服务合同-202309版.docx`、`园林绿化养护合同-202309版.docx`、`特种设备养护合同-202309版.docx` | 内控测试 |
| E17 | 印章管理 | 社区活动要盖章,生活顾问或项目应该怎么申请和留痕? | `南京银城物业服务有限公司印章管理办法.pdf` | 行政制度 |
| E18 | 公车管理 | 项目借用公车或发生车辆事故,需要注意哪些审批和登记? | `南京银城物业服务有限公司公车管理制度.pdf` | 行政制度 |
| E19 | 档案管理 | 项目档案归档、借阅、移交时有哪些要求? | `南京银城物业服务有限公司档案管理制度.pdf` | 行政制度 |
| E20 | 信息安全 | 生活顾问能不能泄露客户资料或隐私,哪些行为属于价值观红线? | `人事规章制度修订-BI规范&人事规章制度.pptx` | 红线测试 |
| E21 | 员工自助 | 员工自助系统里常见人事、考勤事项应该怎么操作? | `员工自助操作手册2025_10.pdf` | 员工支持 |
| E22 | 职业安全 | 一线员工在现场作业中有哪些职业安全要求? | `员工职业安全(潘晓丽)25.3.13.pptx` | 员工支持 |
| E23 | 租售业务 | 生活顾问遇到业主咨询租售业务时,如何记录和跟进? | `租售业务培训(住宅)250305(1).pptx` | 非物业务 |
| E24 | 传统非物 | 社区发展中心传统非物业务有哪些,生活顾问怎么引导业主? | `社区发展中心传统非物业务知识2025.2.26.pptx` | 非物业务 |
## 锁题建议
1. 现场演示先从 Q01-Q12 中选 5 到 8 题,不要现场随机问未验证题。
2. Q01、Q02、Q05、Q10 适合串成“生活顾问的一天”主线。
3. Q03、Q04、Q06、Q08 适合展示直给式 SOP 问答能力。
4. E14-E20 适合业务方测试内控制度和红线问题,不建议全部放进发布会现场演示。
5. 待 27 个视频完成转写后,再补一版“视频微课专项题单”。
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

@@ -0,0 +1,252 @@
# 银城数字师傅产品方案(整合修订)
> 版本:v1.2 | 日期:2026-07-08
> 输入源:①客户方《物业数字师傅系统三期建设方案》(2026-07-08 早,下称"第三版")②《企业内部AI培训学习APP产品需求与发布会内容讨论》会议纪要(2026-07-08)③客户方会后微信补充(学模块课程化+考试;巡检点位转入职学习任务)。
> v1.1 重构:按"产品是什么 → 发布会做什么 → 决策记录 → 执行"四部分分层,产品定义与发布会演示不再混写。
> v1.2 更新(2026-07-08,客户回复三项待确认):①多端跳转议题**暂缓**,现阶段只保证能打开小程序页面;②工作积分排行榜**保留功能、可选择不用**(开关配置);③员工评价管理团队**按组织架构与权限控制**,需有权限才能评价。详见 §3.3。
> 与《银城员工端APP分阶段实施总纲》《AI陪练二期开发推进计划》的关系:本方案是增量修订,不推翻既有阶段划分;§4.2 回写清单已于 2026-07-08 执行——总纲升 v0.3、二期计划升 v0.8(BRD 4.4 修订仍待落 v1.2)。
---
## 0. 一页总览
**产品是什么**:面向物业一线员工(首岗生活顾问)的企业内部 AI 知识库与培训工具。员工每天用它做四件事——**学**(课程与考试)、**练**(AI 对手戏)、**问**(直给式 SOP 问答)、**报**(上报案例)——外加一个全员参与的**问题榜**;这些行为累计**学分**,学分决定晋升统考资格。远期演进为员工端"专业 AI 师傅"(公告、工单、报事报修全流程)。
**发布会做什么**:半年会上的一次对外亮相,面向区域总及以上管理层,用**「生活顾问的一天」**做主线演示。只现场演示已稳定的功能(问答、对练),问题榜/学模块作规划展示,拍照识别不承诺。
**两者的关系**:发布会是产品的营销切片,不是产品范围本身。产品按总纲三阶段推进;发布会只决定"哪天、给谁、演示哪一段"。
---
# 第一部分 产品:银城数字师傅是什么
## 1.1 定位与用户
| 项 | 口径 |
|---|---|
| 当前定位 | 企业内部 AI 知识库与培训工具(会议定案:定位分步走的第一步) |
| 远期定位 | 员工端"专业 AI 师傅":集合公告推送(AI 提取"跟我相关")、工单巡检、报事报修全流程——对应总纲阶段二/三,不拉入当前主线 |
| 首要用户 | 生活顾问(约 800 人,80% 以上为初级) |
| 延伸用户 | 项目上其他岗位(保安、保洁主管、工程等)——"只要想学习、想晋升,就提供机会" |
| 管理用户 | 主管(复盘、预警、派发)、HR/运营(内容、激励规则、评级) |
## 1.2 产品功能全景
自上而下五层;"状态"列区分已有/在建/规划,是排期与承诺的依据。
| 层 | 模块 | 给员工解决什么 | 关键功能 | 状态 |
|---|---|---|---|---|
| 入口 | 载体与架构 | 一个入口找到所有工具 | 现阶段只保证能打开目标小程序页面(最小跳转);企微统一入口/多端架构暂缓再议 | 暂缓(3.3) |
| 日常四模块 | **学** | 该会的知识按计划学、按要求考 | 岗位学习计划、入职任务包、文档/视频课件(防快进、最短阅读时长)、不定期考试(有截止时间) | 本期新建(最小集) |
| | **练** | 每天 5 分钟,跟 AI 业主实战演练 | 场景对练(AI 控制生成)、语音对手戏、回复语气调节按钮、每日任务派发 | 已有主链,本期增强 |
| | **问** | 现场遇事,一问就有能照做的答案 | 直给式问答(结论+一二三四五步骤)、语音提问、答案打勾/打叉、视频链接直出 | 已有,本期改格式 |
| | **报** | 我的经验被看见、被沉淀 | 案例上报(文字+拍照+语音)、审核入库、署名计分 | 已有雏形,本期规范 |
| 共创层 | 问题榜 | 遇到难题有人帮,答题有奖 | "今天遇到什么难题"+"有奖征问题"双入口、全员可答、最佳答案置顶、沉淀入库 | 阶段二(本期埋 knowledge_gap 日志) |
| 成长层 | 学分与晋升 | 努力可积累,晋升有通道 | 学分规则(可配置)、初/中/高级成长进度、E1-E8 远期框架、学分达标→统考资格 | 本期最小集 |
| 地基 | 知识库 | 一切模块的内容来源 | 全业态资料入库、多格式解析、混合检索、纠错闭环、分级访问 | 已有,2026-07-08 当周补资料 |
## 1.3 各模块设计
### 1.3.1 学——标准学习计划与知识课程(本次最大增量)
对标大厂企业学习考试平台,最小可用集:
| 能力 | 要求 |
|---|---|
| 学习计划 | 按岗位安排标准学习计划;新员工入职自动挂**入职学习任务包**(首包:生活顾问日常巡检要点——楼道、停车场、天台各点位关注什么,素材由业务侧从巡检标准拆分) |
| 课程内容 | 文档 + 视频两种课件;复用知识库上传解析链路 |
| 防快进 | 视频**不能快进**:禁用拖拽跳转,只允许回看(H5 播放器监听进度,超出已观看位置回拉) |
| 阅读时长 | 文档设**最短打开时长**,达到才计学完(前端计时+页面可见性校验,后端存进度) |
| 考试 | 学完可考;**不定期学习+不定期考试**;考试有**截止时间**;成绩留档;题库先由业务侧配套供题,后续 AI 辅助出题 |
| 学分 | 完成课程/通过考试计学分(见 1.3.6) |
范围控制:不做直播、线下签到、课程市场;只支撑"岗前课程 + 每日小训 + 不定期考试"三种形态。
### 1.3.2 练——每日 5 分钟 AI 对手戏
- 每天 **5 分钟训练**,系统推送进"今日安排"(与新员工每日三题统一走 `source='daily'` 派发口径);
- 场景由 AI 主动控制生成(收费时遇到 A/B/C/D 类业主等),业务侧**不需要**编写对话脚本,只需保证知识库资料充足——会议已明确此分工;
- **回复调节按钮**:AI 给出方案后,提供"再严肃 / 再温柔 / 更细致 / 更专业"等按钮,点击按该方向重新生成。这是"第三版"七维偏好体系的首期落地形态;偏好自动记忆、千人千面为远期。
### 1.3.3 问——直给式 SOP 问答
- 输出格式**直给**:先给结论,再按 1/2/3/4/5 步骤给操作指引(如报销:打开金蝶→选报销名录→填科目→……),不推大段原文;引用片段后置供核对。"物业人直给直拿"是明确验收口径;
- 视频类流程(如采购平台操作)答案直接带视频链接;
- **打勾/打叉**:每条 AI 答案可标好/差;打叉的知识条目在该问题上后续**不再推送**,并进后台待复核清单——解决"错误知识反复出现"的历史问题;
- 悬赏问答不在问模块内,独立为问题榜(1.3.5)。
### 1.3.4 报——案例上报与经验沉淀
- 上报"今天处理了什么事、怎么处理的",支持文字+拍照+语音;目标**每个生活顾问每月至少 1 个有价值案例**;
- 后台审核,采纳入库计学分并署名;与问题榜优质回答共同构成"行业经验知识库"原料;
- "报"字面易与报事报修歧义,对外文案待定名,功能先行。
### 1.3.5 问题榜——师带徒的替代方案
会议明确否决定向师带徒(80% 以上生活顾问为初级、无带徒岗、无额外报酬,强推造成误解),替代设计:
| 设计项 | 内容 |
|---|---|
| 双入口 | ①**"今天遇到什么难题了?"**——真实困难求助;②**"有奖征问题"**——悬赏征集好问题 |
| 回答开放度 | **全公司所有人可答**(含管理层);不同层级答案分类呈现 |
| 激励 | 提问、回答均有奖励;后台筛选**最佳答案置顶**;优质问答沉淀入库并署名 |
| 经验萃取 | 前期邀请优秀生活顾问对高频问题做经验萃取,喂入 AI |
| 定位 | "广谱师傅":想帮带的人通过答题帮带——留痕、可奖励、无固定关系绑定 |
与总纲阶段二"新问题挂榜共创"是**同一件事**,本次定案其产品形态;落地节奏不变(阶段一只埋 `knowledge_gap` 日志,不做挂榜 UI)。
### 1.3.6 成长体系——学分、等级、晋升对接
**学分链路(会议定案)**:
```
日常行为(训练完成 / 回答被采纳 / 案例被采纳 / 课程考试通过)
→ 累计学分(规则后台可配置)
→ 学分达标 = 获得晋升统考资格
→ 线下统考 + 主管/HR 评定 = 晋升结论
```
- 学分只是**资格门槛**,晋升结论由统考+人工确认——与 BRD G1(AI 辅助参考、人可否决)和二期计划"评级证据包"口径一致;
- 等级框架:当前落地**初/中/高级生活顾问**三档(二期计划 M4);"第三版"E1-E8 作远期框架,其中"带过 N 名徒弟"晋升条件删除,替换为"问题榜优质回答/案例入库 N 条";"破格晋升"保留为人工通道;
- **激励规则配置中心**("第三版"带徒福利引擎的改造保留):学分来源、分值、悬赏金额、徽章条件均后台可配,不写死代码;
- 防刷分:学分只认"被采纳/通过考试"等有审核动作的行为;纯活跃行为给悬赏/徽章不给学分;学分与悬赏在 `incentive_point` 内分类型分账。
### 1.3.7 知识库地基——资料治理口径(会议确认)
| 项 | 口径 |
|---|---|
| 范围 | 全公司**全业态**各职能"应知应会":最新制度、业务手册、流程资料(含财务报销、行政等非业务条线) |
| 时效 | 只放**现行有效**版本;废弃制度不放;已确认错误的内容入库前删除 |
| 已完成 | 2022 版生活顾问手册初筛:已删智慧美家、享乐一安等停办业务及离职人员错误内容;业务类存疑项待严总复筛 |
| 格式 | PDF/文字/PPT/语音/视频均可;**业务侧不需要预处理颗粒度**(现有解析链路已支持全部格式) |
| 保密 | 涉密资料不入库;账号分级访问(知识分级是新增工程需求,阶段二前评审) |
| 纠错 | 上线后靠打叉反馈+后台复核持续清洗 |
### 1.3.8 载体与架构
- 不整合成一个重 APP(改一处影响全局、升级不便);**企业微信统一入口**,点模块跳转独立小程序/H5;培训 APP 是其中一端;
- 对 D6 的影响:2026-07-08 客户回复**多端跳转议题暂缓**——现阶段只保证能打开某个小程序的页面(最小跳转能力),不做统一入口工程;D6 维持 H5 套壳表述,统一入口待后续再议(3.3);
- 电子名片:独立产品,不进本方案范围。
---
# 第二部分 发布会:半年会演示方案
## 2.1 目的与观众
- 场合:半年会产品发布(总纲既定硬死线,须带一线真实使用数据);
- 观众:几十位**区域总及以上**管理层,少量获收费奖项的生活顾问/项目经理;
- 目的:让管理层看到"这个产品能实际减轻生活顾问和项目经理的负担",为 9 月收费季前试点铺路。
## 2.2 演示主线:「生活顾问的一天」
三个候选框架(新人到高手 / 生活顾问的一天 / 晋升通道)中定案**「生活顾问的一天」**——最贴近实操、最容易让管理层代入。五幕动线:
| 幕 | 一天中的时刻 | 演示动作 | 对应模块 |
|---|---|---|---|
| 1 | 上班前 5 分钟 | 今日安排推送每日训练任务,完成一轮微训 | 练 |
| 2 | 晨会/巡检前 | 打开岗前课程,查看"楼道巡检关注点"学习任务 | 学 |
| 3 | 现场遇事 | 语音提问 SOP,得到一二三四五直给步骤 | 问 |
| 4 | 收费/投诉现场 | AI 扮演业主实战对练,展示语气调节按钮 | 练 |
| 5 | 下班前 | 上报今日处理的一个案例,学分到账 | 报 |
## 2.3 展示分级
发布会内容分三档,**只有第一档做现场实操**:
| 档 | 内容 | 理由 |
|---|---|---|
| **现场实操演示** | 问(直给问答)、练(对练+调节按钮)——即幕 1/3/4 | 已稳定或 2026-07-10 前可稳定;演示问题**提前预设并验证** |
| **规划展示(讲即可)** | 学模块(课程/考试)、问题榜、学分晋升链路——幕 2/5 可用界面稿或最小页面走过场 | 功能在建/在阶段二,不做现场输入交互 |
| **不提/不承诺** | 拍照识别巡检点位数值、扫码点位问答、大喇叭推送 | 会议结论"先喂足资料看效果,后续再推进";若发布会前验证稳定可作彩蛋,不写进流程 |
## 2.4 准备与兜底
- 演示问题全部**提前预设**,每题验证输出符合预期后锁定;
- 止损口径沿用总纲:保"陪练 + 总结卡 + 1 条示范视频";
- 录屏兜底沿用一期做法:现场网络/模型异常即切录屏。
---
# 第三部分 决策记录
## 3.1 本次定案汇总(会议+微信补充)
| # | 议题 | 结论 |
|---|---|---|
| 1 | 产品定位 | 第一步=企业内部 AI 知识库与培训工具;远期=员工端专业 AI 师傅 |
| 2 | 四模块 | 学/练/问/报;学=专业学习与考试,练=每日 5 分钟 AI 互动,问=查 SOP 话术,报=上报案例 |
| 3 | 学模块 | 课程化+防快进+阅读时长+不定期考试(有截止时间),参考大厂平台 |
| 4 | 巡检知识 | 作入职学习任务进学模块;扫码/拍照识别后置 |
| 5 | 师带徒 | 定向师带徒**不做**,替换为开放问题榜 |
| 6 | 答案反馈 | 打勾/打叉,打叉不再推送;练模块回复可按钮调节重新生成 |
| 7 | AI 回答格式 | 直给:一二三四五步骤,不推大段资料 |
| 8 | 晋升对接 | 学分绑定统考资格;统考保留线下 |
| 9 | 知识库资料 | 全业态现行版;错误内容删除;多格式均收;不需业务侧调颗粒度;涉密不入库、账号分级 |
| 10 | 架构 | 企微统一入口+多端跳转,不做单一重 APP;电子名片独立 |
| 11 | 发布会主线 | 「生活顾问的一天」 |
| 12 | 当周排期 | 2026-07-08(周三)交资料 → 07-10(周五)测试版 → 07-10~12 业务方测试 |
## 3.2 "第三版"逐项处置对照
| "第三版"内容 | 处置 | 说明 |
|---|---|---|
| 一期·岗位工作量化体系(对接 ERP/工单/财务) | **后置** | 依赖外部系统对接,归总纲阶段三节奏;画像先用训练侧数据 |
| 一期·E1-E8 等级体系 | **修正采纳** | 删带徒条件;当前落初/中/高三档,E 级为远期框架(1.3.6) |
| 一期·工作积分实时排行榜 | **保留为可选功能** | 2026-07-08 客户回复:保留功能、可选择不用——做成开关配置项,默认关(3.3) |
| 一期·提问精调七维标签+偏好记忆 | **裁剪采纳** | 首期只做调节按钮+打勾打叉;偏好自动记忆、千人千面为远期 |
| 二期·师徒传承整章(师傅池/双向盲选/带徒福利/师徒PK) | **替换** | 会议否决;由问题榜承载(1.3.5);二期计划"主管传帮带工作台"不受影响,继续保留 |
| 二期·可配置福利引擎 | **改造保留** | 平移为激励规则配置中心(1.3.6) |
| 三期·纯AI陪练四角色(教练/业主/评委/分析师) | **采纳为演进方向** | AI业主+AI评委已有;AI教练≈定向再练+每日派发;AI分析师≈画像聚合;按二期计划 M4 演进,不另起炉灶 |
| 三期·游戏化五要素 | **最小集采纳** | 学分+徽章+等级进度条;排行榜缓;BOSS挑战/师徒副本不进当前范围 |
| 补充·语音优先/5分钟法则/情景代入 | **采纳** | 与现有设计一致,作体验原则贯穿 |
| 补充·学习小组/对练PK/社交化 | **缓** | 个人闭环验证后再评估 |
| 补充·专属AI师傅人设 | **部分已有** | 员工端已是数字师傅形态;"主动关心"推送依赖触达通道(§4.3) |
| 补充·荣誉墙/成长档案导出 | **采纳(后置)** | 证据包导出已在二期计划 M4/M5;荣誉墙随徽章落地 |
## 3.3 待确认项处置(2026-07-08 客户已回复)
| 项 | 原冲突/依赖 | 客户回复(2026-07-08) | 落地口径 |
|---|---|---|---|
| 多端跳转架构 | 王总此前要求整合单一 APP | **暂时忽略多端跳转议题**,现阶段只保证能打开某个小程序的页面 | 不做统一入口工程;先验证"从当前 H5/企微打开目标小程序页面"的最小跳转能力;D6 表述维持 H5 套壳不动,统一入口待后续再议 |
| 工作积分排行榜 | 与既定"不做同侪排名、先自我对比"口径冲突 | **保留功能,可以选择不用** | 排行榜作为**可开关配置项**开发(默认关);上线与否由运营按项目选择,既定"先自我对比"仍是默认态 |
| 员工评价管理团队 | 组织风险(总纲已列敏感项闸门) | **以组织架构、权限为依据,需权限才能评价** | 不再是"待独立评审的敏感项闸门",转为**权限控制的正常功能**:依组织架构 + 权限模型(复用 `role_perm_mapping`/D1 数据权限)控制谁可评价;仍属阶段二范围 |
---
# 第四部分 执行
## 4.1 当周排期(2026-07-08 ~ 07-12)
| 时间 | 事项 | 责任 |
|---|---|---|
| 2026-07-08(周三)下班前 | 知识库资料交付(全业态现行制度/手册/流程) | 业务方;严总补业务端并交叉核对 |
| 07-08 ~ 07-10 | 资料批量导入解析;`sop_answer` 直给格式改造;预设演示问题走查 | 开发方 |
| 2026-07-10(周五) | 第一个可测试版本(与二期计划 Alpha 锚点重合) | 开发方 |
| 07-10 ~ 07-12 | 业务方实测:问答准确性、直给格式、对练体验,反馈问题清单(可请生活顾问参测) | 业务方 |
| 半年会前 | 按 §2 锁定演示脚本与预设问题;录屏兜底 | 双方 |
## 4.2 文档回写清单
| 增量 | 落点 | 动作 |
|---|---|---|
| 学模块(课程/防快进/阅读时长/考试) | 总纲阶段二"知识学习平台" | 总纲 §4/§5 增补;出学模块专项设计(表:课程、课件、学习进度、考试、成绩);岗前课程最小版可视资源提前到阶段一试点后 |
| 问答直给格式 | 二期计划 M1 提示词模板体系 | 新增 `sop_answer` 模板(结论先行+编号步骤+引用后置),随 2026-07-10 测试版调整 |
| 答案打勾/打叉 | 新机制 | 新表(答案反馈);检索融合对打叉条目降权/屏蔽;后台待复核清单 |
| 练模块调节按钮 | 二期计划 M2 | turn 接口支持 style 重生成参数,模板化注入 |
| 问题榜形态定案 | 总纲阶段二"新问题挂榜共创" | 用 1.3.5 更新总纲对应条目;阶段一继续只埋 `knowledge_gap` |
| 师带徒否决 | 二期计划 §2 暂缓表、BRD 4.4 | "师徒演练暂缓"升级为"定向师带徒不做,由问题榜承载";BRD 同步修订 |
| 学分/激励规则配置中心 | `incentive_point` 体系 | 学分与悬赏分账;规则可配置;统考资格口径写入 M4 成长路径 |
| 知识分级权限 | 知识库 | 账号等级 × 知识密级访问控制,新增工程需求(阶段二前评审) |
| 载体口径 | 总纲 §7、D6/D8、§8 | 2026-07-08 定为暂缓:D6 维持 H5 套壳;只保证小程序页面可打开(3.3),总纲 §8 该行已更新 |
## 4.3 风险
| 风险 | 控制 |
|---|---|
| 资料质量(错误知识入库) | 只收现行版+入库前删已知错误+打叉反馈闭环;业务复筛不阻塞导入,先导后清 |
| 学模块范围膨胀 | 只承诺 1.3.1 最小集;直播/课程市场/线下签到明确不做;完整 LMS 在阶段二 |
| 两天出测试版极紧 | 测试版验收聚焦"资料导入+问答直给";调节按钮/打叉允许迟于 07-10 分批上;演示兜底录屏 |
| 触达通道 | "主动关心"推送、考试截止提醒依赖企微通道(D8 只列不实现)→ 阶段二开工前决策;当前用站内今日安排承载 |
| 学分刷分 | 只认有审核动作的行为计学分;纯活跃给悬赏/徽章不给学分 |
| 口径冲突已收敛 | 3.3 三项已获客户回复并落口径:多端暂缓、排行榜可选、评价走权限;剩余敞口=统一入口后续再议 |
+52 -38
View File
@@ -1,6 +1,6 @@
# AI 陪练二期开发推进计划
> 版本:v0.7 | 日期:2026-07-07
> 版本:v0.8 | 日期:2026-07-08
> 输入源:《AI陪练功能优化与缺口分析(修订版 · 2026-07-06)》+《物业行业 AI 人力资源系统 · BRD v1.1》。
> v0.2 修订:与两份输入源逐项复核后补齐 6 处缺漏——#6/#9/#16/师徒演练的明确处置(§2)、#13 定向再练的里程碑落点(M4)、画像雷达图与 BRD L1「留」段验收承载(M4)、TechSpec P1 表映射与岗位身份前置(M1)、场景管理权限口径(M1/§7)、G6 成本上限量化(§7)。
> v0.3 修订:补齐管理者“传帮带”角色和初/中/高级生活顾问成长路径;训练数据只作晋升评级证据包,不让 AI 自动决定晋升。
@@ -8,6 +8,8 @@
> v0.5 修订:理顺 4 处表述——师徒暂缓与 M4 传帮带的边界互指、总结卡"现在效果不好"补第 1 周快速修复(不等模板 CRUD)、预习卡纳入模板绑定清单、§8 登记 M1/M4 增量超载风险。
> v0.6 修订:并入 2026-07-07 立项会结论(见《银城员工端APP分阶段实施总纲》)——本计划升格为总纲**阶段一**的执行文档;时间锚点对齐 Alpha/Beta/半年会/收费季;收费场景权重上调;新增新员工每日三题;埋 `knowledge_gap` 钩子;岗位术语待 M0 对齐。
> v0.7 确认:客户方确认"管家"="生活顾问"(同一岗位,术语统一为"管家(生活顾问)",`position` 标签不迁移);APP 形态定为 H5 套壳(维持 D6)。M0 两项待确认项关闭。
> v0.8 修订:并入 2026-07-08 产品讨论结论(见《数字师傅学练问报整合方案 v1.1》,docs/20260708/)——①M1 新增 `sop_answer` 直给格式模板(结论先行 + 一二三四五编号步骤 + 引用后置),随 2026-07-10 测试版上线;②M2 新增对练回复调节按钮(turn 支持 style 重生成)与问答答案打勾/打叉反馈;③师徒演练暂缓升级为**定向师带徒不做**,由阶段二开放问题榜承载(主管传帮带工作台不受影响);④M4 成长路径补学分/统考资格口径。
> v0.9 同步:正式试点导出和严格门禁已完成工程收口。两者均强制批次起止日、唯一在职组织身份和每人至少 10 次完训;校准/SOP 门禁以原始计数判定,不以四舍五入后的比率反推。当前仍等待上游提供至少 20 名可登录正式试点人员及业务评审,不将工程就绪写成业务验收通过。
> 结论:二期不做“24 个缺口全量铺开”,先把 AI 陪练从 MVP 演示闭环推进到**生活顾问岗位试点可用**。
## 1. 二期目标
@@ -48,9 +50,9 @@
| 图片/视频情境输入(#9) | BRD 4.3.3 明列(拍照报修),vision 模型已就绪;但场景素材是瓶颈,待 12 条文本场景跑稳后独立评估,可与 B3 文生图联动 |
| 环境噪音模拟(#6) | 前端混音成本不高但非试点闭环必需;噪音音源授权未解决 |
| 自动案例沉淀(#16) | 挂 BRD 4.6/4.7 共创与 review_task 审核链;二期只保证"贡献度"有人工计分入口(见 M4),自动沉淀待案例量起来再评估 |
| 师徒演练(BRD 4.4) | 暂缓的是**真人师父指派体系**(`mentor_assignment` P1 表与师父角色运营);“传帮带”最小语义不暂缓,由 M4 主管传帮带工作台承载(看回放→写复盘建议→派下次训练) |
| 师徒演练(BRD 4.4) | **升级为不做(2026-07-08 会议否决)**:定向师带徒不实现——80% 以上项目生活顾问为初级、无带徒岗位设置、无额外报酬,强推造成误解;`mentor_assignment` P1 表与师父角色运营取消。帮带语义由两处承载:①M4 主管传帮带工作台(看回放→写复盘建议→派下次训练)不受影响,继续做;②员工间帮带由阶段二**开放问题榜**承载(全员可答、最佳答案置顶、有奖,见总纲 §4.4)。BRD 4.4 待同步修订 |
| 动态难度调级(#1 后半 / BRD 4.5) | 场景表保留 difficulty 字段即可;自动升降级依赖画像数据稳定,分发规则 v1 不做 |
| 大喇叭信息平台、知识学习平台(分身份文件摘要)、品检翻转、新问题挂榜 UI、北森对接 | 立项会新增需求,归属总纲**阶段二/三**(见《银城员工端APP分阶段实施总纲》);本阶段只埋 `knowledge_gap` 钩子 |
| 大喇叭信息平台、知识学习平台(学模块课程/考试与分身份文件摘要)、品检翻转、开放问题榜 UI、北森对接 | 立项会及 2026-07-08 产品讨论新增需求,归属总纲**阶段二/三**(见《银城员工端APP分阶段实施总纲》§4.4/§4.5);本阶段只埋 `knowledge_gap` 钩子 |
## 3. 技术路线
@@ -113,6 +115,7 @@
第 1 周随手做(不等模板 CRUD 上线):
- 总结卡“现在效果不好”先做内置 prompt 快速修复:把总结卡生成 prompt 硬编码为固定结构(参照督导图片分析“问题:建议:标准:”三段式的做法,定字段、定字数、定顺序),消除纯自由发挥。后续模板管理上线后,该内置 prompt 转为 `summary_card` 模板的默认版本,不白做。
- SOP 问答直给格式改造(2026-07-08 客户验收口径,随 2026-07-10 测试版上线):问答输出改为**结论先行 + 一二三四五编号操作步骤 + 引用片段后置**,不推大段原文;涉及流程视频的答案直接带视频链接。落为 `sop_answer` 提示词模板;“物业人直给直拿”是明确验收标准。
后端:
@@ -127,17 +130,17 @@
- `frontend/src/api/aihr/practice.ts`:补场景/Rubric API。
- `frontend/src/views/train/practice.vue`:先加轻量场景选择和 Rubric 预览,不做复杂 CRUD。
- 提示词模板管理可先挂在“系统设置-模型配置”下:支持名称、代码、分类、输出格式、系统提示词、提示词模板、描述、启用状态;模板变量用 `{{变量名}}`。
- 提示词模板管理已挂在“系统设置-模型配置”下:支持名称、代码、分类、输出格式、系统提示词、提示词模板、描述、启用状态;模板变量用 `{{变量名}}`。
移动端:
- `mobile/src/App.vue`:“开始训练”根据岗位/今日安排传 `scenarioId`,没有数据时回退当前 seed。
- `mobile-uni/src/pages/user/today/index.vue` / `mobile-uni/src/pages/user/practice/index.vue`:“开始训练”根据岗位/今日安排传 `scenarioId`,没有数据时回退当前 seed。
验收:
- 管理端能选择不同生活顾问场景并开始对练。
- API 可返回场景绑定的 SOP、Rubric、persona。
- 总结卡、导师改写、逐句标注、训练前预习卡(M2 新增文体,提前登记)、图片督导分析等生成能力必须能绑定固定模板;未配置时回退内置模板并记录 `prompt_version`。
- 总结卡、导师改写、逐句标注、训练前预习卡(M2 新增文体,提前登记)、SOP 问答直给(`sop_answer`)、图片督导分析等生成能力必须能绑定固定模板;未配置时回退内置模板并记录 `prompt_version`。
- 总结卡内置 prompt 已完成固定结构改造,输出可稳定拆分展示(第 1 周快速修复项)。
- 模型失败或表为空时,原 MVP 对练仍可跑。
- 场景 CRUD 接口挂 `hr_operator` 角色权限(BACKLOG B1“出题”职责),普通员工端只读。
@@ -151,9 +154,11 @@
- 训练前预习卡:从场景 SOP 生成“要点 / 红线 / 可说话术”,生成绑定 `prep_card` 提示词模板(固定三栏结构,不自由发挥)。
- 每轮 emotion 由 LLM 输出并落入 turn/session,不再只用 seed 锚点。
- 求助按钮:训练中弹出该场景 SOP 提示,记录一次求助事件。**求助/问答未命中知识库时记 `knowledge_gap` 日志(问题、岗位、时间)——为总纲阶段二"新问题挂榜共创"蓄水,本期不做挂榜 UI。**
- 求助按钮:训练中弹出该场景 SOP 提示,记录一次求助事件。**求助/问答未命中知识库时记 `knowledge_gap` 日志(问题、岗位、时间)——为总纲阶段二"开放问题榜"蓄水,本期不做挂榜 UI。**
- 主动打断:COACH_CHECK 命中红线时中断本轮,提示纠正后重来。
- 方言/多语气:先支持 `voice_profile.speed/emotion/dialect`,不做语音克隆。
- 对练回复调节按钮(2026-07-08 新增):AI 给出回复后,提供“再严肃 / 再温柔 / 更细致 / 更专业”等按钮,点击按该方向重新生成——turn 接口支持 `style` 重生成参数,风格指令模板化注入;是客户"第三版"七维偏好体系的首期落地形态,偏好自动记忆/千人千面为远期,不在本期。
- 问答答案打勾/打叉(2026-07-08 新增):SOP 问答每条答案可标好/差;打叉的知识条目在该问题上后续**不再推送**(检索融合降权/屏蔽),并进后台待复核清单——错误知识纠错闭环。新增答案反馈表。允许迟于 2026-07-10 测试版分批上线。
文件落点:
@@ -161,7 +166,7 @@
- `AihrPracticeSeedService.java`:seed fallback 也返回同样字段。
- `AihrSpeechDto.java` / `AihrSpeechService.java`:扩展 TTS voice 参数,保留旧字符串兼容。
- `frontend/src/views/train/practice.vue`:加预习卡、情绪条、求助按钮、红线提示。
- `mobile/src/App.vue`:复用训练前卡和求助逻辑,语音失败仍退文本。
- `mobile-uni/src/pages/user/practice/index.vue`:复用训练前卡、求助和语音转写逻辑,语音失败仍退文本。
验收:
@@ -169,6 +174,8 @@
- 至少 3 条场景能看到情绪随话术变化。
- 求助按钮命中 SOP 并显示引用。
- 红线话术触发纠偏,但不在训练中做逐句评分。
- 对练回复可用调节按钮按指定风格重新生成;重生成不重复计分。
- 问答答案可打勾/打叉;打叉后同一问题再问不再出现该条目,后台可见待复核清单。
### M3. 录音回放与复盘标注(第 5 周)
@@ -186,7 +193,7 @@
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java`:保存 session 时写 audio/annotation 信息。
- 新增 `AihrPracticeReviewService.java`:生成和查询逐句标注。
- `AihrMobileController.java`:复盘详情返回 audio 和 annotations。
- `mobile/src/App.vue`:主管复盘详情增加回放和逐句标注区。
- `mobile-uni/src/pages/supervisor/index/index.vue`:主管复盘详情增加回放和逐句标注区。
验收:
@@ -204,8 +211,8 @@
- 员工端画像以雷达图 + 趋势呈现单场四维分聚合(BRD 4.5 可视化口径,数据留跨期 `competency_assessment` 的入口)。
- 主管端预警:连续低分、超期未训、分数下滑。
- 批量训练任务派发:主管按岗位/短板派发专项。
- 管理者“传帮带”工作台:主管从画像/预警进入员工详情,查看回放与标注,写复盘建议,派发下一次训练,形成“看见问题→带教→复训”的闭环(即 BRD 4.4 师徒演练的二期承载形态,边界见 §2 暂缓表;真人师父指派体系仍暂缓)。
- 初/中/高级生活顾问成长路径:把场景难度、完训次数、人工校准分、主管复盘、案例贡献汇总成职级评级证据包;AI 等级只作参考,晋升结论由主管/HR 确认。
- 管理者“传帮带”工作台:主管从画像/预警进入员工详情,查看回放与标注,写复盘建议,派发下一次训练,形成“看见问题→带教→复训”的闭环(主管侧帮带承载形态,边界见 §2 暂缓表;定向师带徒已定不做,员工间帮带归阶段二开放问题榜)。
- 初/中/高级生活顾问成长路径:把场景难度、完训次数、人工校准分、主管复盘、案例贡献汇总成职级评级证据包;AI 等级只作参考,晋升结论由主管/HR 确认。**学分口径(2026-07-08 定案)**:训练完成、回答被采纳、案例被采纳、课程考试通过累计学分,**学分达标 = 晋升统考资格,统考保留线下**——学分只是资格门槛,与 G1"人可否决"一致;学分与悬赏奖励在 `incentive_point` 内分类型分账,只认"被采纳/通过考试"等有审核动作的行为计学分(防刷分);激励规则(学分来源、分值)走后台可配置,不写死代码。客户"第三版"E1-E8 等级为远期框架(带徒晋升条件已删),当前落地仍为初/中/高三档。
- 定向再练(#13):低分维度自动生成 `source='retry'` 的待训任务,员工“今日安排”可见——错题本语义的最小实现。
- 新员工每日三题(立项会):系统识别新员工(入职 3 个月内),每日派发 `source='daily'` 的 3 道微题(题干+参考答案+AI 秒评,轻量内容形态,区别于 12 条重对练场景);目标 3 个月覆盖 100 个常见场景。微题库内容由业务侧从管家培训材料拆分供给。
- 人工评分校准:HR/主管可修正 AI 评分,形成校准集。
@@ -217,7 +224,7 @@
- `AihrMobileDto.java`:补画像、预警、派发 DTO。
- `AihrMobileController.java`:新增/扩展主管预警、派发接口。
- `frontend/src/views/train/practice.vue` 或新增管理端页:提供人工校准入口。
- `mobile/src/App.vue`:员工画像和主管待办展示新增字段。
- `mobile-uni/src/pages/user/profile/index.vue` / `mobile-uni/src/pages/supervisor/index/index.vue`:员工画像和主管待办展示新增字段。
验收:
@@ -243,6 +250,9 @@
- 住宅类 SOP 问答人工评审可用率目标 ≥80%。
- 语音单轮响应目标 ≤5 秒;超过时有文本降级。
- 试点数据可导出:训练次数、完训率、平均分、预警数、主管复盘率。
- 导出与严格预检必须指定同一批次起止日;只统计能唯一映射到在职组织的正式会话,明细保留正式身份和项目,并同时导出校准命中数、SOP 可用数及对应比率。
当前状态(2026-07-10):工程链路、真实 API 验证、回归和复审已完成;本地正式窗口 `2026-07-07..2026-07-10` 只有 1 名可登录正式人员、`0/1` 人达到十次完训、正式校准 `0/20`、已评审 SOP `2/2`,另有 119 条待评审。因此 M5 的**工程能力已就绪,正式业务试点未通过**。下一动作是上游修复/开放正式手机号和姓名编码后做 dry-run、同步,再按真实试点批次积累训练、校准和 SOP 评审证据。
## 5. 二期 API 建议
@@ -300,7 +310,7 @@
| 风险 | 控制 |
|---|---|
| 内容不足 | M0 先锁 12 条生活顾问场景,内容没到位不进 M2;**每日三题微题库依赖业务侧拆分管家培训材料,拆分没跟上则该项顺延、不阻塞主线** |
| 移动端过大 | M1 同步拆 `mobile/src/App.vue` 的训练/画像/主管面板,不继续堆单文件 |
| 移动端过大 | 当前新增功能落在 `mobile-uni/src/pages/*`,旧 `mobile/src/App.vue` 只保留演示兜底,不继续堆单文件 |
| 成本失控 | 逐句标注只在 finish 后一次性做;总结卡/视频按需触发 |
| 评分争议 | 原 AI 分和人工修正分并存;AI 分不挂绩效 |
| 隐私问题 | 只做录音,不做录像;文本脱敏;音频存储权限明确 |
@@ -330,10 +340,10 @@
**Steps**
- [ ] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。
- [ ] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。
- [ ] 写入 12 条生活顾问场景 seed。
- [ ] reset 后确认场景、rubric 和 prompt template 表有数据。
- [x] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。
- [x] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。
- [x] 写入 12 条生活顾问场景 seed。
- [x] reset 后确认场景、rubric 和 prompt template 表有数据。
### Task 2: 场景读取替换 seed
@@ -345,9 +355,9 @@
**Steps**
- [ ] `start` 优先按 `scenarioId` 读数据库场景。
- [ ] 数据库无场景时回退现有 seed。
- [ ] `turn/finish` 保持当前契约不破坏前端。
- [x] `start` 优先按 `scenarioId` 读数据库场景。
- [x] 数据库无场景时回退现有 seed。
- [x] `turn/finish` 保持当前契约不破坏前端。
### Task 3: 训练前卡和求助
@@ -355,14 +365,14 @@
- Modify: `frontend/src/api/aihr/practice.ts`
- Modify: `frontend/src/views/train/practice.vue`
- Modify: `mobile/src/App.vue`
- Modify: `mobile-uni/src/pages/user/practice/index.vue`
- Reuse: `POST /api/knowledge/search`
**Steps**
- [ ] 开始训练前展示场景预习卡。
- [ ] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。
- [ ] 求助失败显示明确错误,不兜假答案。
- [x] 开始训练前展示场景预习卡。
- [x] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。
- [x] 求助失败显示明确错误,不兜假答案。
### Task 4: 动态情绪和红线纠偏
@@ -371,13 +381,13 @@
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeLlmService.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java`
- Modify: `frontend/src/views/train/practice.vue`
- Modify: `mobile/src/App.vue`
- Modify: `mobile-uni/src/pages/user/practice/index.vue`
**Steps**
- [ ] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。
- [ ] 前端展示情绪条和红线纠偏。
- [ ] 解析失败时回退现有 coachHint,不阻断训练。
- [x] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。
- [x] 前端展示情绪条和红线纠偏。
- [x] 解析失败时回退现有 coachHint,不阻断训练。
### Task 5: 回放与逐句标注
@@ -386,13 +396,14 @@
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java`
- Modify: `mobile/src/App.vue`
- Modify: `mobile-uni/src/pages/user/practice/index.vue`
- Modify: `mobile-uni/src/pages/supervisor/index/index.vue`
**Steps**
- [ ] 对练音频写 `sys_oss`/MinIO 并关联 session。
- [ ] 主管复盘详情显示音频回放。
- [ ] finish 后生成逐句标注,刷新不重复生成。
- [x] 对练音频写 `sys_oss`/MinIO 并关联 session。
- [x] 主管复盘详情显示音频回放。
- [x] finish 后生成逐句标注,刷新不重复生成。
### Task 6: 画像与主管派发
@@ -401,15 +412,17 @@
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java`
- Modify: `mobile/src/App.vue`
- Modify: `mobile-uni/src/pages/user/today/index.vue`
- Modify: `mobile-uni/src/pages/user/profile/index.vue`
- Modify: `mobile-uni/src/pages/supervisor/index/index.vue`
**Steps**
- [ ] 画像补训练时长、贡献度、测评分、AI 等级。
- [ ] 主管端补预警列表。
- [ ] 主管可派发专项训练,员工端今日安排可见。
- [ ] 主管端可记录复盘建议并派发下一步训练。
- [ ] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。
- [x] 画像补训练时长、贡献度、测评分、AI 等级。
- [x] 主管端补预警列表。
- [x] 主管可派发专项训练,员工端今日安排可见。
- [x] 主管端可记录复盘建议并派发下一步训练。
- [x] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。
## 11. 二期完成定义
@@ -421,3 +434,4 @@
- 画像能支撑“下一步练什么”的规则分发。
- 系统能产出初/中/高级生活顾问评级证据包,但不自动给出晋升结论。
- 试点所需的合规、成本、人工校准底线已经留好。
- 二期技术闭环完成不等于一期业务验收完成;业务通过必须以 M5 的正式组织数据、训练记录和业务评审证据为准。
+28 -21
View File
@@ -8,15 +8,16 @@
|---|---|---|
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数和 SOP 可用数,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
| 移动端三端首页 `/h5/user`、`/h5/candidate`、`/h5/supervisor` | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化 |
| 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 |
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
## 后端落点
@@ -43,9 +44,9 @@ ALIYUN_SMS_SIGN_NAME=物业AI助手
| 能力 | 后端接口 | 处理 |
|---|---|---|
| 候选资料上传 | `POST /api/aihr/mobile/candidate/materials` | `multipart/form-data` 字段 `file`,参数 `candidateId/candidateName/materialType`;需移动端登录返回的 `Authorization` 与 `clientid`;文件用 `ISysOssService` 写 `sys_oss`/MinIO,关系写 `aihr_candidate_material`,状态默认 `待审核`,单文件 20MB |
| 候选资料列表 | `GET /api/aihr/mobile/candidate/materials?candidateId=` | 返回前 20 条 `{id, ossId, fileName, materialType, fileSize, status, time}`,供 `/h5/candidate` 的“已提交资料”展示 |
| HR 资料审核 | `GET /api/aihr/hr/candidate/materials?status=`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 管理端 `/recruit/interview` 展示前 50 条,可按状态筛选;审核请求体 `{status}`,只接受 `待审核/已通过/已驳回` |
| 候选资料上传 | `POST /api/aihr/mobile/candidate/materials` | `multipart/form-data` 字段 `file`,参数 `candidateId/candidateName/materialType`;需移动端登录返回的 `Authorization` 与 `clientid`;APP 用户场景后端强制使用登录手机号和派生候选人名,不信任前端传入身份;文件用 `ISysOssService` 写 `sys_oss`/MinIO,关系写 `aihr_candidate_material`,状态默认 `待审核`,单文件 20MB |
| 候选资料列表 | `GET /api/aihr/mobile/candidate/materials?candidateId=` | 返回前 20 条 `{id, ossId, fileName, materialType, fileSize, status, time}`,供 `/h5/candidate` 的“已提交资料”展示;APP 用户场景忽略传入 `candidateId`,只返回登录手机号对应资料 |
| HR 资料审核 | `GET /api/aihr/hr/candidate/materials?status=`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 管理端 `/recruit/interview` 展示前 50 条,可按状态筛选;审核请求体 `{status}`,只接受 `待审核/已通过/已驳回`;接口只允许管理端超级管理员或 `hr_operator` 角色访问 |
SOP 文档上传第三片已经落最小后端边界:
@@ -92,20 +93,23 @@ Qdrant 本地默认值可不配;需要覆盖时用 JVM property 或环境变
| 配置 | 默认值 | 用途 |
|---|---|---|
| `AIHR_ORG_SYNC_BASE_URL` / `-Daihr.org-sync.base-url` | 空 | 外部开放平台 `/open/v1` 前缀,例如 `https://hr.example.com/open/v1` |
| `AIHR_ORG_SYNC_BASE_URL` / `-Daihr.org-sync.base-url` | 空 | 外部开放平台 `/api/open/v1` 前缀,例如 `https://wuye.meihe.cc/api/open/v1` |
| `AIHR_ORG_SYNC_ACCESS_TOKEN` / `-Daihr.org-sync.access-token` | 空 | 已有 Bearer token;为空时尝试用 client credentials 换 token |
| `AIHR_ORG_SYNC_CLIENT_ID` / `-Daihr.org-sync.client-id` | 空 | 开放平台应用 `client_id`,也用于签名头 `X-Client-Id` |
| `AIHR_ORG_SYNC_CLIENT_SECRET` / `-Daihr.org-sync.client-secret` | 空 | `POST /auth/token` 换取访问令牌 |
| `AIHR_ORG_SYNC_SIGNING_SECRET` / `-Daihr.org-sync.signing-secret` | 空 | 可选 HMAC-SHA256 签名密钥;配置后请求带 `X-Timestamp/X-Nonce/X-Signature` |
| `AIHR_ORG_SYNC_SIGNING_SECRET` / `-Daihr.org-sync.signing-secret` | 空 | 可选 HMAC-SHA256 签名密钥;为空时使用 `AIHR_ORG_SYNC_CLIENT_SECRET`,业务请求带 `X-Client-Secret/X-Timestamp/X-Nonce/X-Signature`,签名 path 必须包含 `/api/open/v1` |
本地启动脚本会先加载根目录 `.env.local`,再加载 `backend/.env`。如果 `backend/.env` 沿用开放平台字段名 `client_id` / `client_secret`,脚本会映射为 `AIHR_ORG_SYNC_CLIENT_ID` / `AIHR_ORG_SYNC_CLIENT_SECRET`。
```bash
TOKEN=<登录后 access_token>
curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \
API_BASE=${API_BASE:-https://wygj-api.localhost}
curl -fsS "$API_BASE/api/aihr/org/sync" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"dryRun":true}'
curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \
curl -fsS "$API_BASE/api/aihr/org/sync" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"replaceExisting":true}'
@@ -126,17 +130,18 @@ curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \
- 资料处理页面:`frontend/src/views/knowledge/processing.vue`
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
- 模型配置页面:`frontend/src/views/system/model/index.vue`
- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
- 员工端训练:任务卡“开始训练”复用管理端三角色对练 API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
- 候选人端:`/h5/candidate` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示 OSS ID、资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
- 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;完成后记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
- 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
## 验收
```bash
./scripts/demo-check.sh
npm --prefix mobile run build
curl -fsS http://127.0.0.1:8080/api/aihr/mobile/home/user
npm --prefix mobile-uni run build:h5
API_BASE=${API_BASE:-https://wygj-api.localhost}
curl -fsS "$API_BASE/api/aihr/mobile/home/user"
npm --prefix frontend run lint:eslint -- src/api/aihr/interview.ts src/views/recruit/interview.vue src/api/aihr/practice.ts src/views/train/practice.vue src/api/aihr/case.ts src/views/knowledge/cases.vue src/api/aihr/sop.ts src/views/knowledge/sop.vue
mvn -f backend/pom.xml -pl ruoyi-admin -am -DskipTests package
```
@@ -145,9 +150,10 @@ mvn -f backend/pom.xml -pl ruoyi-admin -am -DskipTests package
```bash
TOKEN=<登录后 access_token>
curl -fsS http://127.0.0.1:8080/api/aihr/model/providers -H "Authorization: Bearer $TOKEN"
curl -fsS http://127.0.0.1:8080/api/aihr/model/configs -H "Authorization: Bearer $TOKEN"
curl -fsS -X POST http://127.0.0.1:8080/api/aihr/model/chat \
API_BASE=${API_BASE:-https://wygj-api.localhost}
curl -fsS "$API_BASE/api/aihr/model/providers" -H "Authorization: Bearer $TOKEN"
curl -fsS "$API_BASE/api/aihr/model/configs" -H "Authorization: Bearer $TOKEN"
curl -fsS -X POST "$API_BASE/api/aihr/model/chat" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"prompt":"物业管家处理漏水投诉第一步是什么?"}'
@@ -157,9 +163,10 @@ Qdrant 和向量索引可单独 smoke;重建接口会清空旧 embedding 并
```bash
TOKEN=<登录后 access_token>
API_BASE=${API_BASE:-https://wygj-api.localhost}
curl -fsS http://127.0.0.1:6333/
curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN"
curl -fsS -X POST http://127.0.0.1:8080/api/knowledge/doc/rebuild-vector-index -H "Authorization: Bearer $TOKEN"
curl -fsS "$API_BASE/api/knowledge/doc/vector-index-status" -H "Authorization: Bearer $TOKEN"
curl -fsS -X POST "$API_BASE/api/knowledge/doc/rebuild-vector-index" -H "Authorization: Bearer $TOKEN"
```
浏览器验收仍按 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) 的四条关键路径执行。
+1 -1
View File
@@ -98,7 +98,7 @@
### 零散待办
- **总结卡「保存图片」按钮**(设计基准 summary-modal 图内有):需 html2canvas 类依赖做 DOM 转图,2026-07-05 P0-2 实现时按"不加依赖"红线砍掉;引依赖或后端出图方案定型后补。
- **员工端「学」板块**(我的地图橙色块):当前进「建设中」整页,承接 B8 五段旅程的岗前课程/每日小训模块。
- **员工端「学」板块**(我的地图橙色块):已在 `mobile-uni/src/pages/user/learning/index.vue` 落成第一版,从今日任务、岗位 SOP 检索和训练前预习卡读取现有真实接口;后续再接课程/考试/视频学习数据。
## Future Enhancements
+18 -3
View File
@@ -1,6 +1,6 @@
# 一期 MVP 演示与验收清单
本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;移动端员工端、候选人端、主管端首页已拆到独立 `mobile/` 工程并按高保真原型实现,已接手机号短信登录,数据 API 优先请求 `GET /api/aihr/mobile/home/{role}` 并保留本地 fallback;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。
本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;用户侧三端已在 `mobile-uni/` 按真实页面栈重建,portless 入口为 `https://wygj-mobile-uni.localhost/h5/`,已接手机号登录、问师傅、员工训练、员工学习、案例素材、候选人面试/资料和主管复盘/派专项;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;候选人端面试复用 AI 面试 API,补充资料上传写 `sys_oss` 和 `aihr_candidate_material`,HR 可在管理端审核通过/驳回;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。
## 演示前预检
@@ -15,20 +15,35 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」,点“开始训练”完成两轮提交,再打开主管端;点快捷 chip 或「问」可文本/语音查 SOP,结果页点「生成总结卡·约10秒」弹出信息图卡(四步法/异议应对/开口话术/关键提醒+听一遍/开始跟读);候选人端点“开始面试/面试练习”和“补充资料”;管理端 `/recruit/interview` 审核候选资料 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我,切换回顶),滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页(返回首页按钮),无死按钮;主管端完训人数、“待复盘对练”计数和待复盘列表增加;训练页语音输入在浏览器录音失败时应切到“选择语音”而不是死按钮;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人端可拉题/答题/评分,资料上传后显示“已提交资料”、`OSS ...`、`待审核`;HR 审核后候选人端显示 `已通过/已驳回`;接口不可用时走本地 fallback |
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 |
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
## AI陪练二期增量脚本
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
试点正式验收前,用 `AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,日期替换为本批试点的真实起止日。闸门只统计该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。
2026-07-10 本地快照(时间窗 `2026-07-07..2026-07-10`):场景 `12/12`、开发活动身份 `50` 人,但达到 10 次对练者 `0` 人;窗口内能关联正式试点会话的校准样本为 `0/20`,因此分档一致率暂不可验收;已评审 SOP `2/2` 可用,另有 `119` 条待评审。严格预检当前**失败**于正式组织人员门槛:3001 名本地在职人员只有 1 名具备唯一可用手机号,活动数据也只有 1 人能唯一匹配正式组织,且该人员达到 10 次对练的数量为 `0/1`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。
## 录屏兜底
录屏只保留一条完整路径,不录框架后台配置。
1. 从首页开始,左侧菜单依次进入四个页面。
2. 每页只点上表的路径,停留 2 秒展示“必须看到”的验收文本。
3. 若现场网络或浏览器状态异常,用本阶段通过验收的四张截图兜底:`/tmp/wygj-ai-interview-flow.png`、`/tmp/wygj-practice-flow.png`、`/tmp/wygj-cases-flow.png`、`/tmp/wygj-sop-flow.png`。
3. 彩排前运行 `node scripts/capture-demo-screenshots.mjs` 生成四张兜底截图:`/tmp/wygj-ai-interview-flow.png`、`/tmp/wygj-practice-flow.png`、`/tmp/wygj-cases-flow.png`、`/tmp/wygj-sop-flow.png`。如本机 Chrome 或 portless 名称不同,用 `CHROME_BIN=... ADMIN_URL=... API_URL=...` 覆盖。
4. 若现场网络或浏览器状态异常,直接切上一步生成的截图。
## 不演示
+64 -16
View File
@@ -4,20 +4,57 @@
- 后端:`backend`,来自 `dromara/RuoYi-Vue-Plus` 的 `5.X` 分支
- 管理端前端:`frontend`,来自 `CrazyLionCat/plus-ui` 的 `5.X` 分支
- 移动端前端:`mobile`,独立 Vue/Vite 工程
- 用户侧前端:`mobile-uni`,uni-app Vue3 H5 工程;旧 `mobile` 仅保留为 MVP 演示兜底
## 本地端口
## 本地入口与依赖端口
- 管理端前端:`http://127.0.0.1:5173/`
- 移动端前端:`http://127.0.0.1:5174/h5/user`
- 后端:`http://127.0.0.1:8080/`
- 管理端前端:`https://wygj-admin.localhost/`
- uni-app 用户侧:`https://wygj-mobile-uni.localhost/h5/`
- 后端:`https://wygj-api.localhost/`
- 固定端口仅用于显式 fallback:`PORTLESS=0 ./scripts/dev.sh` 时后端回退 `http://127.0.0.1:8080/`,旧移动端回退 `http://127.0.0.1:5174/h5/user`
- MySQL:`127.0.0.1:13306`,数据库 `ry-vue`,账号 `root/root`
- Redis:`127.0.0.1:16379`,密码 `ruoyi123`
- MinIO API:`http://127.0.0.1:9000`,Console:`http://127.0.0.1:9001`,账号 `ruoyi / ruoyi123`,默认 bucket `ruoyi`
- Qdrant REST:`http://127.0.0.1:6333`,默认 collection `aihr_knowledge`
- 服务端资料导入根目录:`./.data/import`
Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;如外部要求 HMAC 签名,再配置 `AIHR_ORG_SYNC_SIGNING_SECRET`。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。
## portless 本地入口
本机开发测试优先用 portless 访问稳定域名,避免继续记固定端口:
- uni-app 用户侧:`https://wygj-mobile-uni.localhost/h5/`
- 管理端:`https://wygj-admin.localhost/`
- 后端 API:`https://wygj-api.localhost`
从根目录一次性启动本地依赖、后端、管理端和 uni-app 用户侧:
```bash
./scripts/dev.sh
```
`./scripts/dev.sh` 默认通过 portless 启动后端、管理端和 `mobile-uni`,并由 Docker 启动 MySQL/Redis/MinIO/Qdrant。管理端 Vite 代理读取 `frontend/.env.development` 的 `VITE_APP_PROXY_TARGET=https://wygj-api.localhost`,`mobile-uni` Vite 代理默认指向 `https://wygj-api.localhost`,都不依赖固定 `8080`。如需单独启动后端,可执行:
```bash
portless run --name wygj-api ./scripts/dev-backend.sh
```
重复启动或切换分支时优先使用 `./scripts/dev.sh restart`,停止使用 `./scripts/dev.sh stop`。脚本会递归终止 portless 包装进程及其 Java/Vite 子进程,避免旧后端脱离后继续占用 MySQL 连接池;进程树回归可用 `bash scripts/tests/process-tree.test.sh` 验证。
也可以只让 portless 按根目录 `portless.json` 启动前端工程:
```bash
portless
```
旧 `mobile/` 只在显式 `PORTLESS=0 ./scripts/dev.sh` 时作为固定端口兜底启动;后端开发测试优先看 `wygj-api.localhost`,管理端开发测试优先看 `wygj-admin.localhost`,新用户侧开发测试优先看 `wygj-mobile-uni.localhost`。
Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/api/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;业务请求会用 client secret 生成 HMAC-SHA256 hex 签名。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。若开放平台凭证放在 `backend/.env`,启动脚本也会加载该文件,并把 `client_id`/`client_secret` 映射为组织同步实际读取的 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`。
组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。只有 `phone_linked` 能覆盖至少 20 名正式试点人员且上游修复疑似乱码后,才切换管理端组织同步为写入模式。
正式试点预检必须指定当前批次时间窗,例如:`AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受 `YYYY-MM-DD`,只统计窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。
管理端正式试点 CSV 也必须显式选择同一批次日期。可用真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。
本地 `.env.local` 示例:
@@ -28,11 +65,18 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=xxx
ALIYUN_SMS_SIGN_NAME=物业AI助手
# 演示兜底:非空则不真发短信,验证码固定为该值;dev 默认 123456,prod profile 代码级强制失效
AIHR_SMS_DEV_FIXED_CODE=123456
AIHR_ORG_SYNC_BASE_URL=https://hr.example.com/open/v1
AIHR_ORG_SYNC_BASE_URL=https://wuye.meihe.cc/api/open/v1
AIHR_ORG_SYNC_ACCESS_TOKEN=
AIHR_ORG_SYNC_CLIENT_ID=
AIHR_ORG_SYNC_CLIENT_SECRET=
AIHR_ORG_SYNC_SIGNING_SECRET=
# 若放在 backend/.env,也可沿用开放平台字段名:
# client_id=dn_xxxxxx
# client_secret=dns_xxxxxx
# 试点成本闸门:超预算时改 false,chat/asr/tts 会走现有文本兜底
AIHR_AI_RUNTIME_ENABLED=true
AIHR_AI_CHAT_ENABLED=true
AIHR_AI_SPEECH_ENABLED=true
```
`application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。
@@ -45,6 +89,8 @@ AIHR_ORG_SYNC_SIGNING_SECRET=
./scripts/dev.sh
```
`./scripts/dev.sh` 默认通过 portless 暴露后端、管理端和当前 `mobile-uni` 用户侧。若要显式使用旧端口调试,可执行 `PORTLESS=0 ./scripts/dev.sh`,后端入口为 `http://127.0.0.1:8080/`,管理端入口为 `http://127.0.0.1:5173/`,旧移动端兜底入口为 `http://127.0.0.1:5174/h5/user`。
首次启动或需要重建本地 `ry-vue` MVP 数据库时执行:
```bash
@@ -74,13 +120,14 @@ AIHR_ORG_SYNC_SIGNING_SECRET=
- `aihr_interview_result_mysql8.sql` 已纳入 reset 脚本;AI 面试评分完成后结果落 `aihr_interview_result`
- `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material`
- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot`
- 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围
- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时
- 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`)
- 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。
- 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
- 后端 `ruoyi-admin` 已完成 Maven 打包并启动在 `8080`
- 前端依赖已安装,Vite 已启动在 `5173`
- 移动端依赖已安装,Vite 已启动在 `5174`
- 后端 `ruoyi-admin` 已完成 Maven 打包,并通过 portless 暴露为 `https://wygj-api.localhost/`
- 前端依赖已安装,Vite 已通过 portless 暴露为 `https://wygj-admin.localhost/`
- 当前用户侧由 `./scripts/dev.sh` 通过 portless 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`;旧移动端兜底仅在 `PORTLESS=0` 调试时启动到 `5174`
- 移动端手机号登录页已接 `/resource/sms/code` 与 `/auth/mobile/sms-login`;未配置真实 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`、`AIHR_SMS_LOGIN_TEMPLATE_ID` 时不会发送阿里云短信;手机号不存在时会自动注册为 `app_user`
- 移动端员工端“开始训练”已复用 `/api/train/practice/start`、`/turn`、`/finish`;移动端请求需带登录返回的 `Authorization` 与 `clientid`;完成后主管端完训人数、“待复盘对练”计数、复盘列表、复盘详情、员工训练历史和能力画像会变化
- 移动端候选人端“开始面试/面试练习”已复用 `/api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”需带移动端 `Authorization` 与 `clientid`,上传后写 `sys_oss` 和 `aihr_candidate_material`,管理端 `/recruit/interview` 可审核为 `已通过/已驳回`
@@ -97,7 +144,7 @@ curl -fsS http://127.0.0.1:6333/
```bash
TOKEN=<登录后 access_token>
curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN"
curl -fsS https://wygj-api.localhost/api/knowledge/doc/vector-index-status -H "Authorization: Bearer $TOKEN"
```
## 线上移动端静态发布
@@ -105,10 +152,10 @@ curl -fsS http://127.0.0.1:8080/api/knowledge/doc/vector-index-status -H "Author
线上 `peilian.njzhmj.top` 由 Caddy 服务,`/h5*` 映射到服务器 `/opt/wygj/www/h5`,并 fallback 到 `/h5/index.html`。只改移动端静态资源时无需重启后端。
```bash
VITE_BASE=/h5/ npm --prefix mobile run build
npm --prefix mobile-uni run build:h5
ssh YCWY 'ts=$(date +%Y%m%d%H%M%S); mkdir -p /opt/wygj/backups; cp -a /opt/wygj/www/h5 /opt/wygj/backups/h5-$ts; echo /opt/wygj/backups/h5-$ts'
rsync -az --delete mobile/dist/ YCWY:/opt/wygj/www/h5/
curl -k -s https://peilian.njzhmj.top/h5/supervisor | sed -n '1,20p'
rsync -az --delete mobile-uni/dist/build/h5/ YCWY:/opt/wygj/www/h5/
curl -k -s https://peilian.njzhmj.top/h5/ | sed -n '1,20p'
```
## MVP 页面验证
@@ -134,8 +181,9 @@ curl -k -s https://peilian.njzhmj.top/h5/supervisor | sed -n '1,20p'
- 案例沉淀:进入 `/knowledge/cases`,上传真实语音 → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增案例记录;上传必须先完成 ASR 转写。
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。
- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录(dev 验证码固定 `123456`)。员工端首页为「师傅区(mentor.png 头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图四色块→我的成长摘要」(视觉基准 `feedback/2026070423/design/`),底部 tab(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。“开始训练” → 两次“填入建议回复 / 提交本轮”,应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 asr/tts 模型后生效;iOS/WebView 若无法直接访问麦克风,会切到“选择语音”并用系统录音/音频文件继续走 ASR,否则降级文本。快捷 chip 或「问」打开查 SOP 面板:文本/语音提问 → 轻概要(回答+要点+注意)+依据折叠;点「生成总结卡·约10秒」弹出信息图卡(四步法/异议/话术/提醒+听一遍/开始跟读),需 chat 模型;语音需 asr 模型。
- 移动端候选人:进入 `http://127.0.0.1:5174/h5/candidate`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到“已提交资料”、`OSS ...`、`待审核`。再进入管理端 `/recruit/interview` 底部“候选资料审核”点“通过/驳回”,候选人端刷新后应显示新状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。
- uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。
- uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
- 真 LLM 激活:在 `/system/model` 给供应商填 api_host/api_key 并启用 `category=chat` 模型后,三角色对练的客户回复与评分即为真实 LLM 生成;再启用 `category=asr/tts`(如硅基流动 SenseVoice/CosyVoice2)语音路径生效。未配置时全链路自动回退 seed。
演示前可先跑最小预检:
+25
View File
@@ -0,0 +1,25 @@
# PC 管理端功能差异 TODO
> 依据:2026-07-09 浏览器实测管理端页面 + `docs/物业AI人力资源系统业务需求文档BRD.md` + `docs/银城员工端APP分阶段实施总纲.md` + `docs/AI陪练二期开发推进计划.md`。
> 口径:先支撑阶段一“陪练试点可用”,阶段二/三需求只登记,不抢跑。
| 优先级 | 差异项 | 当前状态 | 本次实现 | 验收口径 |
|---|---|---|---|---|
| P0 | 组织人员 / 岗位 / 项目权限运营页 | 已挂 `组织与权限` 页面,接组织快照查询、项目/岗位/层级/状态筛选、同步预检和质量提示 | 保留组织人员快照、岗位映射、项目数据范围和同步预检入口 | PC 可查询人员快照,看到身份三轴、权限映射、同步结果边界 |
| P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 已挂 `复盘校准` 页面,接真实复盘、评分校准、学分和派发接口 | 保留一页式处理:看记录、写建议、校准总分、给学分、派训练 | 主管能从一页完成“看记录 -> 写建议 -> 校准评分 -> 派训练” |
| P0 | 能力画像 / 等级证据包 / 激励规则 | 已挂 `成长激励` 页面,接画像、晋升证据包、预警、待训任务和专项派发接口 | 在成长页选择员工、专项场景和派发原因后派训练;规则先展示最小运营口径 | HR 能看到初/中/高级证据和学分规则口径,并能按场景派发专项 |
| P1 | 学模块课程 / 考试后台 | 文档要求阶段二建设;当前无页面 | 暂不实现,保留 TODO | 阶段二专项设计后再建课程、课件、考试、成绩表 |
| P1 | 大喇叭文件分发 | 总纲阶段二;当前无页面 | 暂不实现,保留 TODO | 半年会后按身份三轴做摘要、已读未读、补推 |
| P1 | 开放问题榜 | 阶段一只埋 `knowledge_gap`;当前无 UI | 暂不实现,保留 TODO | 阶段二消费 gap 池、上榜、最佳答案、沉淀入库 |
| P1 | 品检翻转 | 阶段二;当前无页面 | 暂不实现,保留 TODO | 依赖图片分析模板和业务巡检流程 |
| P2 | 北森 SSO / 考勤入口 | 阶段三;接口未知 | 暂不实现,保留 TODO | 阶段三拿到北森接口文档后做跳转/同步 |
## 本次交付边界
- 不新建数据库表。
- 不改登录、密钥、模型配置、支付、部署脚本。
- 优先复用已有 `/api/aihr/mobile/practice/*` 与 `/api/train/practice/*` 接口。
- 组织人员快照在 `组织与权限` 页分页展示;业务页只取下拉候选,真实复盘范围由接口参数落到后端过滤。
- `组织与权限` 页暴露手机号覆盖数和疑似乱码数,只做质量提示,不展示手机号明细。
- 组织同步预检同步返回手机号覆盖数、脱敏手机号数和疑似乱码数;手机号只统计可用于移动端登录的 11 位手机号。
- 规则配置中心先做运营口径展示;需要真实持久化时再补 `incentive_rule` 等表。
+6 -3
View File
@@ -8,12 +8,15 @@
|---|---|
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 |
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、业务模块落点、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 |
| [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 |
| [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲:陪练 → 大喇叭+知识学习平台 → 北森对接;身份三轴模型与防遗漏分发策略 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
| [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜) → 北森对接;身份三轴模型与防遗漏分发策略 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 |
| [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 |
| [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:产品定义(学练问报+问题榜+学分晋升)与半年会发布会方案分层;客户"第三版"逐项处置对照 |
| [基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get 笔记】.md](基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get%20笔记】.md) | 2026-07-07 立项会纪要原文(总纲的输入源,只作留档) |
| [20260708/企业内部AI培训学习APP产品需求与发布会内容讨论(纪要)](20260708/) | 2026-07-08 产品讨论纪要原文及客户"第三版"docx(整合方案的输入源,只作留档) |
| [open-org-sync-api-design-v1.md](open-org-sync-api-design-v1.md) | 开放组织同步系统 `/open/v1` 接口设计,`POST /api/aihr/org/sync` 的外部契约 |
| [物业AI人力资源系统业务需求文档BRD.md](物业AI人力资源系统业务需求文档BRD.md) | 业务需求、范围边界、角色、风险与验收 |
| [物业AI人力资源系统开发规格TechSpec.md](物业AI人力资源系统开发规格TechSpec.md) | 工程结构、数据模型、页面路由、API 和核心实现规格 |
@@ -16,3 +16,4 @@
| [AI人力资源系统功能介绍演示.pptx](AI人力资源系统功能介绍演示.pptx) | 功能介绍演示稿 |
| [AI人力资源系统功能介绍演示-截图版.pptx](AI人力资源系统功能介绍演示-截图版.pptx) | 截图版功能介绍演示稿 |
| [AI人力资源系统用户故事演示.pptx](AI人力资源系统用户故事演示.pptx) | 用户故事演示稿 |
| [design-qa.md](design-qa.md) | 三端首页高保真实现的截图对照 QA 记录(MVP 阶段收尾,结论 passed;截图产物在已忽略的 `.tmp/`,仅存文字结论) |
@@ -0,0 +1,450 @@
# Mobile Uni Employee Pages Visual Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将员工端 `today / sop / practice` 三页及其强相关的 `tabBar / 共享卡片样式` 调整为接近设计基准稿的统一视觉,同时保持现有业务交互和接口行为不变。
**Architecture:** 采用“视觉优先、业务最小改动”的改法:先建立员工端共享视觉 token 与基础样式,再按首页、问师傅页、训练页的顺序逐页重排结构和样式。页面内部函数、接口调用与路由保留,主要改模板层级、类名、样式组织和少量静态资源引用。
**Tech Stack:** uni-app Vue3、Vite、TypeScript、CSS、Node 原生测试、H5 构建校验
---
### Task 1: 建立员工端共享视觉基座
**Files:**
- Modify: `mobile-uni/src/styles/base.css`
- Modify: `mobile-uni/src/pages.json`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 写一个失败测试,锁住 Vue3 manifest 与员工端视觉约束继续有效**
```js
test('tabBar 继续保留员工端四个固定入口', async () => {
const source = await pageSource('../src/pages.json');
assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/);
assert.match(source, /pages\/user\/profile\/index/);
});
```
- [x] **Step 2: 运行测试,确认新增断言先失败或当前文件尚未覆盖该约束**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 新增测试如果还没加入会缺失;加入后在修改前至少能作为后续回归校验入口存在。
- [x] **Step 3: 在 `base.css` 中整理员工端共享 token,不扩大到全站侵入式重置**
```css
:root {
--employee-bg: #f4f6fb;
--employee-surface: #ffffff;
--employee-primary: #2f6bff;
--employee-primary-soft: #eaf1ff;
--employee-success: #34b368;
--employee-warning: #ff8a1f;
--employee-danger: #ff5b4d;
--employee-text: #182230;
--employee-muted: #6b7280;
--employee-card-radius: 24px;
--employee-card-shadow: 0 12px 32px rgba(47, 107, 255, 0.08);
}
.employee-card {
border-radius: var(--employee-card-radius);
background: var(--employee-surface);
box-shadow: var(--employee-card-shadow);
}
.employee-section-title {
color: var(--employee-text);
font-size: 20px;
font-weight: 900;
}
```
- [x] **Step 4: 调整 `pages.json` 的员工端 tabBar 视觉参数,保留四 tab 结构和路径**
```json
"tabBar": {
"color": "#7b8494",
"selectedColor": "#2f6bff",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
"fontSize": "12px",
"iconWidth": "25px",
"spacing": "3px",
"list": [
{ "pagePath": "pages/user/today/index", "text": "今日" },
{ "pagePath": "pages/user/practice/index", "text": "练" },
{ "pagePath": "pages/user/sop/index", "text": "问" },
{ "pagePath": "pages/user/profile/index", "text": "我" }
]
}
```
- [x] **Step 5: 更新回归测试,验证 tabBar 四入口与共享样式关键 token 存在**
```js
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /--employee-primary:/);
assert.match(source, /--employee-card-radius:/);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS,且已有输入兼容与 manifest 测试继续通过。
- [ ] **Step 7: 提交这一阶段改动**
```bash
git add mobile-uni/src/styles/base.css mobile-uni/src/pages.json mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: add employee visual foundation"
```
### Task 2: 重做员工端首页视觉
**Files:**
- Modify: `mobile-uni/src/pages/user/today/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为首页结构写失败测试,锁住设计稿要求的核心区块**
```js
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /mentor-hero/);
assert.match(source, /voice-panel/);
assert.match(source, /今日安排/);
assert.match(source, /我的地图/);
});
```
- [x] **Step 2: 运行测试确认结构断言可作为改版护栏**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 失败或在旧结构下不足以覆盖新样式目标,后续补全后通过。
- [x] **Step 3: 重排首页模板,保留原有交互函数但调整为设计稿节奏**
```vue
<view class="mentor-hero employee-hero">
<view class="hero-copy">
<text class="hero-role">{{ positionProfile.value }}</text>
<text class="hero-title">{{ positionProfile.heroTitle }}</text>
<text class="hero-sub">有问题,随时问老师傅</text>
</view>
<image class="mentor-image" src="/static/mentor.png" mode="aspectFit" />
<view class="mentor-bubble">
<text>{{ mentorBubbleText }}</text>
</view>
</view>
<view class="voice-panel employee-card">
<button class="voice-primary" @click="goSop">...</button>
<button class="voice-secondary" @click="goSop">...</button>
</view>
<view class="section-card employee-card" v-else-if="hasSchedule">...</view>
<view class="section-card employee-card" v-if="loggedIn">...</view>
```
- [x] **Step 4: 用 scoped 样式还原首页视觉,控制人物占屏不超过首屏约五分之一**
```css
.today-page {
background: radial-gradient(circle at top, #eef5ff 0%, #f7f9fc 38%, #f4f6fb 100%);
}
.mentor-hero {
min-height: 220px;
padding: 28px 24px 0;
}
.mentor-image {
right: 24px;
bottom: 0;
width: 108px;
height: 148px;
}
.voice-panel {
margin-top: -28px;
grid-template-columns: 1.4fr 1fr;
}
.map-tile {
border-radius: 22px;
min-height: 136px;
}
```
- [x] **Step 5: 更新测试,验证首页地图主标题继续显示真实 `tile.title`,避免回退成单字标题**
```js
test('员工首页地图主标题继续显示真实工具名称', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /<text class="map-title">\{\{ tile\.title \}\}<\/text>/);
});
```
- [x] **Step 6: 运行测试验证首页改版没有破坏护栏**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交首页改版**
```bash
git add mobile-uni/src/pages/user/today/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee home page"
```
### Task 3: 重做问师傅页视觉与总结卡展现
**Files:**
- Modify: `mobile-uni/src/pages/user/sop/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为问师傅页写失败测试,锁住搜索主导与总结卡入口仍然存在**
```js
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
assert.match(source, /query-panel/);
assert.match(source, /answer-panel/);
assert.match(source, /summary-panel/);
});
```
- [x] **Step 2: 运行测试确认护栏建立成功**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 断言可执行,作为后续视觉重排的结构保护。
- [x] **Step 3: 收紧问师傅页头部,改为“轻标题 + 强搜索框”的布局**
```vue
<view class="page sop-page">
<view class="sop-toolbar">
<text class="page-title">查SOP</text>
<text class="page-subtitle">直接搜 SOP、案例和常用话术</text>
</view>
<view class="panel query-panel employee-card">
<textarea v-model="query" class="textarea sop-query-textarea" ... />
<view class="chip-row">...</view>
<button class="primary-button query-submit" ...>问老师傅</button>
</view>
</view>
```
- [x] **Step 4: 调整答案卡、引用依据和总结卡为更接近设计稿的卡片层级与底部浮层感**
```css
.sop-page {
background: linear-gradient(180deg, #f7f9fc 0%, #f4f6fb 100%);
}
.query-panel {
border-radius: 22px;
padding: 18px;
}
.answer-panel {
border-left: 0;
border-radius: 22px;
}
.modal-card {
border-radius: 28px 28px 0 0;
max-width: 430px;
width: calc(100% - 16px);
}
```
- [x] **Step 5: 保持 H5 输入兼容测试存在,防止误把 `textarea` 再次改成 `border-box`**
```js
test('SOP 查询框不覆盖 textarea 的 box-sizing', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
const queryPanelStyle = source.match(/\.query-panel \.textarea \{[\s\S]*?\n\}/)?.[0] || '';
assert.equal(queryPanelStyle.includes('box-sizing'), false);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交问师傅页改版**
```bash
git add mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee sop page"
```
### Task 4: 重做训练页视觉但保留训练逻辑
**Files:**
- Modify: `mobile-uni/src/pages/user/practice/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为训练页写失败测试,锁住训练关键区块仍存在**
```js
test('训练页保留场景区、对话区、回复区和结果区', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /scenario-card/);
assert.match(source, /dialogue-card/);
assert.match(source, /reply-card/);
assert.match(source, /result-card/);
});
```
- [x] **Step 2: 运行测试确认结构护栏存在**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 断言可运行,用于防止视觉重排时误删功能区块。
- [x] **Step 3: 调整训练页模板,让头部、场景卡、老师傅提示更接近设计稿的训练面板结构**
```vue
<view class="practice-head">
<text class="practice-eyebrow">数字师傅 · 场景训练</text>
<text class="page-title">{{ activeScenarioName }}</text>
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话</text>
</view>
<view v-if="loggedIn" class="panel scenario-card employee-card">...</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">...</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card employee-card">...</view>
```
- [x] **Step 4: 用样式强化三类角色区分与老师傅提示卡,但不引入每轮真实评分**
```css
.turn-row.customer {
background: linear-gradient(180deg, #fff8f1 0%, #ffffff 100%);
}
.turn-row.trainee {
background: linear-gradient(180deg, #f2fbf6 0%, #ffffff 100%);
}
.turn-row.coach {
background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%);
}
.reply-card .primary-button {
min-height: 52px;
border-radius: 18px;
}
.help-card,
.mentor-rewrite {
border-radius: 22px;
}
```
- [x] **Step 5: 保留并复用提交失败回滚测试,避免训练视觉改版时误伤已有状态修复**
```js
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /const previousTurns = turns\.value\.slice\(\)/);
assert.match(source, /turns\.value = previousTurns/);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交训练页改版**
```bash
git add mobile-uni/src/pages/user/practice/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee practice page"
```
### Task 5: 全量验证与收尾
**Files:**
- Verify: `mobile-uni/src/pages/user/today/index.vue`
- Verify: `mobile-uni/src/pages/user/sop/index.vue`
- Verify: `mobile-uni/src/pages/user/practice/index.vue`
- Verify: `mobile-uni/src/styles/base.css`
- Verify: `mobile-uni/src/pages.json`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 运行员工端页面回归测试**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [x] **Step 2: 运行全量单测**
Run: `npm run test:unit`
Expected: 全部 PASS;允许保留既有 WebSocket 噪音,只要没有失败。
- [x] **Step 3: 运行类型检查**
Run: `npm run typecheck`
Expected: PASS。
- [x] **Step 4: 运行 H5 构建**
Run: `npm run build:h5`
Expected: `Build complete.`
- [ ] **Step 5: 本地打开首页进行人工快速核对**
Run: `open "http://localhost:5175/h5/#/pages/user/today/index"`
Expected: 能看到新的首页首屏结构、语音条、今日安排、我的地图和统一 tabBar。
- [ ] **Step 6: 提交最终整体验证结果**
```bash
git add mobile-uni/src/pages/user/today/index.vue mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/pages/user/practice/index.vue mobile-uni/src/styles/base.css mobile-uni/src/pages.json mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: unify employee mobile page visuals"
```
## 计划自检
### Spec 覆盖检查
- 三页范围:Task 2、Task 3、Task 4 覆盖 `today / sop / practice`。
- 共享样式与 tabBar:Task 1 覆盖。
- 老师傅仅复用现有素材、不扩展业务:Task 2 与 Task 4 的模板调整限制在现有资源与逻辑内。
- SOP 不伪造能力:Task 3 明确保留真实能力,不新增假 tab 行为。
- 训练页不做每轮真实评分:Task 4 明确只改视觉分层,不新增该模块。
- 验证要求:Task 5 覆盖页面测试、全量单测、类型检查、H5 构建。
### Placeholder 扫描
- 无 `TODO`、`TBD`、`适当处理` 等空泛描述。
- 每个任务都包含具体文件、命令和代码片段。
### 类型与命名一致性
- 继续沿用现有页面类名:`scenario-card`、`dialogue-card`、`reply-card`、`summary-panel`、`voice-panel` 等。
- 复用现有关键回归点:`tile.title`、`previousTurns`、`box-sizing`、`vueVersion`。
@@ -0,0 +1,241 @@
# 员工端三页视觉改版设计
## 背景
当前 `mobile-uni` 员工端首页、问师傅页、训练页虽然已经有一轮样式调整,但整体仍然存在以下问题:
- 三页视觉语言不统一,像是独立拼接出来的页面,而不是同一套员工端产品。
- 信息层级和卡片节奏偏乱,重点入口不够聚焦,页面看起来“重、散、旧”。
- 与 `docs/feedback/2026070423/design/` 中已经采纳的 P1 视觉基准仍有明显差距。
- 当前诉求是优先解决“页面丑陋”的观感问题,而不是重写业务交互。
本次改版以视觉还原为主,业务交互保持最小改动。
## 目标
### 主要目标
- 统一员工端 `today / sop / practice` 三页视觉气质。
- 尽量向已采纳设计稿靠拢,优先还原布局、卡片层级、按钮形态、留白和色彩关系。
- 保持现有业务逻辑、接口调用、页面跳转、训练流程与登录流程不变。
- 允许联动调整员工端 `tabBar` 和少量共享基础样式,使三页不再割裂。
### 非目标
- 不新增后端字段或伪造后端能力。
- 不重写训练页状态机或 SOP 检索逻辑。
- 不做候选人端、主管端的同步视觉改版。
- 不在本次引入新的正式人物素材来源。
## 设计依据与约束
### 设计依据
- 首页视觉基准:`docs/feedback/2026070423/design/h5-redesign-employee-home.png`
- SOP 结果与总结卡基准:`docs/feedback/2026070423/design/h5-feedback-summary-modal.png`
- 训练页对手戏基准:`docs/feedback/2026070423/design/h5-feedback-voice-roleplay.png`
- 综合评审约束:`docs/feedback/2026070423/design/REVIEW.md`
### 必须遵守的约束
- 老师傅形象不照抄首页参考图中的女性写实形象,统一使用现有本地素材做“蓝工装插画风男性师傅”的近似表达。
- 首页人物只承担氛围与陪伴感,不大面积占屏;视觉占比控制在首屏约五分之一以内。
- 训练页不实现每轮真实评分,只保留轻提示或教练辅助信息。
- SOP 页如果设计稿出现“全部/SOP/案例/话术”类型 tab,在后端维度未落地前不得伪造成功能型筛选。
- 遵守 uni-app H5 输入兼容性规则,不对 `input` / `textarea` 设置 `box-sizing: border-box`。
## 范围
### 页面范围
- `src/pages/user/today/index.vue`
- `src/pages/user/sop/index.vue`
- `src/pages/user/practice/index.vue`
### 允许联动的共享部分
- `src/styles/base.css`
- `src/pages.json` 中员工端 `tabBar` 视觉配置
- 员工端页面使用的现有本地静态素材引用方式
### 明确不改的部分
- 页面路由结构
- 接口地址、参数与返回处理
- 训练开始、提交、结束、SOP 查询、总结卡生成等业务行为
- 登录与岗位确认流程
## 总体方案
采用“视觉优先还原型”方案:
- 保留现有业务结构和页面职责,不移动关键交互入口的语义。
- 对三页的首屏结构、标题区、功能卡、按钮区、辅助信息区进行重排和统一。
- 在共享样式层建立一套仅服务员工端三页的轻量视觉 token,包括背景、卡片、主色、辅助色、圆角、阴影、标题字号与留白节奏。
- 对 `tabBar` 做有限统一,让页面主视觉与底部导航形成同一套产品感知。
这条方案的核心是:让用户明显感觉“这是一套被设计过的产品”,而不是“旧页面换了点颜色”。
## 共享视觉系统
### 色彩
- 主蓝:用于首页主入口、主要行动按钮、SOP 核心操作。
- 辅橙:用于复盘、提醒、异议、次强调动作。
- 辅绿:用于师傅、建议、可执行正向反馈。
- 辅红:用于风险、命中红线、强提醒。
- 大面积背景:使用偏暖的浅灰白底,避免纯白页面带来的生硬感。
### 形态
- 卡片统一使用大圆角、轻阴影、弱边框。
- 主操作按钮统一做成更饱满的胶囊或大圆角矩形。
- 标签、chips、状态 badge 使用明显但不过分抢眼的弱底色。
- 页面内图文块尽量减少硬线分割,多用留白和卡片分区建立节奏。
### 文本层级
- 页面标题使用大号高对比字重。
- 模块标题明显强于正文,但弱于页面标题。
- 辅助说明统一压低饱和度,避免整页“处处都是重点”。
### TabBar
- 保留“今日 / 练 / 问 / 我”结构不变。
- 调整选中态、阴影、顶部边界和留白,让其更接近设计稿中的轻质底栏。
- 不改动页面跳转与当前 tab 逻辑。
## 分页面设计
### 1. 员工端首页 `today`
#### 目标
首页要尽量贴近 `h5-redesign-employee-home.png`:先建立“老师傅陪练”的第一印象,再把语音入口、今日安排、我的地图作为三个连续层级展开。
#### 结构
- 顶部使用浅背景氛围图区。
- 左侧为岗位与欢迎信息,右下为较小的老师傅形象与气泡。
- 头图区下方是大白卡语音入口,主入口明显、次入口收敛。
- 再下方是快捷 chips。
- 然后依次是“今日安排”“我的地图”“今日小贴士”。
#### 关键调整
- 头图不再追求“人物大图压场”,而是营造轻陪伴感。
- “问老师傅”作为首要入口,视觉上高于“打字问”。
- “今日安排”卡更像日程卡,而不是普通列表。
- “我的地图”保留四色功能块,但主标题显示真实业务名,单字仅作装饰符号。
- 整页通过更清晰的上下节奏建立首屏完成度。
### 2. 问师傅页 `sop`
#### 目标
页内重点从“普通查询页”升级为“搜索驱动的知识助手页”,并尽量贴近 `h5-feedback-summary-modal.png` 的结构氛围。
#### 结构
- 顶部收成更轻的标题与搜索区。
- 搜索框作为首屏主元素。
- 查询结果区按“直给答案 -> 要点 -> 提醒 -> 引用依据”分层。
- 总结卡入口保持单独区域。
- 总结卡展示形态从普通 modal 调整为更贴近设计稿的底部浮层信息图风格。
#### 关键调整
- 减少当前大块头图区的存在感,把注意力转回搜索行为。
- 查询按钮、快捷问题、答案卡的主次关系更明确。
- 总结卡内部采用更强的信息图分组感,但不新增保存、听一遍、跟读等未实现业务按钮。
- 若现阶段无法正确承载分类 tab,则不强行做功能性顶部分类。
### 3. 训练页 `practice`
#### 目标
把页面从“功能堆叠页”调整为更像训练对话面板的页面,同时遵守“不做每轮真实评分”的约束。
#### 结构
- 顶部为训练标题、场景标签和老师傅陪伴状态。
- 场景预习卡保留,但视觉上并入训练前准备区域。
- 对话区强化三类角色的视觉差异:业主、我、老师傅。
- 回复区的录音、转写、求助、提交按钮重新排版,但功能不变。
- 教练提示与 SOP 提示更像辅助卡,而不是和主流程抢层级。
- 结果卡在完成后仍独立呈现。
#### 关键调整
- 参考 `h5-feedback-voice-roleplay.png` 的对话氛围与角色卡感,但不照搬其每轮评分模块。
- 让“老师傅提醒”成为视觉上可信赖的辅助块。
- 保持现有训练逻辑顺序,不改开始、提交、结束时机。
## 组件与样式边界
本次优先在现有页面文件内完成视觉重排,不额外拆出大量新组件,理由如下:
- 用户当前诉求是快速改善三页视觉,而不是先做前端组件体系建设。
- 三页虽然要统一语言,但内容结构差异仍较大,过早抽象容易制造样式耦合。
- 共享部分只抽到 `base.css` 的 token 和少量通用类,避免引入新的复杂度。
允许的抽象上限:
- 共享 token
- 通用卡片/标题/按钮类
- 少量重复且完全稳定的视觉辅助类
不建议在本次新增多层组件封装。
## 验收标准
### 视觉验收
- 三页首屏都明显接近参考设计稿的节奏与气质。
- 员工端首页、SOP 页、训练页能够被一眼识别为同一套产品。
- 老师傅、语音入口、核心行动按钮、模块标题的层级明显优于当前版本。
- `tabBar` 与页面主视觉不再割裂。
### 行为验收
- 现有路由、接口、登录、训练、SOP 查询行为不回归。
- 不引入假交互、不新增无后端支持的视觉功能。
- 输入框与文本域在 H5 仍可正常聚焦、输入。
### 技术验收
- 至少完成类型检查、单测和 H5 构建验证。
- 如新增与视觉约束有关的轻量回归测试,应优先覆盖容易再次被误改的关键点。
## 风险与处理
### 风险 1:现有本地素材不足以完全贴近设计稿
处理:优先还原布局、比例、色彩和氛围;人物素材先做近似表达,不因素材不足阻塞页面改版。
### 风险 2:为了追视觉而误伤业务逻辑
处理:限定本次为视觉优先、交互最小改动,不重写流程函数和接口组织。
### 风险 3:共享样式改动波及其它页面
处理:共享样式只服务员工端相关页面,避免做全局侵入式 reset。
### 风险 4:SOP 设计稿含未落地能力
处理:只保留当前真实能力,视觉上做近似,不伪造功能。
## 实施顺序建议
1. 先整理员工端共享 token 和 tabBar 视觉基座。
2. 完成员工端首页改版,建立整套视觉语言。
3. 以首页风格为基准改 SOP 页。
4. 最后改训练页,避免其复杂交互拖慢整体节奏。
5. 跑类型检查、单测和 H5 构建验证。
## 规格自检结论
- 无占位符、TODO 或未定义术语。
- 方案范围清晰,聚焦三页与少量共享视觉层。
- 与用户已确认方向一致:优先还原视觉,业务交互最小改动。
- 与项目评审约束一致:人物统一、首页人物缩量、训练页不做每轮真评分、SOP 不伪造分类能力。
@@ -43,7 +43,7 @@ mobile/
```
- 复用若依 `ruoyi-system` 的用户/角色/菜单/数据权限;自有后端 API 集中在 `ruoyi-aihr`,不要塞回上游 `ruoyi-demo`。
- 管理端 `frontend/` 不承载 `/h5/*`;移动端通过 `5174` 独立启动,路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`。
- 管理端 `frontend/` 不承载 `/h5/*`;当前用户侧通过 `mobile-uni/` + portless 启动,入口 `https://wygj-mobile-uni.localhost/h5/`,三端路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 仅做兼容重定向。
- AI 能力先在 `ruoyi-aihr` 内提供统一接口(对话/评分/转写/生成),便于切换厂商与兜底;后续可按模块拆分。
- 可借助已有的 "codex 若依组件生成 skill" 批量生成 CRUD 骨架。
@@ -173,9 +173,9 @@ mobile/
| `/recruit/interview` | **AI 面试(英雄路径A:出题→作答→打分)** | P0跑通 |
| `/recruit/candidates` | 候选人列表 | P1 |
| `/train/practice` | **三角色对练(英雄路径核心)** | P0跑通 |
| `/h5/user` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数 | P0跑通 |
| `/h5/candidate` | 候选人端首页,手机号登录后可面试练习、查岗位 SOP/案例、上传补充资料 | P0跑通 |
| `/h5/supervisor` | 主管端首页,查看团队、待复盘、案例/SOP 工具 | P0跑通 |
| `/h5/#/pages/user/today/index` | 员工端首页,手机号登录后可“开始训练”并同步主管待复盘计数;旧 `/h5/user` 兼容重定向 | P0跑通 |
| `/h5/#/pages/candidate/index/index` | 候选人端首页,手机号登录后可面试练习、查岗位 SOP/案例、上传补充资料;旧 `/h5/candidate` 兼容重定向 | P0跑通 |
| `/h5/#/pages/supervisor/index/index` | 主管端首页,查看团队、待复盘、案例/SOP 工具;旧 `/h5/supervisor` 兼容重定向 | P0跑通 |
| `/train/daily` | 每日一练 | P1 |
| `/train/camp` | 专项训练营 | P1 |
| `/train/mistakes` | 错题本 | P1 |
+32 -12
View File
@@ -1,10 +1,11 @@
# 银城员工端 APP 分阶段实施总纲
> 版本:v0.2 | 日期:2026-07-07
> 输入源:《基于AI的银城员工端APP项目立项与开发规划会议(2026-07-07)》纪要 +《BRD v1.1》+《AI陪练二期开发推进计划 v0.7》。
> 版本:v0.3 | 日期:2026-07-08
> 输入源:《基于AI的银城员工端APP项目立项与开发规划会议(2026-07-07)》纪要 +《BRD v1.1》+《AI陪练二期开发推进计划 v0.8》+《数字师傅学练问报整合方案 v1.1》(docs/20260708/)。
> 定位:立项会把交付物从"AI 人力资源系统"升级为**银城员工端 APP**(员工服务载体)。本总纲定义三阶段顺序与边界;各阶段的执行细节仍由对应专项计划承载(阶段一 = 二期推进计划)。
> 分阶段口径(已定):**第一步陪练相关全部工作 → 第二步银城大喇叭 + 知识学习平台 → 第三步北森人事考勤对接**。
> v0.2 确认(2026-07-07,客户方):①"管家"与场景库"生活顾问"为**同一岗位**,术语统一为"管家(生活顾问)",`position` 标签沿用现有值不迁移;②APP 形态定为 **H5 套壳 APP**(WebView 包装可安装),原生重写不做,D6 决策维持。
> v0.3 修订(2026-07-08,客户产品讨论):①阶段二"知识学习平台"扩充**学模块**——标准学习计划/课程/防快进视频/最短阅读时长/不定期考试,见 §4.5;②"新问题挂榜共创"产品形态定案为**开放问题榜**,定向师带徒经会议否决不做,见 §4.4;③新增知识分级访问权限工程需求;④三项待确认已获客户回复(2026-07-08):多端跳转暂缓(只保证能打开小程序页面,D6 不动)、排行榜保留为可选开关、员工评管理层按组织架构权限控制,见 §8 与《数字师傅学练问报整合方案 v1.2》§3.3。
## 1. 立项会带来的定位变化
@@ -23,21 +24,21 @@
| 阶段 | 内容 | 时间锚点 | 承载文档 |
|---|---|---|---|
| **一、陪练** | 陪练相关全部工作:场景库、语音对手戏、复盘、画像、派发、新员工每日三题、传帮带 | 2026-07-10 Alpha → 2026-07-13 当周 Beta → 半年会发布 → 9 月收费季前试点可用 | 《AI陪练二期开发推进计划》v0.7 |
| **二、大喇叭 + 知识学习平台** | 下行:公司信息直达推送(不过滤);每份文件 AI 总结 + 分身份"跟我相关"提取(见 §4 身份模型);上行:品检翻转(员工拍照上报+AI分析+积分)、新问题挂榜共创 | 半年会后启动,与阶段一试点并行推进 | 本总纲 §4/§5(后续出专项计划) |
| **一、陪练** | 陪练相关全部工作:场景库、语音对手戏、复盘、画像、派发、新员工每日三题、传帮带 | 2026-07-10 Alpha → 2026-07-13 当周 Beta → 半年会发布 → 9 月收费季前试点可用 | 《AI陪练二期开发推进计划》v0.8 |
| **二、大喇叭 + 知识学习平台** | 下行:公司信息直达推送(不过滤);每份文件 AI 总结 + 分身份"跟我相关"提取(见 §4 身份模型);**学模块:标准学习计划 + 知识课程 + 不定期考试(见 §4.5)**;上行:品检翻转(员工拍照上报+AI分析+积分)、开放问题榜(见 §4.4) | 半年会后启动,与阶段一试点并行推进;**学模块岗前课程最小版视资源提前到阶段一试点后** | 本总纲 §4/§5 +《数字师傅学练问报整合方案》(后续出专项计划) |
| **三、北森对接** | SSO/组织人员同步、考勤请假入口整合;报事闭环接口对接("一键走天下") | 阶段二稳定后;报事流程梳理由闫鑫牵头业务侧先行 | 本总纲 §6(后续出集成方案) |
排序理由(立项会原话):先陪练可倒逼知识沉淀 + 缓解 9 月收费压力;不求大求全,先做员工必用核心功能。
## 3. 阶段一:陪练(对二期计划的增量调整)
二期计划主体不变,立项会带来 6 处调整(已并入 v0.6,岗位与 APP 形态两项确认已并入 v0.7,详见该文档):
二期计划主体不变,立项会带来 6 处调整(已并入 v0.6,岗位与 APP 形态两项确认已并入 v0.7,2026-07-08 产品讨论增量已并入 v0.8,详见该文档):
1. **时间锚点收紧**:8 周里程碑映射到 Alpha(2026-07-10)/Beta(2026-07-13 当周)/半年会发布/9 月收费季四个外部节点;半年会前必须收集到一线员工真实使用数据 → M1/M2 的可用增量要提前放给种子用户,不能等 M5 才见人。
2. **岗位口径**:**已确认(2026-07-07)**——立项会所说"管家"与场景库"生活顾问"为同一岗位,文档统一写"管家(生活顾问)",`position` 标签沿用现有值不迁移;**项目经理为第二波人设**,场景由内容侧供给,不阻塞第一波。
3. **收费场景权重上调**:9 月收费季是上线动因,12 条场景中催费/预收/停车费类优先做深(预交折扣、历史欠费怎么谈等立项会举例场景入库)。
4. **新员工每日三题**:新管家入职 3 个月掌握 100 个场景、每天 5 分钟 3 题、AI 秒评。落在 M4 派发机制(`source='daily'`),新增**轻量微题库**内容形态(题干+参考答案+秒评,区别于 12 条重对练场景)。
5. **新问题识别蓄水**:阶段一先埋钩子——求助/问答未命中时记录 `knowledge_gap` 日志(问题、岗位、时间),为阶段二"挂榜积分共创"备数据;阶段一不做挂榜 UI。
5. **新问题识别蓄水**:阶段一先埋钩子——求助/问答未命中时记录 `knowledge_gap` 日志(问题、岗位、时间),为阶段二"开放问题榜"(§4.4)备数据;阶段一不做挂榜 UI。
6. **范围保护**:大喇叭、品检翻转、北森对接**不进阶段一主线**,进本总纲阶段二/三。
## 4. 阶段二核心设计:身份模型与"跟我相关"分发策略
@@ -79,9 +80,25 @@
### 4.4 阶段二其余范围
- **上行通道·品检翻转**:员工拍照上报现场问题 → AI 分析(督导图片分析模板已在提示词模板体系内,`supervision_image_analysis_*`)→ 管理者针对性巡检;积分激励,体验做"好玩"不做"负担"(立项会明确要求)。
- **新问题挂榜共创**:消费阶段一积攒的 `knowledge_gap` 日志,新问题亮榜、员工答题得积分(立项会示例 500 积分/题)、采纳入库署名——复用 BRD 4.7 共创链与 `incentive_point`。
- **开放问题榜**(2026-07-08 产品形态定案,原"新问题挂榜共创"):消费阶段一积攒的 `knowledge_gap` 日志;**双入口**——①"今天遇到什么难题了?"(真实困难求助)②"有奖征问题"(悬赏征集好问题);**全公司所有人可答**(含管理层),不同层级答案分类呈现;提问、回答均有奖励,后台筛选最佳答案置顶,优质问答沉淀入库并署名;前期邀请优秀生活顾问对高频问题做经验萃取喂入 AI。复用 BRD 4.7 共创链与 `incentive_point`。**定位说明:问题榜是定向师带徒的替代方案**——2026-07-08 会议明确否决定向师带徒(80% 以上项目生活顾问为初级、无带徒岗位设置、无额外报酬),问题榜以"广谱师傅"形态承载帮带:想帮带的人通过答题帮带,留痕、可奖励、无固定关系绑定。
- **内容形态升级**:长文自动图文总结卡 + 语音播报(能力已有);AI 场景演示视频/番茄小短剧挂 BACKLOG B4,半年会可先出 1–2 条示范样片。
- **敏感项闸门**:立项会提到"员工参与对管理团队的评价"——涉组织风险,列为阶段二**待独立评审项**,不默认进主线(参照 G4 员工接受度闸门处理)。
- **知识分级访问**(2026-07-08 新增工程需求):涉密资料不入库;入库资料按"账号等级 × 知识密级"做访问控制(会议口径:不同等级账号可问的范围不同)。阶段二开工前完成方案评审。
- **敏感项处置更新**:"员工参与对管理团队的评价"——2026-07-08 客户回复**以组织架构、权限为依据,需有权限才能评价**;从"待独立评审的敏感项闸门"转为**权限控制的正常功能**(复用 `role_perm_mapping`/D1 数据权限控制谁可评价),仍属阶段二范围。
### 4.5 学模块:标准学习计划与知识课程(2026-07-08 新增)
客户方明确要求员工端四大按钮中的「学」升级为"安排标准学习计划和知识课程",对标大厂企业员工学习考试平台。最小可用集:
| 能力 | 要求 |
|---|---|
| 学习计划 | 按岗位安排标准学习计划;新员工入职自动挂**入职学习任务包**(首包:生活顾问日常巡检要点——楼道、停车场、天台各点位关注什么,素材由业务侧从巡检标准拆分) |
| 课程内容 | 文档 + 视频两种课件;复用知识库上传解析链路,课程是"结构化的知识包" |
| 防快进 | 视频**不能快进**:禁用拖拽跳转,只允许回看(H5 播放器监听进度,超出已观看位置回拉) |
| 阅读时长 | 文档设**最短打开时长**,达到才计学完(前端计时 + 页面可见性校验,后端存进度) |
| 考试 | 学完可考;**不定期学习 + 不定期考试**;考试有**截止时间**;成绩留档;题库先由业务侧按课程配套供题,后续 AI 辅助出题 |
| 学分 | 完成课程/通过考试计入学分;学分达标 = 晋升统考资格,统考保留线下(G1 口径:系统只出证据,晋升由人确认) |
范围控制:不做直播、不做线下签到、不做课程市场;只支撑"岗前课程 + 每日小训 + 不定期考试"三种形态。数据模型需新增:课程、课件、学习进度、考试、成绩(专项设计待出)。**节奏**:整体属阶段二;岗前课程最小版(课程列表 + 完成状态 + 入职任务包)视内容资源到位情况,可提前到阶段一试点后启动。
## 5. 阶段二不做什么(边界)
@@ -102,15 +119,18 @@
| 1 | 定位补充 | §1.2 补"交付载体为银城员工端 APP,三大功能:陪练/信息直达/人事入口" |
| 2 | 外部系统具名 | §7 人事/组织/认证的外部系统明确为**北森** |
| 3 | D6 复核 | **已确认(2026-07-07)**:APP 形态 = **H5 套壳 APP**(WebView 包装可安装、可推送),原生重写不做——守住 D6 工程量约束,满足"可安装"体验要求 |
| 4 | 新增范围 | 大喇叭信息平台、品检翻转、新问题挂榜共创补入业务范围(阶段二) |
| 5 | 培训模式表述 | 4.2/4.4 补立项会口径:线下集中培训/考试整体退位,转"知识库 + 陪练 + 每日趣味练习" |
| 4 | 新增范围 | 大喇叭信息平台、品检翻转、开放问题榜(原"新问题挂榜共创",形态见 §4.4)、学模块(§4.5)补入业务范围(阶段二) |
| 5 | 培训模式表述 | 4.2/4.4 补立项会口径:线下集中培训/考试整体退位,转"知识库 + 陪练 + 每日趣味练习";2026-07-08 补充:学模块承接课程化学习与不定期考试(§4.5),晋升统考保留线下 |
| 6 | 师徒演练修订 | BRD 4.4 师徒演练:定向师带徒经 2026-07-08 会议**否决不做**,替换为开放问题榜(§4.4)+ 主管传帮带工作台(二期计划 M4);建议 BRD 4.4 改写为"帮带机制"并引用两个承载形态 |
## 8. 风险与待确认
| 项 | 说明 |
|---|---|
| 半年会硬死线 | Alpha(2026-07-10)/Beta(2026-07-13 当周)节奏极紧;止损:半年会演示保"陪练 + 总结卡 + 1 条示范视频",大喇叭出原型图不上真功能 |
| 信息透明的口径 | "不过滤"与"有人维护口径"(立项会两位发言人有张力)→ 建议:发布走运营审核,**发布后不做分级隐藏**;员工评管理层单独评审 |
| 信息透明的口径 | "不过滤"与"有人维护口径"(立项会两位发言人有张力)→ 建议:发布走运营审核,**发布后不做分级隐藏**;员工评管理层已定按权限控制(§4.4) |
| 推送通道 | H5 无系统级推送;强触达依赖企微/短信,涉 D8(企微只列接口不实现)→ 阶段二开工前决策 |
| 积分体系提前 | 挂榜/品检/贡献命名都依赖 `incentive_point`(P1);阶段二开工时优先建积分最小闭环 |
| 积分体系提前 | 挂榜/品检/贡献命名都依赖 `incentive_point`(P1);阶段二开工时优先建积分最小闭环;**学分(晋升资格口径)与悬赏奖励(问题榜激励口径)分类型分账,防刷分:只认"被采纳/通过考试"等有审核动作的行为计学分**;激励规则(学分来源、分值、悬赏金额、徽章条件)走后台可配置的激励规则配置中心,不写死代码 |
| 载体口径 | 2026-07-08 客户回复:多端跳转议题**暂缓**,现阶段只保证能打开某个小程序的页面(最小跳转能力);不做统一入口工程,D6 维持 H5 套壳表述;企微统一入口/整合单一 APP 之争待后续再议(电子名片为独立产品不进主线) |
| 排行榜口径 | 2026-07-08 客户回复:排行榜**保留功能、可选择不用**——作为可开关配置项开发(默认关),上线与否由运营按项目选择;"先自我对比"仍是默认态,与"不做同侪排名"底线以开关方式兼容 |
| 北森接口未知 | 接口文档、账号体系、数据范围均未调研;阶段三开工前 2 周做技术摸底,失败兜底=快照同步继续用 |
+1
View File
@@ -7,6 +7,7 @@ VITE_APP_ENV = 'development'
# 开发环境
VITE_APP_BASE_API = '/dev-api'
VITE_APP_PROXY_TARGET = 'https://wygj-api.localhost'
# 应用访问路径 例如使用前缀 /admin/
VITE_APP_CONTEXT_PATH = '/'
+258
View File
@@ -0,0 +1,258 @@
import axios from 'axios';
import request, { globalHeaders } from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type ReviewRecord = {
id: number;
sessionId: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
extPartyId: string;
};
export type ReviewDetail = ReviewRecord & {
mentorRewrite: string;
aiComment: string;
reviewAdvice: string;
incentivePoint: number;
scoreItems: Array<{ label: string; value: number | string; note: string }>;
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string; audioOssId?: number | string }>;
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
};
export type ReviewAdviceRequest = {
reviewer?: string;
advice?: string;
incentivePoint?: number;
nextScenarioId?: string;
nextReason?: string;
};
export type CompetencyProfile = {
score: number;
completed: number;
pendingReview: number;
trainingMinutes: number;
contribution: number;
assessmentScore: number;
aiLevel: string;
dimensions: Array<{ label: string; value: number | string; note: string }>;
growthPath: Array<{ level: string; progress: number; status: string; evidence: string }>;
};
export type PracticeAlert = {
type: string;
title: string;
desc: string;
severity: string;
extPartyId: string;
};
export type PracticeAssignment = {
id: number;
extPartyId: string;
traineeName: string;
scenarioId: string;
scenarioName: string;
source: string;
reason: string;
status: string;
createTime: string;
question?: string;
referenceAnswer?: string;
userAnswer?: string;
feedback?: string;
correct?: boolean;
};
export type PracticeAssignmentRequest = {
extPartyId: string;
traineeName?: string;
scenarioId: string;
scenarioName?: string;
source: string;
reason: string;
};
export type PromotionEvidence = {
extPartyId: string;
currentLevel: string;
assessmentScore: number;
trainingMinutes: number;
contribution: number;
completed: number;
pendingReview: number;
dimensions: Array<{ label: string; value: number | string; note: string }>;
latestRecords: ReviewRecord[];
assignments: PracticeAssignment[];
evidenceItems: string[];
};
export type OrgSyncRequest = {
dryRun?: boolean;
replaceExisting?: boolean;
pageSize?: number;
maxPages?: number;
};
export type OrgSyncResponse = {
dryRun: boolean;
replaceExisting: boolean;
source: string;
companyCount: number;
departmentCount: number;
employeeCount: number;
syncedCount: number;
skippedCount: number;
phoneLinked: number;
maskedPhone: number;
suspectText: number;
warnings: string[];
};
export type OrgProjectOption = {
projectCode: string;
projectName: string;
};
export type OrgPersonRow = {
id: number;
projectCode: string;
projectName: string;
deptName: string;
extPartyId: string;
personName: string;
positionName: string;
positionLevel: string;
employmentStatus: string;
snapshotDate: string;
};
export type OrgSnapshotParams = {
keyword?: string;
projectCode?: string;
positionLevel?: string;
status?: string;
pageNum?: number;
pageSize?: number;
limit?: number;
};
export type OrgSnapshotResponse = {
total: number;
active: number;
departed: number;
phoneLinked: number;
suspectText: number;
pageNum: number;
pageSize: number;
projects: OrgProjectOption[];
levels: string[];
rows: OrgPersonRow[];
};
export type ReviewListParams = {
projectCode?: string;
extPartyId?: string;
limit?: number;
};
export function listPracticeReviews(params?: ReviewListParams): Promise<ApiResult<ReviewRecord[]>> {
return request({
url: '/api/aihr/mobile/practice/reviews',
method: 'get',
params
});
}
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}`,
method: 'get'
});
}
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
return URL.createObjectURL(response.data);
}
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function getCompetencyProfile(extPartyId?: string): Promise<ApiResult<CompetencyProfile>> {
return request({
url: '/api/aihr/mobile/profile',
method: 'get',
params: { extPartyId }
});
}
export function getPromotionEvidence(extPartyId?: string): Promise<ApiResult<PromotionEvidence>> {
return request({
url: '/api/aihr/mobile/profile/promotion-evidence',
method: 'get',
params: { extPartyId }
});
}
export function listPracticeAlerts(): Promise<ApiResult<PracticeAlert[]>> {
return request({
url: '/api/aihr/mobile/practice/alerts',
method: 'get'
});
}
export function listPracticeAssignments(extPartyId?: string): Promise<ApiResult<PracticeAssignment[]>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'get',
params: { extPartyId }
});
}
export function createPracticeAssignment(data: PracticeAssignmentRequest): Promise<ApiResult<PracticeAssignment>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
return request({
url: '/api/aihr/org/sync',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function getOrgSnapshot(params: OrgSnapshotParams): Promise<ApiResult<OrgSnapshotResponse>> {
return request({
url: '/api/aihr/org/snapshot',
method: 'get',
params
});
}
+153
View File
@@ -12,6 +12,43 @@ export type PracticeStartRequest = {
mode: string;
};
export type PracticeScenarioResponse = {
id: string;
name: string;
position: string;
scenarioType: string;
projectType: string;
difficulty: number;
customerPersona: string;
openingText: string;
explicitNeed: string;
hiddenNeed: string;
irritants: string;
delights: string;
sopRefs: string;
successCriteria: string;
strategy: string;
goal: string;
enabled: boolean;
rubric?: {
code: string;
name: string;
version: string;
dimensions: Array<{ code: string; name: string; weight: number; description: string }>;
};
};
export type PracticeScenarioEnabledRequest = {
enabled: boolean;
};
export type PracticePrepCardResponse = {
keyPoints: string[];
redLines: string[];
scripts: string[];
promptVersion: string;
};
export type PracticeStartResponse = {
sessionId: string;
scenarioId: string;
@@ -34,6 +71,7 @@ export type PracticeTurnRequest = {
roundIndex: number;
traineeText: string;
traineeAudioUrl?: string;
traineeAudioOssId?: number;
};
export type PracticeTurnResponse = {
@@ -42,6 +80,7 @@ export type PracticeTurnResponse = {
emotion: number;
trust: number;
coachHint: string;
redFlag: boolean;
roundIndex: number;
finished: boolean;
};
@@ -70,6 +109,78 @@ export type PracticeFinishResponse = {
}>;
};
export type PracticeCalibrationRequest = {
reviewer?: string;
correctedTotal?: number;
correctedCompliance?: number;
correctedEmotion?: number;
correctedCommunication?: number;
correctedMarketing?: number;
reason?: string;
};
export type PracticeCalibrationResponse = {
id: number;
sessionId: string;
originalScore: number;
correctedScore: number;
reviewer: string;
reason: string;
status: string;
createTime: string;
};
export type PromptTemplateResponse = {
id: number;
code: string;
name: string;
category: string;
outputFormat: string;
systemPrompt: string;
template: string;
variables: string;
version: string;
enabled: boolean;
description: string;
updateTime: string;
};
export type PromptTemplateRequest = {
code: string;
name: string;
category: string;
outputFormat: string;
systemPrompt: string;
template: string;
variables: string;
version: string;
enabled: boolean;
description: string;
};
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
return request({
url: '/api/train/practice/scenarios',
method: 'get',
params
});
}
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
return request({
url: `/api/train/practice/scenarios/${id}/enabled`,
method: 'patch',
data
});
}
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
return request({
url: `/api/train/practice/scenarios/${id}/prep-card`,
method: 'get'
});
}
export function startPracticeSession(data: PracticeStartRequest): Promise<ApiResult<PracticeStartResponse>> {
return request({
url: '/api/train/practice/start',
@@ -96,3 +207,45 @@ export function finishPracticeSession(data: PracticeFinishRequest): Promise<ApiR
headers: { repeatSubmit: false }
});
}
export function calibratePracticeSession(sessionId: string, data: PracticeCalibrationRequest): Promise<ApiResult<PracticeCalibrationResponse>> {
return request({
url: `/api/train/practice/sessions/${sessionId}/calibration`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function exportPracticePilotCsv(params: { startDate: string; endDate: string }): Promise<Blob> {
return request({
url: '/api/train/practice/export',
method: 'get',
params,
responseType: 'blob'
}) as unknown as Promise<Blob>;
}
export function listPromptTemplates(params?: { category?: string; code?: string; enabled?: boolean }): Promise<ApiResult<PromptTemplateResponse[]>> {
return request({
url: '/api/aihr/prompt-templates',
method: 'get',
params
});
}
export function createPromptTemplate(data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
return request({
url: '/api/aihr/prompt-templates',
method: 'post',
data
});
}
export function updatePromptTemplate(id: number, data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
return request({
url: `/api/aihr/prompt-templates/${id}`,
method: 'put',
data
});
}
+20 -1
View File
@@ -36,6 +36,16 @@ export type SopSearchResponse = {
snippets: SopSnippet[];
training: string[];
records: SopRecord[];
keyPoints?: string[];
cautions?: string[];
reviewId?: number;
};
export type SopReviewResponse = {
id: number;
usable: boolean | null;
status: string;
reviewedAt: string;
};
export type SopUploadResponse = {
@@ -49,7 +59,7 @@ export type SopUploadResponse = {
snippets: SopSnippet[];
};
export function searchKnowledgeSop(data: { queryText: string; category: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
export function searchKnowledgeSop(data: { queryText: string; category: string; position?: string; source?: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
return request({
url: '/api/knowledge/search',
method: 'post',
@@ -68,6 +78,15 @@ export function uploadKnowledgeDoc(data: FormData): Promise<ApiResult<SopUploadR
});
}
export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer?: string; note?: string }): Promise<ApiResult<SopReviewResponse>> {
return request({
url: `/api/knowledge/reviews/${id}`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export type UploadEnqueueResponse = {
itemId: number;
batchId: string;
@@ -36,7 +36,19 @@ const route = useRoute();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const demoMenuTitles = new Set(['首页', 'AI面试', '三角色对练', '案例沉淀', 'SOP知识库', '资料处理', '模型配置']);
const demoMenuTitles = new Set([
'首页',
'AI面试',
'训练运营',
'三角色对练',
'复盘校准',
'案例沉淀',
'SOP知识库',
'资料处理',
'模型配置',
'组织与权限',
'成长激励'
]);
const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title;
const sidebarRouters = computed<RouteRecordRaw[]>(() =>
permissionStore.getSidebarRoutes().filter((route) => demoMenuTitles.has(String(getRouteMenuTitle(route))))
+34
View File
@@ -92,12 +92,20 @@ export const constantRoutes: RouteRecordRaw[] = [
path: '/train',
component: Layout,
redirect: '/train/practice',
alwaysShow: true,
meta: { title: '训练运营', icon: 'peoples' },
children: [
{
path: 'practice',
component: () => import('@/views/train/practice.vue'),
name: 'TrainPractice',
meta: { title: '三角色对练', icon: 'peoples' }
},
{
path: 'reviews',
component: () => import('@/views/train/reviews.vue'),
name: 'TrainReviews',
meta: { title: '复盘校准', icon: 'finish' }
}
]
},
@@ -152,6 +160,32 @@ export const constantRoutes: RouteRecordRaw[] = [
}
]
},
{
path: '/admin',
component: Layout,
redirect: '/admin/org-permission',
children: [
{
path: 'org-permission',
component: () => import('@/views/admin/org-permission.vue'),
name: 'OrgPermission',
meta: { title: '组织与权限', icon: 'tree' }
}
]
},
{
path: '/competency',
component: Layout,
redirect: '/competency/growth',
children: [
{
path: 'growth',
component: () => import('@/views/competency/growth.vue'),
name: 'CompetencyGrowth',
meta: { title: '成长激励', icon: 'chart' }
}
]
},
{
path: '/user',
component: Layout,
+463
View File
@@ -0,0 +1,463 @@
<template>
<div class="org-permission-page">
<section class="page-head">
<div>
<h1>组织与权限</h1>
<p>把组织人员快照、岗位映射和项目数据范围放到同一张运营视图,先支撑陪练试点权限边界。</p>
</div>
<div class="head-actions">
<el-switch v-model="dryRun" active-text="预检" inactive-text="写入" />
<el-button type="danger" :icon="Refresh" :loading="syncing" @click="runSync">同步预检</el-button>
</div>
</section>
<section class="metric-grid">
<article v-for="item in metrics" :key="item.label" class="metric-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</article>
</section>
<section class="panel usage-panel">
<div class="panel-title">
<h2>配置与使用</h2>
</div>
<el-table :data="usageRules" size="small">
<el-table-column prop="item" label="配置项" width="130" />
<el-table-column prop="source" label="同步字段" min-width="210" show-overflow-tooltip />
<el-table-column prop="configure" label="配置方式" min-width="220" show-overflow-tooltip />
<el-table-column prop="usage" label="系统使用" min-width="280" show-overflow-tooltip />
</el-table>
</section>
<section class="panel org-panel">
<div class="panel-title">
<h2>组织人员快照</h2>
<div class="snapshot-stats">
<el-tag>总数 {{ orgSnapshot?.total ?? 0 }}</el-tag>
<el-tag type="success">在职 {{ orgSnapshot?.active ?? 0 }}</el-tag>
<el-tag type="info">离职 {{ orgSnapshot?.departed ?? 0 }}</el-tag>
<el-tag :type="mobileLoginReady ? 'success' : 'warning'">可登录 {{ orgSnapshot?.phoneLinked ?? 0 }}</el-tag>
<el-tag v-if="orgSnapshot?.suspectText" type="danger">疑似乱码 {{ orgSnapshot.suspectText }}</el-tag>
</div>
</div>
<el-alert
v-if="orgQualityWarnings.length"
class="quality-alert"
type="warning"
:closable="false"
show-icon
>
<template #title>
{{ orgQualityWarnings.join(';') }}
</template>
</el-alert>
<div class="org-filters">
<el-input v-model="orgFilters.keyword" clearable placeholder="姓名/岗位/部门/外部ID" @keyup.enter="searchOrgSnapshot" />
<el-select v-model="orgFilters.projectCode" clearable placeholder="项目">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="orgFilters.positionLevel" clearable placeholder="层级">
<el-option v-for="item in orgSnapshot?.levels || []" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="orgFilters.status" clearable placeholder="状态">
<el-option label="在职" value="active" />
<el-option label="离职" value="departed" />
</el-select>
<el-button :icon="Refresh" :loading="orgLoading" @click="searchOrgSnapshot">查询</el-button>
</div>
<el-table v-loading="orgLoading" :data="orgSnapshot?.rows || []" size="small" empty-text="暂无组织人员快照">
<el-table-column prop="projectName" label="项目" min-width="150" show-overflow-tooltip />
<el-table-column prop="deptName" label="部门/条线" min-width="130" show-overflow-tooltip />
<el-table-column prop="personName" label="姓名" width="110" />
<el-table-column prop="positionName" label="岗位" min-width="140" show-overflow-tooltip />
<el-table-column prop="positionLevel" label="层级" width="100" />
<el-table-column prop="employmentStatus" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.employmentStatus === 'active' ? 'success' : 'info'" size="small">
{{ row.employmentStatus === 'active' ? '在职' : '离职' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="extPartyId" label="外部主体ID" min-width="150" show-overflow-tooltip />
<el-table-column prop="snapshotDate" label="快照日期" width="110" />
</el-table>
<pagination
v-show="(orgSnapshot?.total || 0) > 0"
v-model:page="orgPage.pageNum"
v-model:limit="orgPage.pageSize"
:total="orgSnapshot?.total || 0"
@pagination="loadOrgSnapshot"
/>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>身份三轴</h2>
</div>
<el-table :data="identityAxes" size="small">
<el-table-column prop="axis" label="轴" width="120" />
<el-table-column prop="source" label="当前数据源" width="180" />
<el-table-column prop="example" label="例子" min-width="200" />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.type">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
</article>
<article class="panel">
<div class="panel-title">
<h2>角色映射</h2>
</div>
<el-table :data="roleMappings" size="small">
<el-table-column prop="match" label="匹配" min-width="150" />
<el-table-column prop="role" label="本地角色" width="130" />
<el-table-column prop="scope" label="数据范围" min-width="160" />
<el-table-column prop="owner" label="负责人" width="100" />
</el-table>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>项目范围</h2>
</div>
<el-table :data="projectScopes" size="small" empty-text="组织同步后展示真实项目范围">
<el-table-column prop="project" label="项目" min-width="150" />
<el-table-column prop="type" label="业态" width="90" />
<el-table-column prop="supervisor" label="主管" width="100" />
<el-table-column prop="visibleData" label="可见数据" min-width="210" />
</el-table>
</article>
<article class="panel sync-panel">
<div class="panel-title">
<h2>同步结果</h2>
</div>
<div v-if="syncResult" class="sync-result">
<div>
<span>模式</span><strong>{{ syncResult.dryRun ? '预检' : '写入' }}</strong>
</div>
<div>
<span>来源</span><strong>{{ syncResult.source }}</strong>
</div>
<div>
<span>公司/部门/员工</span>
<strong>{{ syncResult.companyCount }} / {{ syncResult.departmentCount }} / {{ syncResult.employeeCount }}</strong>
</div>
<div>
<span>写入/跳过</span><strong>{{ syncResult.syncedCount }} / {{ syncResult.skippedCount }}</strong>
</div>
<div>
<span>手机号/疑似乱码</span><strong>{{ syncResult.phoneLinked }} / {{ syncResult.suspectText }}</strong>
</div>
<div>
<span>脱敏手机号</span><strong>{{ syncResult.maskedPhone }}</strong>
</div>
<p v-if="syncResult.warnings?.length">{{ syncResult.warnings.join(';') }}</p>
<p v-else>同步接口已返回结果。</p>
</div>
<el-empty v-else description="尚未执行同步预检" />
</article>
</section>
</div>
</template>
<script setup name="OrgPermission" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { getOrgSnapshot, syncOrgSnapshot, type OrgSnapshotResponse, type OrgSyncResponse } from '@/api/aihr/mobile-admin';
const dryRun = ref(true);
const syncing = ref(false);
const syncResult = ref<OrgSyncResponse | null>(null);
const orgLoading = ref(false);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const orgFilters = reactive({
keyword: '',
projectCode: '',
positionLevel: '',
status: 'active'
});
const orgPage = reactive({
pageNum: 1,
pageSize: 20
});
const metrics = [
{ label: '权威组织源', value: '快照', note: '外部同步写入 aihr_org_snapshot' },
{ label: '数据范围主体', value: '项目', note: '使用 project_code / project_name' },
{ label: '试点岗位', value: '管家(生活顾问)', note: '使用 position_name / position_level' },
{ label: 'SSO', value: '暂缓', note: '北森对接归阶段三' }
];
const usageRules = [
{
item: '人员主键',
source: 'ext_party_id',
configure: '同步时自动写入,不手工编辑',
usage: '关联训练记录、复盘记录、能力画像、移动端账号'
},
{
item: '项目范围',
source: 'project_code / project_name',
configure: '从组织快照自动生成项目筛选',
usage: '限制主管查看本项目员工、训练记录、复盘和画像'
},
{
item: '岗位条线',
source: 'dept_name / position_name',
configure: '按同步岗位名称识别生活顾问、客服、工程等条线',
usage: '筛选试点对象、匹配训练场景和统计岗位覆盖'
},
{
item: '岗位层级',
source: 'position_level',
configure: '同步时由岗位名称派生: 一线 / 主管 / 项目经理',
usage: '区分学员、主管复盘人、项目管理视角'
},
{
item: '在职状态',
source: 'employment_status',
configure: '同步 active / departed,默认只看 active',
usage: '离职人员不进入默认派训和运营统计'
}
];
const identityAxes = [
{ axis: '横向条线', source: 'dept_name / position_name', example: '管家/客服、工程、保洁、保安', status: '已接入', type: 'success' },
{ axis: '纵向层级', source: 'position_level', example: '一线、主管、项目经理', status: '最小集', type: 'warning' },
{ axis: '管辖范围', source: 'project_code / project_name', example: '单项目 / 多项目 / 城市 / 集团', status: 'P0', type: 'danger' }
];
const roleMappings = [
{ match: 'position_name 包含 管家/生活顾问', role: '学员', scope: '本人训练与今日安排', owner: 'HR' },
{ match: 'position_level = 主管/项目经理', role: '主管', scope: '本项目团队训练与复盘', owner: '项目经理' },
{ match: '管理后台 role = hr_operator', role: 'HR运营', scope: '场景、Rubric、候选资料审核', owner: 'HR' },
{ match: '管理后台 admin', role: '管理员', scope: '模型、资料处理、组织同步', owner: '技术' }
];
const projectScopes = computed(() =>
(orgSnapshot.value?.projects || []).slice(0, 8).map((item) => ({
project: item.projectName,
type: '住宅',
supervisor: '按组织层级',
visibleData: '本项目员工、训练记录、复盘与画像'
}))
);
const mobileLoginReady = computed(() => {
const snapshot = orgSnapshot.value;
return Boolean(snapshot?.total && snapshot.phoneLinked >= snapshot.active);
});
const orgQualityWarnings = computed(() => {
const snapshot = orgSnapshot.value;
if (!snapshot?.total) {
return [];
}
const warnings: string[] = [];
if (snapshot.phoneLinked < snapshot.active) {
warnings.push(`移动端登录映射不完整:当前筛选 ${snapshot.active} 名在职人员,仅 ${snapshot.phoneLinked} 名带手机号`);
}
if (snapshot.suspectText > 0) {
warnings.push(`组织字段存在 ${snapshot.suspectText} 条疑似乱码,请检查同步源或 SQL 导入字符集`);
}
return warnings;
});
const loadOrgSnapshot = async () => {
orgLoading.value = true;
try {
const result = await getOrgSnapshot({ ...orgFilters, pageNum: orgPage.pageNum, pageSize: orgPage.pageSize });
orgSnapshot.value = result.data;
} catch (error) {
ElMessage.error('组织人员快照加载失败');
} finally {
orgLoading.value = false;
}
};
const searchOrgSnapshot = () => {
orgPage.pageNum = 1;
loadOrgSnapshot();
};
const runSync = async () => {
if (!dryRun.value) {
await ElMessageBox.confirm('写入模式会覆盖当前租户组织快照,确认继续吗?', '组织同步', { type: 'warning' });
}
syncing.value = true;
try {
const result = await syncOrgSnapshot({ dryRun: dryRun.value, replaceExisting: true, pageSize: 500 });
syncResult.value = result.data;
ElMessage.success(dryRun.value ? '组织同步预检完成' : '组织快照已同步');
if (!dryRun.value) {
await loadOrgSnapshot();
}
} catch (error) {
ElMessage.error('组织同步接口不可用,请检查外部同步配置');
} finally {
syncing.value = false;
}
};
onMounted(loadOrgSnapshot);
</script>
<style scoped lang="scss">
.org-permission-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
}
.metric-grid,
.main-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.main-grid {
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr);
}
.metric-card {
padding: 16px;
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 8px 0;
font-size: 22px;
}
}
.panel {
padding: 18px;
}
.panel-title h2 {
margin: 0 0 14px;
font-size: 17px;
}
.usage-panel,
.org-panel {
margin-top: 16px;
}
.org-panel .panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.snapshot-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.org-filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(130px, 0.4fr)) auto;
gap: 10px;
margin-bottom: 14px;
}
.quality-alert {
margin-bottom: 14px;
}
.sync-result {
display: grid;
gap: 10px;
div {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #eef2f7;
padding-bottom: 8px;
}
span {
color: #667085;
}
p {
margin: 8px 0 0;
color: #475467;
}
}
@media (max-width: 980px) {
.page-head,
.main-grid,
.org-filters {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.page-head {
display: block;
}
.head-actions {
margin-top: 14px;
}
}
</style>
+478
View File
@@ -0,0 +1,478 @@
<template>
<div class="growth-page">
<section class="page-head">
<div>
<h1>成长激励</h1>
<p>展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。</p>
</div>
<div class="head-actions">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="extPartyId" clearable filterable allow-create default-first-option placeholder="组织人员或 extPartyId">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
<el-button :icon="Download" :disabled="!evidence" @click="exportEvidenceCsv">导出证据包</el-button>
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</section>
<section class="metric-grid">
<article class="metric-card">
<span>AI测评分</span>
<strong>{{ profile?.assessmentScore ?? '-' }}</strong>
<small>AI辅助参考,人可否决</small>
</article>
<article class="metric-card">
<span>当前等级</span>
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
<small>初/中/高级生活顾问</small>
</article>
<article class="metric-card">
<span>训练时长</span>
<strong>{{ profile?.trainingMinutes ?? 0 }} 分钟</strong>
<small>来自移动端训练记录</small>
</article>
<article class="metric-card">
<span>贡献度</span>
<strong>{{ profile?.contribution ?? 0 }}</strong>
<small>复盘学分 + 训练完成</small>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>能力维度</h2>
</div>
<div class="dimension-list">
<div v-for="item in profile?.dimensions || []" :key="item.label" class="dimension-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.note }}</span>
</div>
<el-progress :percentage="Number(item.value) || 0" :stroke-width="10" color="#e60012" />
</div>
</div>
</article>
<article class="panel">
<div class="panel-title">
<h2>等级路径</h2>
</div>
<div class="stage-list">
<div v-for="stage in profile?.growthPath || []" :key="stage.level" class="stage-item">
<div class="stage-head">
<strong>{{ stage.level }}</strong>
<el-tag :type="stage.status === '已达成' ? 'success' : 'info'">{{ stage.status }}</el-tag>
</div>
<el-progress :percentage="stage.progress" :stroke-width="9" />
<small>{{ stage.evidence }}</small>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>晋升证据包</h2>
</div>
<ul class="evidence-list">
<li v-for="item in evidence?.evidenceItems || []" :key="item">{{ item }}</li>
</ul>
</article>
<article class="panel">
<div class="panel-title">
<h2>预警</h2>
</div>
<div class="alert-list">
<div v-for="item in alerts" :key="item.title" class="alert-item" :class="`severity-${item.severity}`">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>待训任务</h2>
<el-button type="danger" plain @click="createManualAssignment">派发专项</el-button>
</div>
<div class="assignment-form">
<el-select v-model="assignmentScenarioId" filterable placeholder="选择专项场景">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
<el-input v-model="assignmentReason" placeholder="派发原因" />
</div>
<el-table :data="assignments" size="small">
<el-table-column prop="traineeName" label="学员" width="110" />
<el-table-column prop="scenarioName" label="训练" min-width="160" />
<el-table-column prop="source" label="来源" width="92" />
<el-table-column prop="status" label="状态" width="92" />
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
</el-table>
</article>
<article class="panel">
<div class="panel-title">
<h2>学分规则最小集</h2>
<el-switch v-model="leaderboardEnabled" active-text="排行榜" inactive-text="默认关闭" />
</div>
<el-table :data="rules" size="small">
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="points" label="分值" width="90" />
<el-table-column prop="guard" label="防刷分口径" min-width="220" />
</el-table>
</article>
</section>
</div>
</template>
<script setup name="CompetencyGrowth" lang="ts">
import { Download, Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import FileSaver from 'file-saver';
import { computed, onMounted, ref, watch } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import {
createPracticeAssignment,
getCompetencyProfile,
getOrgSnapshot,
getPromotionEvidence,
listPracticeAlerts,
listPracticeAssignments,
type CompetencyProfile,
type OrgSnapshotResponse,
type PracticeAlert,
type PracticeAssignment,
type PromotionEvidence
} from '@/api/aihr/mobile-admin';
const extPartyId = ref('');
const selectedProjectCode = ref('');
const loading = ref(false);
const leaderboardEnabled = ref(false);
const profile = ref<CompetencyProfile | null>(null);
const evidence = ref<PromotionEvidence | null>(null);
const alerts = ref<PracticeAlert[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const assignmentScenarioId = ref('');
const assignmentReason = ref('HR在成长激励页派发专项训练');
const rules = [
{ source: '训练完成', points: '+5', guard: '只认完成记录,不按点击或打开计分' },
{ source: '主管复盘采纳', points: '+0~100', guard: '由主管复盘时人工确认' },
{ source: '案例被采纳', points: '+20', guard: '必须审核入库并署名' },
{ source: '课程考试通过', points: '+10', guard: '阶段二课程考试上线后启用' }
];
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const selectedPerson = computed(() => orgPeople.value.find((item) => item.extPartyId === extPartyId.value));
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadAll = async () => {
loading.value = true;
const party = extPartyId.value.trim();
try {
const [profileResult, evidenceResult, alertsResult, assignmentsResult] = await Promise.all([
getCompetencyProfile(party),
getPromotionEvidence(party),
listPracticeAlerts(),
listPracticeAssignments(party)
]);
profile.value = profileResult.data;
evidence.value = evidenceResult.data;
alerts.value = alertsResult.data || [];
assignments.value = assignmentsResult.data || [];
} catch (error) {
ElMessage.error('成长数据加载失败');
} finally {
loading.value = false;
}
};
const loadScenarios = async () => {
try {
scenarios.value = ((await listPracticeScenarios()).data || []).filter((item) => item.enabled);
assignmentScenarioId.value = assignmentScenarioId.value || scenarios.value[0]?.id || '';
} catch (error) {
scenarios.value = [];
}
};
const createManualAssignment = async () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
if (!party) {
ElMessage.warning('请先输入员工手机号或 extPartyId');
return;
}
const scenario = scenarios.value.find((item) => item.id === assignmentScenarioId.value);
if (!scenario) {
ElMessage.warning('请先选择专项场景');
return;
}
try {
await createPracticeAssignment({
extPartyId: party,
traineeName: selectedPerson.value?.personName || party,
scenarioId: scenario.id,
scenarioName: scenario.name,
source: 'manual',
reason: assignmentReason.value.trim() || 'HR在成长激励页派发专项训练'
});
ElMessage.success('专项训练已派发');
await loadAll();
} catch (error) {
ElMessage.error('专项训练派发失败');
}
};
const csvCell = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const appendCsvRow = (rows: string[], ...values: unknown[]) => {
rows.push(values.map(csvCell).join(','));
};
const exportEvidenceCsv = () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId?.trim() || 'all';
if (!evidence.value) {
ElMessage.warning('证据包数据加载后才能导出');
return;
}
const rows: string[] = [];
const personName = selectedPerson.value?.personName || (party === 'all' ? '全部员工' : party);
appendCsvRow(rows, 'section', 'field', 'value');
appendCsvRow(rows, 'summary', 'employee', personName);
appendCsvRow(rows, 'summary', 'ext_party_id', party);
appendCsvRow(rows, 'summary', 'current_level', evidence.value.currentLevel);
appendCsvRow(rows, 'summary', 'assessment_score', evidence.value.assessmentScore);
appendCsvRow(rows, 'summary', 'training_minutes', evidence.value.trainingMinutes);
appendCsvRow(rows, 'summary', 'contribution', evidence.value.contribution);
appendCsvRow(rows, 'summary', 'completed', evidence.value.completed);
appendCsvRow(rows, 'summary', 'pending_review', evidence.value.pendingReview);
profile.value?.growthPath.forEach((stage) =>
appendCsvRow(rows, 'growth_path', `${stage.level} ${stage.status}`, `${stage.progress}% ${stage.evidence}`)
);
evidence.value.dimensions.forEach((item) => appendCsvRow(rows, 'dimension', item.label, `${item.value} ${item.note}`));
evidence.value.evidenceItems.forEach((item, index) => appendCsvRow(rows, 'evidence_item', index + 1, item));
evidence.value.latestRecords.forEach((item) =>
appendCsvRow(rows, 'latest_record', item.scene, `${item.score} ${item.status} ${item.time} ${item.summary}`)
);
evidence.value.assignments.forEach((item) =>
appendCsvRow(rows, 'assignment', item.scenarioName, `${item.status} ${item.createTime} ${item.reason}`)
);
FileSaver.saveAs(new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), `aihr-promotion-evidence-${party}.csv`);
ElMessage.success('成长证据包 CSV 已导出');
};
watch(selectedProjectCode, async () => {
extPartyId.value = '';
await loadOrgSnapshot();
});
watch(extPartyId, () => {
loadAll();
});
onMounted(() => {
loadOrgSnapshot();
loadScenarios();
loadAll();
});
</script>
<style scoped lang="scss">
.growth-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.head-actions {
display: grid;
grid-template-columns: 220px 280px auto auto;
gap: 10px;
}
.metric-grid,
.main-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.main-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.metric-card,
.panel {
padding: 18px;
}
.metric-card {
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 8px 0;
font-size: 24px;
}
}
.panel-title,
.stage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title {
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.assignment-form {
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(220px, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.dimension-list,
.stage-list,
.alert-list {
display: grid;
gap: 14px;
}
.dimension-item strong,
.stage-item strong {
display: block;
margin-bottom: 4px;
}
.dimension-item span,
.stage-item small {
display: block;
margin-bottom: 8px;
color: #667085;
}
.evidence-list {
margin: 0;
padding-left: 18px;
li {
margin-bottom: 10px;
color: #475467;
}
}
.alert-item {
border-radius: 8px;
padding: 12px;
background: #f8fafc;
strong,
span {
display: block;
}
span {
margin-top: 4px;
color: #667085;
}
}
.severity-high {
background: #fff1f3;
}
.severity-medium {
background: #fff7ed;
}
@media (max-width: 1080px) {
.page-head,
.main-grid,
.assignment-form,
.metric-grid {
grid-template-columns: 1fr;
}
.page-head {
display: block;
}
.head-actions {
display: grid;
grid-template-columns: 1fr;
margin-top: 14px;
}
}
</style>
+19 -16
View File
@@ -19,7 +19,6 @@
</div>
<div class="entry-actions">
<el-button type="danger" :disabled="!item.path" @click="goFeature(item.path)">进入功能</el-button>
<el-button>查看样例</el-button>
</div>
</article>
</section>
@@ -46,7 +45,7 @@
<article class="panel risk-panel">
<div class="panel-title">
<h2>演示待办与风险</h2>
<h2>验收待办与风险</h2>
</div>
<ul class="risk-list">
<li v-for="risk in risks" :key="risk.name">
@@ -78,9 +77,9 @@
</el-table-column>
<el-table-column prop="time" label="时间" width="112" />
</el-table>
<div class="panel-link">
<button type="button" class="panel-link" @click="goFeature('/train/practice')">
查看更多 <el-icon><ArrowRight /></el-icon>
</div>
</button>
</article>
<article class="panel pipeline-panel">
@@ -102,9 +101,9 @@
<span>待审核:{{ pipelineSummary.pending }}</span>
<span>已入库:{{ pipelineSummary.completed }}</span>
</div>
<div class="panel-link">
<button type="button" class="panel-link" @click="goFeature('/knowledge/cases')">
进入案例沉淀 <el-icon><ArrowRight /></el-icon>
</div>
</button>
</article>
<article class="panel metrics-panel">
@@ -121,9 +120,9 @@
</div>
</div>
</div>
<div class="panel-link">
<button type="button" class="panel-link" @click="goFeature('/competency/growth')">
查看指标详情 <el-icon><ArrowRight /></el-icon>
</div>
</button>
</article>
</section>
</div>
@@ -148,8 +147,7 @@ import {
Switch,
Tickets,
TrendCharts,
UserFilled,
VideoCamera
UserFilled
} from '@element-plus/icons-vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -163,7 +161,7 @@ const emptyDashboard: DashboardOverview = {
stages: [
{ step: 1, name: '选:AI面试', desc: '题目与评估', label: '待接入', state: 'idle', tagType: 'info' },
{ step: 2, name: '用:岗位适配', desc: '组织快照', label: '待同步', state: 'idle', tagType: 'info' },
{ step: 3, name: '育:三角色对练', desc: '实战陪练', label: '待演示', state: 'active', tagType: 'danger' },
{ step: 3, name: '育:三角色对练', desc: '实战陪练', label: '待跑通', state: 'active', tagType: 'danger' },
{ step: 4, name: '留:成长追踪', desc: '能力画像', label: '待积累', state: 'idle', tagType: 'info' },
{ step: 5, name: '案例沉淀', desc: '知识与复用', label: '待入库', state: 'idle', tagType: 'info' }
],
@@ -179,7 +177,7 @@ const emptyDashboard: DashboardOverview = {
{ name: 'ASR 转写', count: '0 条', state: 'todo' },
{ name: 'AI 整理', count: '0 条', state: 'todo' },
{ name: '审核入库', count: '0 条', state: 'todo' },
{ name: '样片展示', count: '0 条', state: 'todo' }
{ name: '培训复用', count: '0 条', state: 'todo' }
],
pipelineSummary: { todayNew: '0 条', pending: '0 条', completed: '0 条' },
metrics: [
@@ -199,7 +197,7 @@ const baseHeroEntries = [
key: 'interview',
title: 'AI面试出题',
desc: '基于岗位画像与 JD,一键生成面试题目',
status: '可演示',
status: '可试用',
type: 'success',
icon: DocumentChecked,
path: '/recruit/interview'
@@ -217,7 +215,7 @@ const baseHeroEntries = [
key: 'case',
title: '案例语音整理',
desc: '语音上传,AI 整理为结构化案例',
status: '样片兜底',
status: '待入库',
type: 'warning',
icon: Microphone,
path: '/knowledge/cases'
@@ -247,7 +245,7 @@ const stages = computed(() => displayDashboard.value.stages);
const records = computed(() => displayDashboard.value.records);
const pipelineSummary = computed(() => displayDashboard.value.pipelineSummary);
const pipelineIcons = [FolderChecked, Histogram, Cpu, Tickets, VideoCamera];
const pipelineIcons = [FolderChecked, Histogram, Cpu, Tickets, Notebook];
const pipeline = computed(() =>
displayDashboard.value.pipeline.map((step, index) => ({
...step,
@@ -262,7 +260,7 @@ const riskIcon = (risk: DashboardRisk) => {
if (risk.name.includes('SOP')) return Tickets;
if (risk.name.includes('ASR')) return Headset;
if (risk.name.includes('审核')) return Finished;
return VideoCamera;
return Notebook;
};
const metricIcon = (metric: DashboardMetric) => {
@@ -702,7 +700,12 @@ onMounted(loadDashboard);
justify-content: flex-end;
gap: 4px;
margin-top: 14px;
margin-left: auto;
padding: 0;
border: 0;
background: transparent;
color: #d70f20;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
+8 -20
View File
@@ -68,14 +68,9 @@
<small>{{ step.note }}</small>
</div>
</div>
<div class="sample-card" :class="{ active: sampleVisible }">
<strong>视频样片</strong>
<p>
{{
sampleVisible ? '已命中车库积水投诉处理样片,可用于演示案例沉淀后的培训复用。' : '案例转视频本期只展示预渲染样片,不现场调用视频生成。'
}}
</p>
<el-button :icon="VideoCamera" @click="viewSample">查看样片</el-button>
<div class="sample-card" :class="{ active: stored }">
<strong>复用状态</strong>
<p>{{ stored ? apiSampleHint || '案例已入库,可用于训练复用。' : '案例入库后进入训练复用;本期不展示视频样片。' }}</p>
</div>
</article>
</section>
@@ -98,7 +93,7 @@
</template>
<script setup name="KnowledgeCases" lang="ts">
import { DocumentChecked, FolderChecked, Microphone, Tickets, VideoCamera } from '@element-plus/icons-vue';
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -111,7 +106,6 @@ const audioUploaded = ref(false);
const organized = ref(false);
const reviewed = ref(false);
const stored = ref(false);
const sampleVisible = ref(false);
const caseId = ref('');
const apiMode = ref<'idle' | 'api' | 'error'>('idle');
const apiFileName = ref('');
@@ -167,10 +161,10 @@ const pipeline = computed(() => [
state: stored.value ? 'done' : reviewed.value ? 'active' : 'todo'
},
{
name: '样片展示',
count: sampleVisible.value ? '1 条' : '5 条',
note: sampleVisible.value ? '已打开预渲染样片' : '演示兜底',
state: sampleVisible.value ? 'done' : 'todo'
name: '培训复用',
count: stored.value ? '1 条' : '0 条',
note: stored.value ? apiSampleHint.value || '已入库' : '未入库',
state: stored.value ? 'done' : 'todo'
}
]);
@@ -216,7 +210,6 @@ async function uploadAudio(file: File) {
organized.value = false;
reviewed.value = false;
stored.value = false;
sampleVisible.value = false;
apiSummary.value = [];
apiRecord.value = null;
apiSampleHint.value = '';
@@ -296,11 +289,6 @@ async function storeCase() {
ElMessage.error('后端案例入库 API 不可用');
}
}
function viewSample() {
sampleVisible.value = true;
ElMessage.info(apiSampleHint.value || '已打开预渲染样片兜底');
}
</script>
<style lang="scss" scoped>
+1 -19
View File
@@ -93,7 +93,6 @@
</span>
</button>
</div>
<button class="new-category" type="button">+ 新建分类</button>
</aside>
<main class="panel task-panel">
@@ -145,12 +144,6 @@
</el-table-column>
<el-table-column prop="summary" label="AI摘要" min-width="230" show-overflow-tooltip />
<el-table-column prop="updatedAt" label="更新时间" width="138" />
<el-table-column label="操作" width="92" fixed="right">
<template #default="{ row }">
<el-button link type="primary">{{ row.action }}</el-button>
<el-button v-if="row.status === '失败'" link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<footer class="table-footer">
@@ -356,7 +349,7 @@ const seedOverview: ProcessingOverview = {
),
task(2, '住宅 SOP', '物业收费催缴流程.pdf', 'PDF', '8.1MB', '解析中', 'primary', 62, 0, '待向量化', 'warning', '2026-07-02 14:28', '查看', ''),
task(3, '培训课件', '巡检标准培训课件.pptx', 'PPT', '42MB', '等待解析', 'warning', 0, 0, '待处理', 'warning', '2026-07-02 14:25', '开始', ''),
task(4, '图片素材', '工单照片样例.zip', '图片', '286MB', 'OCR排队', 'primary', 0, 0, '待处理', 'warning', '2026-07-02 14:20', '查看', ''),
task(4, '图片素材', '工单照片归档.zip', '图片', '286MB', 'OCR排队', 'primary', 0, 0, '待处理', 'warning', '2026-07-02 14:20', '查看', ''),
task(
5,
'规章制度',
@@ -1053,17 +1046,6 @@ function fileBadgeClass(type: string) {
background: #e6f7ee;
}
.new-category {
width: 100%;
margin-top: 14px;
border: 0;
border-top: 1px solid #edf0f5;
background: transparent;
padding-top: 14px;
color: #657184;
cursor: pointer;
}
.task-title {
align-items: center;
}
+55 -3
View File
@@ -50,6 +50,13 @@
<strong>AI 回答</strong>
<p>{{ answerText }}</p>
<span>引用:{{ referenceText }}</span>
<div class="review-row">
<small>{{ reviewMessage }}</small>
<div>
<el-button size="small" type="success" :loading="reviewSaving" :disabled="!canReview" @click="markReview(true)">可用</el-button>
<el-button size="small" :loading="reviewSaving" :disabled="!canReview" @click="markReview(false)">不可用</el-button>
</div>
</div>
</div>
<div v-else class="empty-state">点击检索后展示命中文档、原文片段和带引用的 AI 回答。</div>
<div v-if="searched" class="doc-list">
@@ -105,7 +112,7 @@ import { ElMessage } from 'element-plus';
import type { UploadFile } from 'element-plus';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
const router = useRouter();
@@ -203,6 +210,8 @@ const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiResult = ref<SopSearchResponse | null>(null);
const uploading = ref(false);
const uploadedRecords = ref<SopRecord[]>([]);
const reviewSaving = ref(false);
const reviewStatus = ref('');
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
@@ -213,13 +222,19 @@ const training = computed(() => apiResult.value?.training ?? activeSeed.value.tr
const apiPill = computed(() => {
if (apiMode.value === 'api') return { label: '后端 API', type: 'pill-primary' };
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
return { label: '本地 seed 演示', type: 'pill-primary' };
return { label: '本地知识兜底', type: 'pill-primary' };
});
const flowStatus = computed(() => {
if (trainingGenerated.value) return { label: '已完成闭环', type: 'pill-success' };
if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' };
return { label: '可引用 SOP 内容', type: 'pill-success' };
});
const canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId));
const reviewMessage = computed(() => {
if (reviewStatus.value) return reviewStatus.value;
if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`;
return '后端检索后可评审';
});
const seedRecords = [
{
@@ -257,10 +272,12 @@ function selectCategory(item: Category) {
trainingGenerated.value = false;
apiMode.value = 'seed';
apiResult.value = null;
reviewStatus.value = '';
}
async function runSearch() {
trainingGenerated.value = false;
reviewStatus.value = '';
try {
const { data } = await searchKnowledgeSop({
queryText: queryText.value,
@@ -275,7 +292,29 @@ async function runSearch() {
apiResult.value = null;
apiMode.value = 'fallback';
searched.value = true;
ElMessage.warning('后端 SOP 检索 API 不可用,已回退本地 seed 知识库');
ElMessage.warning('后端 SOP 检索 API 不可用,已使用本地知识兜底');
}
}
async function markReview(usable: boolean) {
const reviewId = apiResult.value?.reviewId;
if (!reviewId) {
ElMessage.warning('请先完成一次后端 SOP 检索');
return;
}
reviewSaving.value = true;
try {
const { data } = await reviewKnowledgeSop(reviewId, {
usable,
reviewer: '管理端评审',
note: usable ? '回答可用于试点问答' : '回答需补充或修正'
});
reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`;
ElMessage.success('SOP 问答评审已记录');
} catch {
ElMessage.error('SOP 问答评审失败');
} finally {
reviewSaving.value = false;
}
}
@@ -442,6 +481,19 @@ async function generateTraining() {
}
}
.review-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
small {
color: #657184;
font-size: 12px;
}
}
.empty-state,
.training-card {
padding: 14px;
+5 -24
View File
@@ -9,16 +9,16 @@
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetFlow">重置演示</el-button>
<el-button type="danger" :icon="Refresh" @click="resetFlow">重置流程</el-button>
</div>
</section>
<section class="flow-strip">
<button v-for="step in flowSteps" :key="step.title" type="button" class="flow-step" :class="{ active: step.active, done: step.done }">
<div v-for="step in flowSteps" :key="step.title" class="flow-step" :class="{ active: step.active, done: step.done }">
<span>{{ step.index }}</span>
<strong>{{ step.title }}</strong>
<small>{{ step.desc }}</small>
</button>
</div>
</section>
<section class="interview-grid">
@@ -105,21 +105,13 @@
<div class="answer-box">
<div class="answer-head">
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
<el-button text type="danger" @click="fillSampleAnswer">填入参考回答</el-button>
</div>
<el-input
v-model="answers[activeQuestion.id]"
type="textarea"
:rows="6"
resize="none"
placeholder="输入候选人真实回答,或点击“填入参考回答”快速推进测试"
/>
<el-input v-model="answers[activeQuestion.id]" type="textarea" :rows="6" resize="none" placeholder="输入候选人真实回答" />
</div>
<div class="action-row question-actions">
<el-button @click="moveQuestion(-1)">上一题</el-button>
<el-button @click="moveQuestion(1)">下一题</el-button>
<el-button @click="fillAllSampleAnswers">填满参考回答</el-button>
<el-button @click="saveDraft">保存草稿</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
</div>
@@ -518,17 +510,6 @@ const generateQuestions = async () => {
generated.value = true;
};
const fillSampleAnswer = () => {
answers[activeQuestion.value.id] = activeQuestion.value.sampleAnswer;
};
const fillAllSampleAnswers = () => {
questions.value.forEach((question) => {
answers[question.id] = question.sampleAnswer;
});
ElMessage.success('已填入全部参考回答');
};
const moveQuestion = (offset: number) => {
const index = questions.value.findIndex((question) => question.id === activeQuestion.value.id);
const nextIndex = Math.min(Math.max(index + offset, 0), questions.value.length - 1);
@@ -555,7 +536,7 @@ const saveDraft = async () => {
ElMessage.warning('后端暂存 API 不可用,已保留本地草稿');
}
}
ElMessage.success('草稿已暂存到本地演示状态');
ElMessage.success('草稿已暂存到当前流程');
};
const mapApiScoreResult = (data: InterviewFinishResponse): CandidateSeed['scoreResult'] => ({
+198 -14
View File
@@ -166,9 +166,41 @@
</el-table>
</section>
<section class="panel prompt-panel" v-loading="promptLoading">
<div class="panel-title with-actions">
<h2>提示词模板</h2>
<div>
<el-button :icon="Refresh" @click="loadPromptTemplates">刷新</el-button>
<el-button type="danger" :icon="Plus" @click="openPromptDialog()">新增模板</el-button>
</div>
</div>
<el-table :data="promptTemplates" class="model-table" size="large">
<el-table-column prop="code" label="代码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="outputFormat" label="文体" width="120" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column label="启用" width="100">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
active-color="#e60012"
inactive-color="#d7dee8"
@change="(value) => togglePromptEnabled(row, Boolean(value))"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="danger" @click="openPromptDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</section>
<div class="tip-bar">
<el-icon><InfoFilled /></el-icon>
<span>调用优先级:模型配置覆盖供应商默认值;未配置时回退 seed,接口不返回 API Key。</span>
<span>调用优先级:模型配置覆盖供应商默认值;未配置时使用内置默认兜底,接口不返回 API Key。</span>
</div>
</main>
</section>
@@ -221,7 +253,7 @@
</el-select>
</el-form-item>
<el-form-item label="用途">
<el-input v-model="modelForm.modelDescribe" placeholder="用于 Demo 对话与 RAG 生成" />
<el-input v-model="modelForm.modelDescribe" placeholder="用于通用对话与 RAG 生成" />
</el-form-item>
<el-form-item label="维度">
<el-input-number v-model="modelForm.modelDimension" :min="0" :controls="false" placeholder="向量模型填写" />
@@ -241,6 +273,45 @@
<el-button type="danger" :loading="saving" @click="submitModel">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="promptDialogVisible" :title="promptForm.id > 0 ? '编辑提示词模板' : '新增提示词模板'" width="720px">
<el-form label-width="104px">
<el-form-item label="模板代码" required>
<el-input v-model="promptForm.code" placeholder="summary_card" />
</el-form-item>
<el-form-item label="模板名称" required>
<el-input v-model="promptForm.name" placeholder="总结卡" />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="promptForm.category" placeholder="practice" />
</el-form-item>
<el-form-item label="输出文体">
<el-input v-model="promptForm.outputFormat" placeholder="summary_card" />
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="提示词模板" required>
<el-input v-model="promptForm.template" type="textarea" :rows="5" />
</el-form-item>
<el-form-item label="变量">
<el-input v-model="promptForm.variables" placeholder='["question","answer"]' />
</el-form-item>
<el-form-item label="版本">
<el-input v-model="promptForm.version" placeholder="v1" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="promptForm.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="promptDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="promptSaving" @click="submitPromptTemplate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -266,6 +337,13 @@ import {
type ModelProviderPayload,
type VectorIndexStatus
} from '@/api/aihr/model';
import {
createPromptTemplate,
listPromptTemplates,
updatePromptTemplate,
type PromptTemplateRequest,
type PromptTemplateResponse
} from '@/api/aihr/practice';
type ProviderView = ModelProvider & { logo: string };
type ProviderFormState = ModelProviderPayload & {
@@ -282,6 +360,9 @@ type ModelFormState = ModelConfigPayload & {
apiKey: string;
modelDescribe: string;
};
type PromptTemplateFormState = PromptTemplateRequest & {
id: number;
};
const seedProviders: ProviderView[] = [
{
@@ -364,7 +445,7 @@ const seedModels: ModelConfig[] = [
category: 'chat',
modelName: 'deepseek-chat',
providerCode: 'deepseek',
modelDescribe: '用于 Demo 对话与 RAG 生成',
modelDescribe: '用于通用对话与 RAG 生成',
modelShow: 'Y',
apiHost: '',
enabled: true,
@@ -430,13 +511,17 @@ const keyword = ref('');
const loading = ref(false);
const testing = ref(false);
const saving = ref(false);
const promptLoading = ref(false);
const promptSaving = ref(false);
const vectorLoading = ref(false);
const rebuilding = ref(false);
const showKey = ref(false);
const providerDialogVisible = ref(false);
const modelDialogVisible = ref(false);
const promptDialogVisible = ref(false);
const lastTestAt = ref('未测试');
const vectorStatus = ref<VectorIndexStatus>();
const promptTemplates = ref<PromptTemplateResponse[]>([]);
const providerForm = reactive<ProviderFormState>({
id: 0,
@@ -463,6 +548,20 @@ const modelForm = reactive<ModelFormState>({
enabled: true
});
const promptForm = reactive<PromptTemplateFormState>({
id: 0,
code: '',
name: '',
category: 'practice',
outputFormat: '',
systemPrompt: '',
template: '',
variables: '',
version: 'v1',
enabled: true,
description: ''
});
const filteredProviders = computed(() => {
const word = keyword.value.trim().toLowerCase();
if (!word) return providers.value;
@@ -472,11 +571,10 @@ const filteredProviders = computed(() => {
const activeProvider = computed<ProviderView>(
() => providers.value.find((item) => item.providerCode === selectedProviderCode.value) ?? providers.value[0] ?? seedProviders[0]
);
const activeChatModel = computed(
() =>
modelRows.value.find((item) => item.category === 'chat' && item.enabled && item.configured) ??
modelRows.value.find((item) => item.category === 'chat' && item.enabled) ??
modelRows.value[0]
const activeChatModel = computed(() =>
modelRows.value.find(
(item) => item.id > 0 && item.providerCode === selectedProviderCode.value && item.category === 'chat' && item.enabled && item.configured
)
);
const keyText = computed(() => {
if (!activeProvider.value.configured) return '未配置';
@@ -531,7 +629,7 @@ const loadData = async (preferredProviderCode = selectedProviderCode.value) => {
providers.value = seedProviders;
modelRows.value = seedModels;
selectedProviderCode.value = preferredProviderCode || 'deepseek';
ElMessage.warning('模型配置 API 不可用,已展示本地 seed 原型数据');
ElMessage.warning('模型配置 API 不可用,已展示内置默认清单');
} finally {
loading.value = false;
}
@@ -549,6 +647,18 @@ const loadVectorStatus = async () => {
}
};
const loadPromptTemplates = async () => {
promptLoading.value = true;
try {
const result = await listPromptTemplates();
promptTemplates.value = result.data ?? [];
} catch {
promptTemplates.value = [];
} finally {
promptLoading.value = false;
}
};
const runVectorRebuild = async () => {
try {
await ElMessageBox.confirm('将清空旧片段向量并重建 Qdrant 索引,继续吗?', '重建向量索引', {
@@ -570,6 +680,10 @@ const runVectorRebuild = async () => {
};
const runConnectionTest = async () => {
if (!activeChatModel.value) {
ElMessage.warning('请先为当前供应商配置并启用 chat 模型');
return;
}
testing.value = true;
try {
const result = await testModelChat({
@@ -580,10 +694,10 @@ const runConnectionTest = async () => {
if (result.data?.configured) {
ElMessage.success(`连接成功:${result.data.modelName}`);
} else {
ElMessage.warning('后端返回 seed fallback,请先在数据库配置供应商 URL 和 Key');
ElMessage.warning('当前模型未完成真实调用配置,请检查供应商 URL、Key 和模型名');
}
} catch {
ElMessage.warning('模型测试接口不可用,当前仅展示页面原型数据');
ElMessage.warning('模型测试接口不可用,请检查后端服务、登录状态或当前供应商配置');
} finally {
testing.value = false;
}
@@ -645,7 +759,7 @@ const submitProvider = async () => {
const toggleProviderStatus = async (enabled: boolean) => {
const provider = activeProvider.value;
if (provider.id < 0) {
ElMessage.warning('seed 供应商请先保存后再启停');
ElMessage.warning('内置供应商请先保存后再启停');
openProviderDialog(provider);
return;
}
@@ -719,7 +833,7 @@ const submitModel = async () => {
const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
if (row.id < 0) {
row.enabled = !enabled;
ElMessage.warning('seed 模型请先保存后再启停');
ElMessage.warning('内置模型请先保存后再启停');
openModelDialog(row);
return;
}
@@ -734,9 +848,78 @@ const toggleModelEnabled = async (row: ModelConfig, enabled: boolean) => {
}
};
const openPromptDialog = (template?: PromptTemplateResponse) => {
Object.assign(promptForm, {
id: template?.id ?? 0,
code: template?.code ?? '',
name: template?.name ?? '',
category: template?.category ?? 'practice',
outputFormat: template?.outputFormat ?? '',
systemPrompt: template?.systemPrompt ?? '',
template: template?.template ?? '',
variables: template?.variables ?? '',
version: template?.version ?? 'v1',
enabled: template?.enabled ?? true,
description: template?.description ?? ''
});
promptDialogVisible.value = true;
};
const promptPayload = (): PromptTemplateRequest | undefined => {
const code = promptForm.code.trim();
const name = promptForm.name.trim();
const template = promptForm.template.trim();
if (!code || !name || !template) {
ElMessage.warning('请填写模板代码、名称和提示词模板');
return undefined;
}
return {
code,
name,
category: promptForm.category.trim() || 'practice',
outputFormat: promptForm.outputFormat.trim() || code,
systemPrompt: promptForm.systemPrompt.trim(),
template,
variables: promptForm.variables.trim(),
version: promptForm.version.trim() || 'v1',
enabled: promptForm.enabled,
description: promptForm.description.trim()
};
};
const submitPromptTemplate = async () => {
const payload = promptPayload();
if (!payload) return;
promptSaving.value = true;
try {
if (promptForm.id > 0) {
await updatePromptTemplate(promptForm.id, payload);
} else {
await createPromptTemplate(payload);
}
promptDialogVisible.value = false;
await loadPromptTemplates();
ElMessage.success('提示词模板已保存');
} finally {
promptSaving.value = false;
}
};
const togglePromptEnabled = async (row: PromptTemplateResponse, enabled: boolean) => {
try {
await updatePromptTemplate(row.id, { ...row, enabled });
row.enabled = enabled;
ElMessage.success(enabled ? '模板已启用' : '模板已停用');
} catch {
row.enabled = !enabled;
}
};
onMounted(() => {
loadData();
loadVectorStatus();
loadPromptTemplates();
});
</script>
@@ -941,7 +1124,8 @@ onMounted(() => {
.detail-panel,
.vector-panel,
.model-panel {
.model-panel,
.prompt-panel {
padding-bottom: 18px;
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolvePilotDateWindow } from './pilot-export';
describe('resolvePilotDateWindow', () => {
it('returns null when the Element Plus date range is cleared', () => {
expect(resolvePilotDateWindow(null)).toBeNull();
});
it('returns an explicit start and end date only for a complete range', () => {
expect(resolvePilotDateWindow(['2026-07-07', '2026-07-10'])).toEqual({
startDate: '2026-07-07',
endDate: '2026-07-10'
});
});
});
+15
View File
@@ -0,0 +1,15 @@
export type PilotDateWindow = {
startDate: string;
endDate: string;
};
export function resolvePilotDateWindow(value: unknown): PilotDateWindow | null {
if (!Array.isArray(value) || value.length !== 2) {
return null;
}
const [startDate, endDate] = value;
if (typeof startDate !== 'string' || typeof endDate !== 'string' || !startDate || !endDate) {
return null;
}
return { startDate, endDate };
}
+638 -26
View File
@@ -8,17 +8,28 @@
<div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
<el-date-picker
v-model="pilotDateRange"
class="pilot-date-range"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="试点开始"
end-placeholder="试点结束"
unlink-panels
/>
<el-button :icon="Download" :loading="exportingPilot" :disabled="!pilotExportWindow" @click="exportPilotCsv">导出试点CSV</el-button>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置演示</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button>
</div>
</section>
<section class="flow-strip">
<button v-for="step in flowSteps" :key="step.title" type="button" class="flow-step" :class="{ active: step.active, done: step.done }">
<div v-for="step in flowSteps" :key="step.title" class="flow-step" :class="{ active: step.active, done: step.done }">
<span>{{ step.index }}</span>
<strong>{{ step.title }}</strong>
<small>{{ step.desc }}</small>
</button>
</div>
</section>
<section class="practice-grid">
@@ -30,7 +41,7 @@
<div class="select-card">
<span>训练场景</span>
<el-select v-model="scenarioId" class="scenario-select" @change="resetPractice">
<el-select v-model="scenarioId" class="scenario-select" @change="handleScenarioChange">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
</div>
@@ -42,6 +53,18 @@
</div>
</div>
<div class="rubric-card">
<div class="rubric-head">
<strong>Rubric 预览</strong>
<small>{{ selectedScenario.scores.length }} 个维度</small>
</div>
<p v-for="score in selectedScenario.scores" :key="score.label">
<span>{{ score.label }}</span>
<b>{{ score.value }}</b>
<small>{{ score.note }}</small>
</p>
</div>
<div class="trust-card">
<div>
<span>业主信任度</span>
@@ -50,6 +73,14 @@
<div class="meter"><i :style="{ width: `${trustScore}%` }"></i></div>
</div>
<div class="trust-card emotion-card">
<div>
<span>情绪波动</span>
<strong>{{ emotionScore }}%</strong>
</div>
<div class="meter"><i :style="{ width: `${emotionScore}%` }"></i></div>
</div>
<div class="action-row config-actions">
<el-button type="danger" :disabled="started" :icon="Microphone" @click="startPractice">开始对练</el-button>
<el-button :icon="Tickets" @click="showGoal = !showGoal">{{ showGoal ? '收起目标' : '查看目标' }}</el-button>
@@ -59,6 +90,16 @@
<strong>训练目标</strong>
<p>{{ selectedScenario.goal }}</p>
</div>
<div v-if="prepCard || prepLoading" class="prep-card">
<strong>训练前预习卡</strong>
<small>{{ prepLoading ? '加载中' : prepCard?.promptVersion }}</small>
<template v-if="prepCard">
<p>要点:{{ prepCard.keyPoints.join(' / ') }}</p>
<p>红线:{{ prepCard.redLines.join(' / ') }}</p>
<p>话术:{{ prepCard.scripts[0] }}</p>
</template>
</div>
</article>
<article class="panel chat-panel">
@@ -87,22 +128,22 @@
<div class="reply-box">
<div class="reply-head">
<span>学员下一句</span>
<el-button text type="danger" :disabled="finished" @click="fillReferenceReply">填入参考回复</el-button>
</div>
<el-input
v-model="draftReply"
type="textarea"
:rows="5"
resize="none"
:disabled="finished"
placeholder="输入学员回复,或点击“填入参考回复”推进测试"
/>
<el-input v-model="draftReply" type="textarea" :rows="5" resize="none" :disabled="finished" placeholder="输入学员真实回复" />
</div>
<div class="action-row">
<el-button :disabled="finished" @click="continueRound">继续一轮</el-button>
<el-button :disabled="sopHelpLoading" @click="askSopHelp">{{ sopHelpLoading ? '查询中' : '求助 SOP' }}</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">结束并评分</el-button>
</div>
<div v-if="sopHelp || sopHelpMessage" class="help-card">
<strong>SOP 求助</strong>
<p v-if="sopHelp">{{ sopHelp.answer }}</p>
<p v-else>{{ sopHelpMessage }}</p>
<small v-if="sopHelp?.reference">{{ sopHelp.reference }}</small>
</div>
</template>
</article>
@@ -129,6 +170,17 @@
<strong>导师改写</strong>
<p>{{ displayRewrite }}</p>
</div>
<div class="calibration-card">
<div class="calibration-head">
<strong>人工校准</strong>
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
</div>
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
<el-input v-model="calibrationReason" type="textarea" :rows="2" resize="none" :disabled="!canCalibrate" placeholder="校准原因" />
<div class="action-row">
<el-button type="danger" :loading="calibrationSaving" :disabled="!canCalibrate" @click="saveCalibration">记录校准样本</el-button>
</div>
</div>
</template>
</article>
</section>
@@ -147,15 +199,131 @@
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
</el-table>
</section>
<section class="panel prompt-panel">
<div class="panel-title title-with-extra">
<div>
<el-icon><Tickets /></el-icon>
<h2>提示词模板</h2>
</div>
<span>{{ promptTemplates.length }} 个模板</span>
</div>
<div class="prompt-layout">
<div class="prompt-list">
<el-table v-loading="promptLoading" :data="promptTemplates" size="small" class="record-table" row-key="id">
<el-table-column prop="code" label="Code" min-width="150" />
<el-table-column prop="name" label="名称" min-width="130" />
<el-table-column prop="category" label="分类" width="110" />
<el-table-column prop="outputFormat" label="输出" width="118" />
<el-table-column prop="version" label="版本" width="82" />
<el-table-column label="状态" width="92">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" effect="plain">{{ row.enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="editPromptTemplate(row)">编辑</el-button>
<el-button link :type="row.enabled ? 'warning' : 'success'" @click="togglePromptTemplate(row)">
{{ row.enabled ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-form label-position="top" class="prompt-form">
<div class="prompt-form-head">
<strong>{{ promptFormMode }}</strong>
<el-button text type="primary" @click="resetPromptTemplateForm">新建模板</el-button>
</div>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="Code">
<el-input v-model="promptForm.code" placeholder="mentor_rewrite" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称">
<el-input v-model="promptForm.name" placeholder="导师改写" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="分类">
<el-select v-model="promptForm.category" class="full-control" filterable allow-create>
<el-option v-for="category in promptCategoryOptions" :key="category" :label="category" :value="category" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="输出格式">
<el-input v-model="promptForm.outputFormat" placeholder="json_card / text" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="版本">
<el-input v-model="promptForm.version" placeholder="v1" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="系统提示词">
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" resize="none" />
</el-form-item>
<el-form-item label="模板正文">
<el-input v-model="promptForm.template" type="textarea" :rows="5" resize="none" />
</el-form-item>
<el-row :gutter="10">
<el-col :span="16">
<el-form-item label="变量">
<el-input v-model="promptForm.variables" placeholder="scenarioName,goal,traineeText 或 JSON" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="启用状态">
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="说明">
<el-input v-model="promptForm.description" type="textarea" :rows="2" resize="none" />
</el-form-item>
<div class="action-row prompt-actions">
<el-button @click="loadPromptTemplates">刷新</el-button>
<el-button type="danger" :loading="promptSaving" @click="savePromptTemplate">保存模板</el-button>
</div>
</el-form>
</div>
</section>
</div>
</template>
<script setup name="TrainPractice" lang="ts">
import { computed, ref } from 'vue';
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { computed, onMounted, ref } from 'vue';
import { Download, Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import { finishPracticeSession, startPracticeSession, submitPracticeTurn } from '@/api/aihr/practice';
import FileSaver from 'file-saver';
import {
calibratePracticeSession,
createPromptTemplate,
exportPracticePilotCsv,
finishPracticeSession,
getPracticePrepCard,
listPromptTemplates,
listPracticeScenarios,
startPracticeSession,
submitPracticeTurn,
updatePromptTemplate,
type PromptTemplateRequest,
type PromptTemplateResponse,
type PracticePrepCardResponse,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
import { resolvePilotDateWindow } from './pilot-export';
type ChatRole = 'customer' | 'trainee' | 'coach';
@@ -175,6 +343,7 @@ type PracticeRound = {
type ScenarioSeed = {
id: string;
name: string;
position?: string;
trainee: string;
customer: string;
project: string;
@@ -193,9 +362,13 @@ type ScenarioSeed = {
summary: string;
};
type PromptTemplateForm = PromptTemplateRequest & {
id: number | null;
};
const router = useRouter();
const scenarios: ScenarioSeed[] = [
const fallbackScenarios: ScenarioSeed[] = [
{
id: 'complaint-water',
name: '客服应对投诉',
@@ -268,7 +441,8 @@ const baseRecords = [
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
];
const scenarioId = ref(scenarios[0].id);
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
const scenarioId = ref(fallbackScenarios[0].id);
const started = ref(false);
const finished = ref(false);
const scored = ref(false);
@@ -282,12 +456,47 @@ const apiTotal = ref<number | null>(null);
const apiRewrite = ref('');
const apiSummary = ref('');
const apiTrust = ref<number | null>(null);
const apiEmotion = ref<number | null>(null);
const prepCard = ref<PracticePrepCardResponse | null>(null);
const prepLoading = ref(false);
const sopHelp = ref<SopSearchResponse | null>(null);
const sopHelpLoading = ref(false);
const sopHelpMessage = ref('');
const conversation = ref<ChatTurn[]>([]);
const records = ref([...baseRecords]);
const promptTemplates = ref<PromptTemplateResponse[]>([]);
const promptLoading = ref(false);
const promptSaving = ref(false);
const calibrationScore = ref(0);
const calibrationReason = ref('管理端人工校准');
const calibrationSaving = ref(false);
const calibrationMessage = ref('');
const exportingPilot = ref(false);
const pilotDateRange = ref<string[] | null>(null);
const pilotExportWindow = computed(() => resolvePilotDateWindow(pilotDateRange.value));
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
const defaultPromptForm = (): PromptTemplateForm => ({
id: null,
code: 'mentor_rewrite',
name: '导师改写',
category: 'practice',
outputFormat: 'text',
systemPrompt: '',
template: '',
variables: 'scenarioName,goal,traineeText',
version: 'v1',
enabled: true,
description: ''
});
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
const selectedScenario = computed(() => scenarios.find((scenario) => scenario.id === scenarioId.value) ?? scenarios[0]);
const selectedScenario = computed(
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]
);
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
const canScore = computed(() => finished.value && !scored.value);
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value));
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
@@ -304,6 +513,15 @@ const trustScore = computed(() => {
}
return selectedScenario.value.trustStart + completedRounds.value * 7;
});
const emotionScore = computed(() => {
if (apiEmotion.value !== null) {
return apiEmotion.value;
}
if (!started.value) {
return 70;
}
return Math.max(45, 76 - completedRounds.value * 8);
});
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '真实 API', className: 'pill-info' };
@@ -341,6 +559,67 @@ const scenarioProfile = computed(() => [
{ label: '学员', value: selectedScenario.value.trainee }
]);
const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
id: item.id,
name: item.name,
position: item.position,
trainee: '张三',
customer: item.customerPersona,
project: item.projectType || item.scenarioType,
strategy: item.strategy,
goal: item.goal || item.successCriteria,
trustStart: Math.max(50, 70 - item.difficulty * 4),
trustEnd: 80 + Math.min(item.difficulty, 3),
rounds: [
{
customer: item.openingText || item.explicitNeed,
sampleReply: '我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。',
coachHint: item.successCriteria || '补上责任人、处理节点和回访方式。'
},
{
customer: '那接下来谁负责、什么时候给我反馈?',
sampleReply: '我会把责任人、处理节点和回访时间同步给您,过程有变化也会及时说明。',
coachHint: '说明闭环节点,不要做未经确认的承诺。'
}
],
scores:
item.rubric?.dimensions?.map((dimension) => ({
label: dimension.name,
value: Math.round(dimension.weight * 100),
note: dimension.description
})) || fallbackScenarios[0].scores,
total: 82,
rewrite: '建议先承接情绪,再说明责任人、处理节点和反馈方式。',
summary: item.successCriteria || '能按SOP给出明确闭环。'
});
const loadPrepCard = async () => {
prepLoading.value = true;
try {
const { data } = await getPracticePrepCard(scenarioId.value);
prepCard.value = data;
} catch {
prepCard.value = null;
} finally {
prepLoading.value = false;
}
};
const loadScenarioOptions = async () => {
try {
const { data } = await listPracticeScenarios({ position: '生活顾问' });
if (data?.length) {
scenarios.value = data.map(mapApiScenario);
if (!scenarios.value.some((scenario) => scenario.id === scenarioId.value)) {
scenarioId.value = scenarios.value[0].id;
}
}
} catch {
scenarios.value = [...fallbackScenarios];
}
await loadPrepCard();
};
const appendTurn = (role: ChatRole, text: string) => {
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
@@ -360,10 +639,21 @@ const resetPractice = () => {
apiRewrite.value = '';
apiSummary.value = '';
apiTrust.value = null;
apiEmotion.value = null;
sopHelp.value = null;
sopHelpMessage.value = '';
calibrationScore.value = 0;
calibrationReason.value = '管理端人工校准';
calibrationMessage.value = '';
conversation.value = [];
records.value = [...baseRecords];
};
const handleScenarioChange = () => {
resetPractice();
void loadPrepCard();
};
const startPractice = async () => {
resetPractice();
const fallbackCustomer = selectedScenario.value.rounds[0].customer;
@@ -376,6 +666,7 @@ const startPractice = async () => {
sessionId.value = data.sessionId;
apiMode.value = 'api';
apiTrust.value = data.trust;
apiEmotion.value = data.emotion;
appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
} catch {
@@ -387,10 +678,6 @@ const startPractice = async () => {
started.value = true;
};
const fillReferenceReply = () => {
draftReply.value = selectedScenario.value.rounds[activeRound.value].sampleReply;
};
const continueRound = async () => {
const round = selectedScenario.value.rounds[activeRound.value];
if (!draftReply.value.trim()) {
@@ -408,8 +695,9 @@ const continueRound = async () => {
roundIndex: activeRound.value,
traineeText
});
appendTurn('coach', data.coachHint || round.coachHint);
appendTurn('coach', data.redFlag ? data.coachHint || '红线纠偏:先承接情绪,再给责任人与反馈节点。' : data.coachHint || round.coachHint);
apiTrust.value = data.trust;
apiEmotion.value = data.emotion;
activeRound.value = Math.min(data.roundIndex ?? activeRound.value + 1, selectedScenario.value.rounds.length);
if (data.finished || activeRound.value >= selectedScenario.value.rounds.length) {
finished.value = true;
@@ -434,6 +722,29 @@ const continueRound = async () => {
appendTurn('customer', selectedScenario.value.rounds[activeRound.value].customer);
};
const askSopHelp = async () => {
sopHelpLoading.value = true;
sopHelpMessage.value = '';
sopHelp.value = null;
try {
const { data } = await searchKnowledgeSop({
queryText: `${selectedScenario.value.name} ${selectedScenario.value.goal}`,
category: 'sop',
position: selectedScenario.value.position || '生活顾问',
source: 'practice_help',
limit: 3
});
if (!data?.answer) {
throw new Error('SOP 未返回可用答案');
}
sopHelp.value = data;
} catch (error) {
sopHelpMessage.value = error instanceof Error ? error.message : 'SOP 求助失败,请稍后重试';
} finally {
sopHelpLoading.value = false;
}
};
const finishScore = async () => {
let nextRecord = {
time: '07-02 10:42',
@@ -470,11 +781,173 @@ const finishScore = async () => {
}
scored.value = true;
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
records.value = [nextRecord, ...baseRecords];
if (apiMode.value !== 'api') {
ElMessage.success('考官评分已生成,并写入本地对练记录');
}
};
const saveCalibration = async () => {
if (!canCalibrate.value) {
ElMessage.warning('请先完成一次真实 API 对练评分');
return;
}
calibrationSaving.value = true;
try {
const { data } = await calibratePracticeSession(sessionId.value, {
reviewer: '管理端校准',
correctedTotal: calibrationScore.value,
reason: calibrationReason.value.trim() || '管理端人工校准'
});
calibrationMessage.value = `已记录:AI ${data.originalScore} / 人工 ${data.correctedScore}`;
ElMessage.success('校准样本已记录');
} catch {
ElMessage.error('校准样本记录失败,请检查权限或会话状态');
} finally {
calibrationSaving.value = false;
}
};
const exportPilotCsv = async () => {
const dateWindow = pilotExportWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择完整的试点批次日期');
return;
}
const { startDate, endDate } = dateWindow;
exportingPilot.value = true;
try {
const data = await exportPracticePilotCsv({ startDate, endDate });
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), `aihr-practice-pilot-${startDate}-${endDate}.csv`);
ElMessage.success('试点数据 CSV 已导出');
} catch {
ElMessage.error('试点数据导出失败,请检查权限');
} finally {
exportingPilot.value = false;
}
};
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
id: template.id,
code: template.code || '',
name: template.name || '',
category: template.category || 'practice',
outputFormat: template.outputFormat || 'text',
systemPrompt: template.systemPrompt || '',
template: template.template || '',
variables: template.variables || '',
version: template.version || 'v1',
enabled: Boolean(template.enabled),
description: template.description || ''
});
const promptFormToRequest = (form: PromptTemplateForm): PromptTemplateRequest => ({
code: form.code.trim(),
name: form.name.trim(),
category: form.category.trim() || 'practice',
outputFormat: form.outputFormat.trim() || 'text',
systemPrompt: form.systemPrompt.trim(),
template: form.template.trim(),
variables: form.variables.trim(),
version: form.version.trim() || 'v1',
enabled: form.enabled,
description: form.description.trim()
});
const validatePromptVariables = (variables: string) => {
const value = variables.trim();
if (!value || !/^[{[]/.test(value)) {
return true;
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};
const validatePromptForm = () => {
if (!promptForm.value.code.trim() || !promptForm.value.name.trim()) {
ElMessage.warning('请填写模板 Code 和名称');
return false;
}
if (!promptForm.value.template.trim()) {
ElMessage.warning('请填写模板正文');
return false;
}
if (!validatePromptVariables(promptForm.value.variables)) {
ElMessage.warning('变量使用 JSON 时必须是合法 JSON;逗号分隔变量可直接填写');
return false;
}
return true;
};
const loadPromptTemplates = async () => {
promptLoading.value = true;
try {
const { data } = await listPromptTemplates();
promptTemplates.value = data || [];
} catch {
promptTemplates.value = [];
ElMessage.warning('提示词模板加载失败');
} finally {
promptLoading.value = false;
}
};
const editPromptTemplate = (template: PromptTemplateResponse) => {
promptForm.value = promptTemplateToForm(template);
};
const resetPromptTemplateForm = () => {
promptForm.value = defaultPromptForm();
};
const savePromptTemplate = async () => {
if (!validatePromptForm()) {
return;
}
promptSaving.value = true;
try {
const requestData = promptFormToRequest(promptForm.value);
if (promptForm.value.id) {
const { data } = await updatePromptTemplate(promptForm.value.id, requestData);
promptForm.value = promptTemplateToForm(data);
ElMessage.success('提示词模板已更新');
} else {
const { data } = await createPromptTemplate(requestData);
promptForm.value = promptTemplateToForm(data);
ElMessage.success('提示词模板已创建');
}
await loadPromptTemplates();
} catch {
ElMessage.error('提示词模板保存失败,请检查权限或字段唯一性');
} finally {
promptSaving.value = false;
}
};
const togglePromptTemplate = async (template: PromptTemplateResponse) => {
promptSaving.value = true;
try {
const nextForm = promptTemplateToForm(template);
nextForm.enabled = !template.enabled;
await updatePromptTemplate(template.id, promptFormToRequest(nextForm));
await loadPromptTemplates();
ElMessage.success(template.enabled ? '提示词模板已停用' : '提示词模板已启用');
} catch {
ElMessage.error('提示词模板状态更新失败');
} finally {
promptSaving.value = false;
}
};
onMounted(() => {
void loadScenarioOptions();
void loadPromptTemplates();
});
</script>
<style lang="scss" scoped>
@@ -513,6 +986,10 @@ const finishScore = async () => {
flex-wrap: wrap;
}
.pilot-date-range {
width: 250px;
}
.flow-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -613,10 +1090,14 @@ const finishScore = async () => {
.select-card,
.field-item,
.rubric-card,
.reply-box,
.coach-card,
.calibration-card,
.trust-card,
.goal-card {
.goal-card,
.prep-card,
.help-card {
padding: 12px;
border: 1px solid #e5eaf3;
border-radius: 8px;
@@ -657,8 +1138,47 @@ const finishScore = async () => {
}
}
.rubric-card {
display: grid;
gap: 8px;
margin-top: 10px;
}
.rubric-head,
.rubric-card p {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
align-items: center;
margin: 0;
}
.rubric-head small,
.rubric-card p small {
grid-column: 1 / -1;
color: #7a8597;
font-size: 12px;
}
.rubric-card p {
padding-top: 8px;
border-top: 1px solid #e5eaf3;
span {
color: #22314a;
font-size: 13px;
font-weight: 700;
}
b {
color: #d70f20;
}
}
.trust-card,
.goal-card {
.goal-card,
.prep-card,
.help-card {
margin-top: 12px;
p {
@@ -666,6 +1186,13 @@ const finishScore = async () => {
color: #46566e;
line-height: 1.6;
}
small {
display: block;
margin-top: 4px;
color: #7a8597;
font-size: 12px;
}
}
.trust-card div:first-child {
@@ -692,6 +1219,10 @@ const finishScore = async () => {
}
}
.emotion-card .meter i {
background: linear-gradient(90deg, #31b367, #ffb020, #f65f61);
}
.chat-list {
display: grid;
gap: 10px;
@@ -818,6 +1349,23 @@ const finishScore = async () => {
}
}
.calibration-card {
display: grid;
gap: 10px;
margin-top: 12px;
}
.calibration-head {
display: flex;
justify-content: space-between;
gap: 10px;
small {
color: #647084;
font-size: 12px;
}
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -857,10 +1405,65 @@ const finishScore = async () => {
overflow: hidden;
}
.prompt-panel {
margin-top: 12px;
}
.prompt-layout {
display: grid;
grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 0.8fr);
gap: 14px;
align-items: start;
}
.prompt-list {
min-width: 0;
}
.prompt-form {
padding: 14px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f9fbfe;
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-textarea__inner) {
line-height: 1.65;
}
}
.prompt-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
strong {
color: #22314a;
font-size: 15px;
}
}
.full-control {
width: 100%;
}
.prompt-actions {
margin-top: 2px;
}
@media (max-width: 1360px) {
.practice-grid {
grid-template-columns: 1fr;
}
.prompt-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
@@ -877,6 +1480,15 @@ const finishScore = async () => {
grid-template-columns: 1fr;
}
.prompt-form {
padding: 12px;
}
.prompt-form :deep(.el-col) {
max-width: 100%;
flex: 0 0 100%;
}
.score-grid {
grid-template-columns: 1fr;
}
+452
View File
@@ -0,0 +1,452 @@
<template>
<div class="review-page">
<section class="page-head">
<div>
<h1>复盘校准</h1>
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
</div>
<div class="head-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
</div>
</section>
<section class="review-layout">
<article class="panel list-panel">
<div class="panel-title">
<h2>待复盘记录</h2>
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div>
<div class="list-filters">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
</div>
<el-table v-loading="loading" :data="visibleReviews" size="small" highlight-current-row @row-click="selectReview">
<el-table-column prop="trainee" label="学员" width="96" />
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="score" label="得分" width="70" />
<el-table-column prop="status" label="状态" width="92">
<template #default="{ row }">
<el-tag :type="row.status === '待复盘' ? 'warning' : 'success'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="128" />
</el-table>
</article>
<article class="panel detail-panel">
<template v-if="detail">
<div class="panel-title">
<h2>{{ detail.trainee }} · {{ detail.scene }}</h2>
<el-tag :type="detail.score >= 80 ? 'success' : 'danger'">{{ detail.score }} 分</el-tag>
</div>
<div class="score-grid">
<div v-for="item in detail.scoreItems" :key="item.label" class="score-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</div>
</div>
<div class="dialogue-list">
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
<div class="dialogue-head">
<strong>{{ turn.label }}</strong>
<el-tag v-if="hasAudio(turn)" size="small" type="info">有录音</el-tag>
</div>
<p>{{ turn.text }}</p>
<div v-if="hasAudio(turn)" class="dialogue-audio-row">
<audio v-if="audioSource(turn, index)" controls :src="audioSource(turn, index)" />
<el-button v-else size="small" :loading="audioLoading[audioKey(index)]" @click.stop="loadAudio(turn, index)">加载录音</el-button>
<span v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</span>
</div>
</div>
</div>
<div class="annotation-list">
<h3>逐句标注</h3>
<el-table :data="detail.annotations" size="small" empty-text="暂无逐句标注">
<el-table-column prop="sourceText" label="原句" min-width="180" show-overflow-tooltip />
<el-table-column prop="issue" label="问题" min-width="160" />
<el-table-column prop="rewrite" label="推荐改写" min-width="220" show-overflow-tooltip />
<el-table-column prop="sopPoint" label="SOP点" min-width="160" />
</el-table>
</div>
<el-form class="review-form" label-position="top">
<el-form-item label="复盘建议">
<el-input v-model="reviewForm.advice" type="textarea" :rows="3" placeholder="写给员工的复盘建议" />
</el-form-item>
<div class="form-grid">
<el-form-item label="贡献学分">
<el-input-number v-model="reviewForm.incentivePoint" :min="0" :max="100" />
</el-form-item>
<el-form-item label="人工校准总分">
<el-input-number v-model="reviewForm.correctedTotal" :min="0" :max="100" />
</el-form-item>
</div>
<el-form-item label="校准原因">
<el-input v-model="reviewForm.calibrationReason" placeholder="例如:AI 对安抚情绪权重偏低" />
</el-form-item>
<div class="form-grid">
<el-form-item label="下次训练">
<el-select v-model="reviewForm.nextScenarioId" clearable placeholder="不派发">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="派发原因">
<el-input v-model="reviewForm.nextReason" placeholder="例如:低分维度定向再练" />
</el-form-item>
<div class="form-actions">
<el-button type="danger" :loading="saving" @click="submitReview">标记已复盘</el-button>
</div>
</el-form>
</template>
<el-empty v-else description="选择一条待复盘记录" />
</article>
</section>
</div>
</template>
<script setup name="TrainReviews" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { calibratePracticeSession, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import {
getPracticeAudioBlobUrl,
getOrgSnapshot,
getPracticeReview,
listPracticeReviews,
markPracticeReviewed,
type OrgSnapshotResponse,
type ReviewDetail,
type ReviewRecord
} from '@/api/aihr/mobile-admin';
const loading = ref(false);
const saving = ref(false);
const reviews = ref<ReviewRecord[]>([]);
const detail = ref<ReviewDetail | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref('');
const selectedExtPartyId = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const reviewForm = reactive({
reviewer: '主管',
advice: '',
incentivePoint: 10,
correctedTotal: 0,
calibrationReason: '',
nextScenarioId: '',
nextReason: ''
});
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value);
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioUrl || turn.audioOssId);
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const needsCalibration = () =>
Boolean(detail.value?.sessionId) && (reviewForm.correctedTotal !== detail.value.score || Boolean(reviewForm.calibrationReason.trim()));
const releaseAudioSources = () => {
Object.values(audioSources.value).forEach((url) => URL.revokeObjectURL(url));
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const loadAudio = async (turn: ReviewDialogueTurn, index: number) => {
const key = audioKey(index);
if (audioSource(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const url = await getPracticeAudioBlobUrl(turn.audioOssId);
audioSources.value = { ...audioSources.value, [key]: url };
} catch {
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
} finally {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
};
const loadScenarios = async () => {
try {
const result = await listPracticeScenarios();
scenarios.value = result.data || [];
} catch (error) {
scenarios.value = [];
}
};
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadReviews = async () => {
loading.value = true;
try {
const result = await listPracticeReviews({
projectCode: selectedProjectCode.value,
extPartyId: selectedExtPartyId.value,
limit: 50
});
reviews.value = result.data || [];
await selectFirstVisibleReview();
} catch (error) {
ElMessage.error('待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const selectReview = async (row: ReviewRecord) => {
releaseAudioSources();
const result = await getPracticeReview(row.id);
detail.value = result.data;
reviewForm.advice = result.data.reviewAdvice || '';
reviewForm.incentivePoint = result.data.incentivePoint || 10;
reviewForm.correctedTotal = result.data.score;
reviewForm.calibrationReason = '';
reviewForm.nextScenarioId = '';
reviewForm.nextReason = '';
};
const selectFirstVisibleReview = async () => {
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
const first = visibleReviews.value[0];
if (!first) {
releaseAudioSources();
detail.value = null;
return;
}
await selectReview(first);
};
const submitReview = async () => {
if (!detail.value) return;
saving.value = true;
try {
if (needsCalibration()) {
await calibratePracticeSession(detail.value.sessionId, {
reviewer: reviewForm.reviewer,
correctedTotal: reviewForm.correctedTotal,
reason: reviewForm.calibrationReason
});
}
await markPracticeReviewed(detail.value.id, {
reviewer: reviewForm.reviewer,
advice: reviewForm.advice,
incentivePoint: reviewForm.incentivePoint,
nextScenarioId: reviewForm.nextScenarioId,
nextReason: reviewForm.nextReason
});
ElMessage.success('已完成复盘处理');
releaseAudioSources();
detail.value = null;
await loadReviews();
} catch (error) {
ElMessage.error('复盘提交失败');
} finally {
saving.value = false;
}
};
watch(selectedProjectCode, async () => {
selectedExtPartyId.value = '';
await loadOrgSnapshot();
await loadReviews();
});
watch(selectedExtPartyId, () => {
loadReviews();
});
onMounted(() => {
loadScenarios();
loadOrgSnapshot();
loadReviews();
});
onBeforeUnmount(releaseAudioSources);
</script>
<style scoped lang="scss">
.review-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.score-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.review-layout {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
gap: 16px;
margin-top: 16px;
}
.panel {
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.list-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.score-grid,
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.score-card {
padding: 12px;
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 6px 0;
font-size: 20px;
}
}
.dialogue-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.dialogue-item {
border-radius: 8px;
background: #f8fafc;
padding: 12px;
p {
margin: 6px 0 0;
color: #475467;
}
}
.dialogue-head,
.dialogue-audio-row {
display: flex;
align-items: center;
gap: 10px;
}
.dialogue-audio-row {
margin-top: 10px;
audio {
width: min(360px, 100%);
}
}
.audio-error {
color: #d92d20;
font-size: 12px;
}
.annotation-list {
margin-top: 18px;
h3 {
margin: 0 0 10px;
font-size: 15px;
}
}
.review-form {
margin-top: 18px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1080px) {
.review-layout,
.list-filters,
.score-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+3 -1
View File
@@ -5,6 +5,7 @@ import path from 'path';
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd());
const proxyTarget = env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
return {
// 部署生产环境和开发环境下的URL。
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
@@ -24,8 +25,9 @@ export default defineConfig(({ mode, command }) => {
open: true,
proxy: {
[env.VITE_APP_BASE_API]: {
target: 'http://localhost:8080',
target: proxyTarget,
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
}
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/static/mentor.png" />
<title>银城员工端</title>
<script>
(function () {
var path = window.location.pathname.replace(/\/+$/, '');
var routes = {
'/h5/user': '/pages/user/today/index',
'/h5/employee': '/pages/user/today/index',
'/h5/user/practice': '/pages/user/practice/index',
'/h5/user/training': '/pages/user/practice/index',
'/h5/user/sop': '/pages/user/sop/index',
'/h5/user/cases': '/pages/user/cases/index',
'/h5/user/profile': '/pages/user/profile/index',
'/h5/candidate': '/pages/candidate/index/index',
'/h5/supervisor': '/pages/supervisor/index/index',
'/h5/admin': '/pages/supervisor/index/index'
};
var route = routes[path];
if (route && (!window.location.hash || window.location.hash === '#/')) {
window.location.replace('/h5/#' + route + window.location.search);
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7720
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile-uni",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "uni -p h5",
"dev:h5": "uni -p h5",
"build:h5": "uni build -p h5",
"test:unit": "node --test tests/*.test.mjs",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12",
"vue": "3.5.30"
},
"devDependencies": {
"@dcloudio/types": "3.4.31",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020120260706001",
"sass": "^1.101.0",
"typescript": "~5.9.3",
"vite": "5.2.8",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": ">=20.19.0",
"npm": ">=8.19.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app';
onLaunch(() => {
// App 只保留全局生命周期。业务页面放在 pages 下。
});
</script>
<style>
@import './styles/base.css';
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="@dcloudio/types" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+7
View File
@@ -0,0 +1,7 @@
import { createSSRApp } from 'vue';
import App from './App.vue';
export function createApp() {
const app = createSSRApp(App);
return { app };
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "银城员工端",
"appid": "__UNI__AIHR",
"description": "物业AI人力资源系统用户侧",
"vueVersion": "3",
"versionName": "0.1.0",
"versionCode": "100",
"uniStatistics": {
"enable": false
},
"h5": {
"router": {
"base": "/h5/"
}
}
}
+168
View File
@@ -0,0 +1,168 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"pages": [
{
"path": "pages/user/today/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "今日"
}
},
{
"path": "pages/user/practice/index",
"style": {
"navigationBarTitleText": "练"
}
},
{
"path": "pages/user/sop/index",
"style": {
"navigationBarTitleText": "问师傅"
}
},
{
"path": "pages/user/profile/index",
"style": {
"navigationBarTitleText": "我"
}
},
{
"path": "pages/user/cases/index",
"style": {
"navigationBarTitleText": "案例素材"
}
},
{
"path": "pages/user/learning/index",
"style": {
"navigationBarTitleText": "学"
}
},
{
"path": "pages/auth/login/index",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/auth/position/index",
"style": {
"navigationBarTitleText": "确认岗位"
}
},
{
"path": "pages/common/building/index",
"style": {
"navigationBarTitleText": "建设中"
}
},
{
"path": "pages/supervisor/index/index",
"style": {
"navigationBarTitleText": "主管"
}
},
{
"path": "pages/supervisor/team/index",
"style": {
"navigationBarTitleText": "团队画像"
}
},
{
"path": "pages/supervisor/assign/index",
"style": {
"navigationBarTitleText": "指派专项"
}
},
{
"path": "pages/supervisor/reviews/index",
"style": {
"navigationBarTitleText": "待复盘"
}
},
{
"path": "pages/supervisor/review/index",
"style": {
"navigationBarTitleText": "复盘详情"
}
},
{
"path": "pages/candidate/index/index",
"style": {
"navigationBarTitleText": "候选人"
}
},
{
"path": "pages/candidate/interview/index",
"style": {
"navigationBarTitleText": "面试任务"
}
},
{
"path": "pages/candidate/materials/index",
"style": {
"navigationBarTitleText": "补充资料"
}
},
{
"path": "pages/candidate/progress/index",
"style": {
"navigationBarTitleText": "进度通知"
}
},
{
"path": "pages/candidate/study/index",
"style": {
"navigationBarTitleText": "岗前预习"
}
}
],
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "银城员工端",
"navigationBarBackgroundColor": "#f3f5f7",
"backgroundColor": "#f3f5f7"
},
"tabBar": {
"color": "#7b8494",
"selectedColor": "#2f6bff",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
"fontSize": "12px",
"iconWidth": "25px",
"spacing": "3px",
"list": [
{
"pagePath": "pages/user/today/index",
"text": "今日",
"iconPath": "static/tabbar/today-normal.png",
"selectedIconPath": "static/tabbar/today-active.png"
},
{
"pagePath": "pages/user/practice/index",
"text": "练",
"iconPath": "static/tabbar/practice-normal.png",
"selectedIconPath": "static/tabbar/practice-active.png"
},
{
"pagePath": "pages/user/sop/index",
"text": "问",
"iconPath": "static/tabbar/ask-normal.png",
"selectedIconPath": "static/tabbar/ask-active.png"
},
{
"pagePath": "pages/user/profile/index",
"text": "我",
"iconPath": "static/tabbar/profile-normal.png",
"selectedIconPath": "static/tabbar/profile-active.png"
}
]
}
}
+322
View File
@@ -0,0 +1,322 @@
<template>
<view class="page login-page">
<view class="login-head">
<view class="brand-lockup">
<image class="login-logo" src="/static/brand-logo.png" mode="aspectFit" />
<view class="brand-copy">
<text class="brand-name">银城物业</text>
<text class="login-eyebrow">数字师傅</text>
</view>
</view>
<text class="page-title">银城员工端</text>
<text class="page-subtitle">使用手机号登录后,回到当前任务。</text>
</view>
<view class="panel login-card">
<text class="panel-title">手机号登录</text>
<uni-forms
ref="loginForm"
class="login-form"
:model="formData"
:rules="formRules"
label-position="top"
validate-trigger="submit"
err-show-type="undertext"
:border="false"
>
<uni-forms-item label="手机号" name="phone" required>
<uni-easyinput
v-model="formData.phone"
type="number"
maxlength="11"
:clearable="false"
primary-color="#e70014"
placeholder="请输入手机号"
placeholder-style="color: #9aa5b5"
:styles="inputStyles"
/>
</uni-forms-item>
<uni-forms-item label="验证码" name="smsCode" required class="code-form-item">
<view class="code-row">
<uni-easyinput
v-model="formData.smsCode"
class="code-input"
type="number"
maxlength="6"
:clearable="false"
primary-color="#e70014"
placeholder="请输入验证码"
placeholder-style="color: #9aa5b5"
:styles="inputStyles"
/>
<button class="secondary-button code-button" :disabled="sending" @click="sendCode">
{{ sending ? '发送中' : '获取验证码' }}
</button>
</view>
</uni-forms-item>
</uni-forms>
<button class="primary-button" :disabled="loggingIn" @click="login">
{{ loggingIn ? '登录中' : '登录' }}
</button>
<uni-notice-bar
v-if="message"
:text="message"
:background-color="message === '验证码已发送' ? '#edf7f0' : '#fff2f3'"
:color="message === '验证码已发送' ? '#168243' : '#bd0010'"
show-icon
/>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { LoginResult } from '@/types/api';
import { apiRequest } from '@/services/api';
import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position';
const stored = getAuth();
const loginForm = ref();
const formData = reactive({
phone: stored.phone || uni.getStorageSync(phoneKey) || '',
smsCode: ''
});
const formRules = {
phone: {
rules: [
{ required: true, errorMessage: '请输入手机号' },
{ pattern: '^1[3-9]\\d{9}$', errorMessage: '请输入正确手机号' }
]
},
smsCode: {
rules: [
{ required: true, errorMessage: '请输入短信验证码' },
{ pattern: '^\\d{4,6}$', errorMessage: '验证码应为 4 至 6 位数字' }
]
}
};
const sending = ref(false);
const loggingIn = ref(false);
const message = ref('');
const inputStyles = {
color: '#25282d',
borderColor: '#d7d9de',
backgroundColor: '#ffffff'
};
const goAfterLogin = async () => {
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
goToMobileTarget(await nextUserRouteAfterLogin(redirect));
};
const sendCode = async () => {
if (sending.value) return;
try {
await loginForm.value?.validateField(['phone']);
} catch {
return;
}
const value = formData.phone.trim();
sending.value = true;
message.value = '';
try {
await apiRequest<void>({
url: `/resource/sms/code?phonenumber=${encodeURIComponent(value)}`,
method: 'GET',
auth: false
});
message.value = '验证码已发送';
} catch (error) {
message.value = error instanceof Error ? error.message : '验证码发送失败';
} finally {
sending.value = false;
}
};
const login = async () => {
if (loggingIn.value) return;
try {
await loginForm.value?.validate();
} catch {
return;
}
const value = formData.phone.trim();
loggingIn.value = true;
message.value = '';
try {
const result = await apiRequest<LoginResult>({
url: '/auth/mobile/sms-login',
method: 'POST',
data: {
phonenumber: value,
smsCode: formData.smsCode.trim(),
tenantId: '000000'
},
auth: false
});
if (!result.access_token || !result.client_id) {
throw new Error('登录响应缺少认证信息');
}
saveAuth(result, value);
await goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '登录失败';
} finally {
loggingIn.value = false;
}
};
onShow(() => {
resetPageScroll();
if (isLoggedIn()) {
void goAfterLogin();
}
});
</script>
<style scoped>
.login-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: flex-start;
padding-top: 52px;
background: #f7f7f8;
}
.login-head {
position: relative;
margin-bottom: 32px;
padding-top: 0;
}
.login-head::before {
position: absolute;
top: 73px;
left: 0;
width: 42px;
height: 3px;
border-radius: 0;
background: #e70014;
content: '';
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 28px;
}
.login-logo {
width: 42px;
height: 42px;
flex: none;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 3px;
}
.brand-name,
.login-eyebrow {
display: block;
color: #34373c;
font-size: 13px;
font-weight: 800;
line-height: 1.2;
}
.login-eyebrow {
color: #168243;
font-size: 12px;
}
.login-head .page-title {
color: #25282d;
font-size: 32px;
line-height: 1.16;
}
.login-head .page-subtitle {
margin-top: 12px;
color: #74777d;
font-size: 14px;
}
.login-card {
display: flex;
flex-direction: column;
gap: 20px;
padding: 26px 20px 22px;
border: 1px solid #e3e4e7;
border-top: 3px solid #e70014;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(27, 31, 38, 0.07);
}
.login-form {
margin-top: 2px;
}
.login-form :deep(.uni-forms-item) {
margin-bottom: 18px;
}
.login-form :deep(.uni-forms-item:last-child) {
margin-bottom: 0;
}
.login-form :deep(.uni-forms-item__label) {
padding-bottom: 8px;
color: #34373c;
font-size: 14px;
font-weight: 800;
}
.login-form :deep(.uni-easyinput__content) {
min-height: 44px;
border-radius: 6px;
}
.login-form :deep(.uni-forms-item__error) {
margin-top: 6px;
font-size: 13px;
}
.code-row {
display: flex;
gap: 10px;
}
.code-input {
flex: 1;
min-width: 0;
}
.code-button {
width: 118px;
min-height: 44px;
flex: none;
border-color: #e70014;
background: #ffffff;
color: #e70014;
font-size: 14px;
}
.login-card > .primary-button {
min-height: 52px;
border-radius: 8px;
background: #e70014;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.16);
}
.login-card :deep(.uni-noticebar) {
margin: 0;
border-radius: 10px;
}
</style>
@@ -0,0 +1,157 @@
<template>
<view class="page position-page">
<view class="position-head">
<text class="page-title">确认岗位</text>
<text class="page-subtitle">{{ phone }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view class="panel">
<text class="panel-title">选择当前岗位</text>
<view class="position-list">
<button
v-for="item in positionCards"
:key="item.value"
class="position-card"
:class="{ active: item.value === selectedPosition }"
:disabled="saving"
@click="selectPosition(item.value)"
>
<view class="position-copy">
<text class="position-name">{{ item.value }}</text>
<text class="position-title">{{ item.heroTitle }}</text>
</view>
<text class="position-mark">{{ item.value === selectedPosition ? '已选' : '选择' }}</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import {
getAuth,
getPositionProfile,
isLoggedIn,
rememberLoginRedirect,
saveSelectedPosition,
userPositionOptions,
type UserPosition
} from '@/services/auth';
import { goToMobileTarget, hydratePositionFromOrg } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
const redirect = ref('/pages/user/today/index');
const selectedPosition = ref<UserPosition | ''>('');
const saving = ref(false);
const message = ref('');
const phone = computed(() => getAuth().phone || '未登录');
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
const finish = () => {
goToMobileTarget(redirect.value || '/pages/user/today/index');
};
const selectPosition = (position: UserPosition) => {
if (saving.value) return;
saving.value = true;
const saved = saveSelectedPosition(position);
selectedPosition.value = saved;
finish();
};
const autoFillFromOrg = async () => {
if (!isLoggedIn()) {
rememberLoginRedirect(redirect.value);
uni.redirectTo({ url: '/pages/auth/login/index' });
return;
}
const officialPosition = await hydratePositionFromOrg();
if (officialPosition) {
selectedPosition.value = officialPosition;
finish();
return;
}
message.value = '未从组织数据匹配到岗位,可能是组织手机号未开放或已脱敏,请手动确认当前岗位';
};
onLoad((query) => {
if (typeof query?.redirect === 'string') {
const target = decodeURIComponent(query.redirect);
if (target.startsWith('/pages/')) {
redirect.value = target;
}
}
});
onShow(() => {
resetPageScroll();
void autoFillFromOrg();
});
</script>
<style scoped>
.position-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
}
.position-head {
margin-bottom: 10px;
}
.position-list {
display: flex;
margin-top: 16px;
flex-direction: column;
gap: 10px;
}
.position-card {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
text-align: left;
}
.position-card.active {
border-color: #2563eb;
background: #eff6ff;
}
.position-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.position-name {
color: #0f172a;
font-size: 18px;
font-weight: 900;
}
.position-title {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.position-mark {
color: #2563eb;
font-size: 14px;
font-weight: 900;
white-space: nowrap;
}
</style>
@@ -0,0 +1,378 @@
<template>
<view class="page candidate-page">
<view class="page-head">
<view>
<text class="page-title">候选人</text>
<text class="page-subtitle">{{ subtitle }}</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看面试任务、资料和审核进度。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel task-panel">
<view class="task-head">
<text class="task-badge">{{ flow.badge }}</text>
<text class="task-progress">{{ flow.progressText }}</text>
</view>
<text class="task-title">{{ flow.title }}</text>
<text class="task-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
<button class="primary-button task-button" :disabled="loading" @click="openRoute(flow.route)">
{{ flow.cta }}
</button>
</view>
<view class="panel action-panel">
<view class="section-head">
<text class="panel-title">办理事项</text>
<text class="muted">按步骤完成</text>
</view>
<view class="action-list">
<button
v-for="item in actionItems"
:key="item.route"
class="action-row"
@click="openRoute(item.route)"
>
<text class="action-index">{{ item.index }}</text>
<view class="action-copy">
<text class="action-title">{{ item.title }}</text>
<text class="action-desc">{{ item.desc }}</text>
</view>
<text class="action-status" :class="item.tone">{{ item.status }}</text>
<text class="action-arrow">›</text>
</button>
</view>
</view>
<view class="panel activity-panel">
<view class="section-head">
<text class="panel-title">最新动态</text>
<button class="text-button" @click="openRoute('/pages/candidate/progress/index')">查看进度</button>
</view>
<view v-if="latestRecord" class="activity-row">
<text class="activity-label">面试</text>
<view class="activity-copy">
<text class="activity-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="activity-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="activity-row">
<text class="activity-label">资料</text>
<view class="activity-copy">
<text class="activity-title">{{ latestMaterial.fileName }}</text>
<text class="activity-desc">{{ latestMaterial.time }} · {{ latestMaterial.status }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCandidateInterviewRecords, getCandidateMaterials, getCandidateName } from '@/services/candidate';
import { buildCandidateFlow } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/index/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const subtitle = computed(() => loggedIn.value
? `尾号${getCandidateName().slice(-4)},按当前任务办理`
: '面试、补资料、查进度');
const actionItems = computed(() => [
{
index: 1,
title: '面试任务',
desc: records.value.length ? '查看最近结果或重新面试' : '完成岗位结构化问答',
status: records.value.length ? '已完成' : '待完成',
tone: records.value.length ? 'done' : 'current',
route: '/pages/candidate/interview/index'
},
{
index: 2,
title: '补充资料',
desc: latestMaterial.value ? latestMaterial.value.fileName : '上传简历、证书或图片',
status: latestMaterial.value?.status || '未提交',
tone: latestMaterial.value?.status === '已通过' ? 'done' : flow.value.stage === 'materials' ? 'current' : 'pending',
route: '/pages/candidate/materials/index'
},
{
index: 3,
title: '进度通知',
desc: flow.value.notice,
status: flow.value.progressText,
tone: flow.value.stage === 'complete' ? 'done' : 'pending',
route: '/pages/candidate/progress/index'
},
{
index: 4,
title: '岗前预习',
desc: '从正式知识库生成岗位要点',
status: '按需查看',
tone: 'pending',
route: '/pages/candidate/study/index'
}
]);
const clearData = () => {
records.value = [];
materials.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
records.value = nextRecords;
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '候选人进度加载失败';
}
} finally {
loading.value = false;
}
};
const openRoute = (route: string) => {
uni.navigateTo({ url: route });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.candidate-page {
min-height: 100vh;
background: #f4f7fb;
}
.candidate-page .panel,
.candidate-page button {
border-radius: 8px;
}
.page-head,
.section-head,
.login-panel,
.task-head,
.activity-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button,
.login-button {
min-width: 84px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.task-panel {
border-left: 4px solid #e60012;
}
.task-badge,
.task-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.task-title {
display: block;
margin-top: 14px;
color: #0f172a;
font-size: 22px;
font-weight: 800;
}
.task-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.task-button {
width: 100%;
margin-top: 16px;
}
.action-list {
margin-top: 12px;
border-top: 1px solid #e2e8f0;
}
.action-row {
display: grid;
width: 100%;
min-height: 72px;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
border-radius: 0 !important;
background: transparent;
text-align: left;
grid-template-columns: 28px minmax(0, 1fr) auto 12px;
align-items: center;
gap: 10px;
}
.action-index,
.activity-label {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f9;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.action-copy,
.activity-copy {
min-width: 0;
flex: 1;
}
.action-title,
.activity-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.action-desc,
.activity-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.action-status {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.action-status.current {
color: #b91c1c;
}
.action-status.done {
color: #15803d;
}
.action-status.pending {
color: #64748b;
}
.action-arrow {
color: #94a3b8;
font-size: 20px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
font-weight: 700;
}
.activity-row {
justify-content: flex-start;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,440 @@
<template>
<view class="page candidate-subpage interview-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">面试任务</text>
<text class="page-subtitle">生活顾问结构化面试</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后开始面试并查看本人记录。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel intro-panel">
<text class="intro-label">当前任务</text>
<text class="intro-title">完成岗位问答</text>
<text class="intro-desc">题目由已配置模型生成,全部作答后形成正式面试记录。</text>
<button class="primary-button start-button" :disabled="interviewBusy" @click="startInterview">
{{ interviewStatus === 'active' ? '重新生成题目' : interviewBusy ? '处理中' : '开始面试' }}
</button>
</view>
<view v-if="interviewQuestions.length" class="question-section">
<view class="section-heading">
<text class="section-title">面试问答</text>
<text class="muted">{{ answeredCount }}/{{ interviewQuestions.length }} 已填写</text>
</view>
<view
v-for="item in interviewQuestions"
:key="item.questionId"
class="question-block"
>
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</text>
<text class="question-title">{{ item.questionText }}</text>
<text class="question-point">考察点:{{ item.point }}</text>
<textarea
v-model="interviewAnswers[item.questionId]"
class="textarea answer-textarea"
maxlength="400"
:disabled="interviewStatus !== 'active'"
placeholder="输入你的真实回答"
/>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="result-head">
<view>
<text class="panel-title">本次结果</text>
<text class="result-title">{{ interviewResult.result }}</text>
</view>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
</view>
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
<view class="dimension-copy">
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看办理进度</button>
</view>
<view class="panel records-panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<button class="text-button" :disabled="recordsLoading" @click="loadRecords">
{{ recordsLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="item in records" :key="`${item.time}-${item.score}`" class="record-row">
<view class="record-copy">
<text class="record-title">{{ item.position }} · {{ item.result }}</text>
<text class="record-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<text class="record-score">{{ item.score }}</text>
</view>
</view>
<view v-else-if="!recordsLoading" class="empty compact-empty">暂无面试记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
finishCandidateInterview,
getCandidateInterviewRecords,
saveInterviewAnswer,
startCandidateInterview
} from '@/services/candidate';
import { candidateRoutes } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
const interviewQuestions = ref<InterviewQuestion[]>([]);
const interviewAnswers = ref<Record<string, string>>({});
const interviewResult = ref<InterviewFinishResponse | null>(null);
const interviewSessionId = ref('');
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
const recordsLoading = ref(false);
const loggedIn = ref(false);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
Boolean(interviewAnswers.value[item.questionId]?.trim())).length);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
};
const clearPrivateData = () => {
clearDraft();
records.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadRecords = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
if (recordsLoading.value) return;
recordsLoading.value = true;
try {
records.value = await getCandidateInterviewRecords();
} catch (error) {
handleRequestError(error, '面试记录加载失败');
} finally {
recordsLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
message.value = '';
try {
const data = await startCandidateInterview();
interviewSessionId.value = data.sessionId;
interviewQuestions.value = data.questions || [];
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
interviewStatus.value = 'active';
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
} catch (error) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败');
}
};
const finishInterview = async () => {
if (interviewBusy.value) return;
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
message.value = '请先开始面试';
return;
}
const answers = Object.fromEntries(
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
records.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败')) {
interviewStatus.value = 'active';
}
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
void loadRecords();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button,
.question-block {
border-radius: 8px;
}
.login-panel,
.section-head,
.result-head,
.record-row,
.dimension-row,
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.intro-panel {
border-left: 4px solid #e60012;
}
.intro-label,
.question-meta {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.intro-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.intro-desc,
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.start-button,
.finish-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.question-section {
margin-top: 22px;
}
.section-title {
color: #172033;
font-size: 20px;
font-weight: 800;
}
.question-block {
margin-top: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.question-title {
display: block;
margin-top: 8px;
color: #172033;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.question-point {
display: inline-block;
margin-top: 8px;
padding: 4px 8px;
background: #f1f5f9;
color: #475569;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.answer-textarea {
margin-top: 12px;
}
.result-title {
display: block;
margin-top: 8px;
color: #15803d;
font-size: 15px;
font-weight: 800;
}
.result-score,
.record-score {
color: #b91c1c;
font-size: 24px;
font-weight: 800;
}
.dimension-list,
.record-list {
margin-top: 14px;
border-top: 1px solid #e2e8f0;
}
.dimension-row,
.record-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
align-items: flex-start;
}
.dimension-copy,
.record-copy {
min-width: 0;
flex: 1;
}
.dimension-title,
.record-title {
display: block;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.dimension-note,
.record-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.dimension-value {
color: #172033;
font-size: 18px;
font-weight: 800;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,293 @@
<template>
<view class="page candidate-subpage materials-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">补充资料</text>
<text class="page-subtitle">提交简历、证书或相关图片</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后上传并查看本人资料。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel upload-panel">
<text class="upload-label">支持 PDF、Word 和常见图片</text>
<text class="upload-title">选择一份补充资料</text>
<text class="upload-desc">提交后由 HR 审核,最新一条资料状态决定当前办理进度。</text>
<button class="primary-button upload-button" :disabled="uploading || loading" @click="uploadMaterial">
{{ uploading ? '上传中' : '选择并上传' }}
</button>
</view>
<view class="panel material-panel">
<view class="section-head">
<text class="panel-title">已提交资料</text>
<button class="text-button" :disabled="loading || uploading" @click="loadMaterials">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="materials.length" class="material-list">
<view v-for="item in materials" :key="item.id" class="material-row">
<view class="material-main">
<view class="material-title-row">
<text class="material-title">{{ item.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(item.status)">{{ item.status }}</text>
</view>
<text class="material-desc">{{ item.time }} · {{ item.materialType }} · {{ formatCandidateFileSize(item.fileSize) }}</text>
<text class="material-id">资料编号 {{ item.id }} · OSS {{ item.ossId }}</text>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty compact-empty">暂无已提交资料</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看审核进度</button>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
chooseCandidateFile,
getCandidateMaterials,
uploadCandidateMaterial
} from '@/services/candidate';
import {
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/materials/index';
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
materials.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadMaterials = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value || uploading.value) return;
loading.value = true;
try {
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料列表加载失败');
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value || loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败');
} finally {
uploading.value = false;
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadMaterials();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.section-head,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.upload-panel {
border-left: 4px solid #e60012;
}
.upload-label {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.upload-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.upload-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.upload-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.material-list {
margin-top: 12px;
border-top: 1px solid #e2e8f0;
}
.material-row {
padding: 14px 0;
border-bottom: 1px solid #e2e8f0;
}
.material-main {
min-width: 0;
}
.material-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.material-id {
display: block;
margin-top: 6px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.material-id {
color: #94a3b8;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,405 @@
<template>
<view class="page candidate-subpage progress-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">进度通知</text>
<text class="page-subtitle">面试与资料审核状态</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看本人面试和资料审核进度。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel status-panel">
<view class="status-head">
<text class="status-badge">{{ flow.badge }}</text>
<text class="status-progress">{{ flow.progressText }}</text>
</view>
<text class="status-title">{{ flow.notice }}</text>
<text class="status-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
<button class="primary-button next-button" :disabled="loading" @click="runCurrentAction">
{{ flow.stage === 'review' || flow.stage === 'complete' ? '刷新进度' : flow.cta }}
</button>
</view>
<view class="panel steps-panel">
<text class="panel-title">办理流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.index" class="step-row" :class="step.status">
<view class="step-rail">
<text class="step-dot">{{ step.status === 'complete' ? '✓' : step.index }}</text>
<view v-if="step.index < steps.length" class="step-line" />
</view>
<view class="step-copy">
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
<text class="step-status">{{ stepStatusLabel(step.status) }}</text>
</view>
</view>
</view>
<view class="panel evidence-panel">
<text class="panel-title">办理依据</text>
<view v-if="latestRecord" class="evidence-row">
<text class="evidence-label">面试记录</text>
<view class="evidence-copy">
<text class="evidence-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="evidence-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="evidence-row">
<text class="evidence-label">最新资料</text>
<view class="evidence-copy">
<view class="material-title-row">
<text class="evidence-title">{{ latestMaterial.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(latestMaterial.status)">{{ latestMaterial.status }}</text>
</view>
<text class="evidence-desc">{{ latestMaterial.time }} · {{ formatCandidateFileSize(latestMaterial.fileSize) }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate';
import {
buildCandidateFlow,
buildCandidateSteps,
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/progress/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const steps = computed(() => buildCandidateSteps(flow.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
records.value = [];
materials.value = [];
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
records.value = nextRecords;
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '办理进度加载失败';
}
} finally {
loading.value = false;
}
};
const runCurrentAction = () => {
if (flow.value.stage === 'review' || flow.value.stage === 'complete') {
void loadPage();
return;
}
uni.navigateTo({ url: flow.value.route });
};
const stepStatusLabel = (status: 'complete' | 'current' | 'pending') => {
if (status === 'complete') return '已完成';
if (status === 'current') return '当前';
return '待办理';
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.status-head,
.evidence-row,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button {
min-width: 76px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.status-panel {
border-left: 4px solid #e60012;
}
.status-badge,
.status-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.status-title {
display: block;
margin-top: 14px;
color: #172033;
font-size: 21px;
font-weight: 800;
}
.status-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.next-button {
width: 100%;
margin-top: 16px;
}
.step-list {
margin-top: 16px;
}
.step-row {
display: grid;
min-height: 76px;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 12px;
}
.step-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.step-row.complete .step-dot {
background: #dcfce7;
color: #166534;
}
.step-row.current .step-dot {
background: #fee2e2;
color: #b91c1c;
}
.step-line {
width: 2px;
flex: 1;
background: #e2e8f0;
}
.step-row.complete .step-line {
background: #22c55e;
}
.step-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.step-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.step-status {
color: #64748b;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.step-row.current .step-status {
color: #b91c1c;
}
.step-row.complete .step-status {
color: #15803d;
}
.evidence-row {
justify-content: flex-start;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.evidence-label {
flex: 0 0 64px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.evidence-copy {
min-width: 0;
flex: 1;
}
.evidence-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.evidence-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,333 @@
<template>
<view class="page candidate-subpage study-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">岗前预习</text>
<text class="page-subtitle">从正式知识库生成岗位要点</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后生成生活顾问岗前预习。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel query-panel">
<text class="query-label">预习主题</text>
<textarea
v-model="queryText"
class="textarea query-input"
maxlength="120"
placeholder="输入要预习的岗位问题"
/>
<view class="topic-row">
<button
v-for="topic in topics"
:key="topic"
class="topic-button"
:class="{ active: queryText === topic }"
:disabled="loading"
@click="queryText = topic"
>
{{ topic }}
</button>
</view>
<button class="primary-button generate-button" :disabled="loading || !queryText.trim()" @click="loadStudy">
{{ loading ? '生成中' : '生成预习' }}
</button>
</view>
<view v-if="answer" class="panel answer-panel">
<text class="panel-title">预习要点</text>
<text class="answer-text">{{ answer }}</text>
<view v-if="keyPoints.length" class="answer-section">
<text class="section-title">关键要点</text>
<view v-for="(item, index) in keyPoints" :key="`${index}-${item}`" class="point-row">
<text class="point-index">{{ index + 1 }}</text>
<text class="point-text">{{ item }}</text>
</view>
</view>
<view v-if="cautions.length" class="answer-section caution-section">
<text class="section-title">注意事项</text>
<text v-for="item in cautions" :key="item" class="caution-text">{{ item }}</text>
</view>
</view>
<view v-if="references.length" class="panel reference-panel">
<text class="panel-title">引用依据</text>
<view v-for="item in references" :key="item" class="reference-row">
<text class="reference-dot">•</text>
<text class="reference-title">{{ item }}</text>
</view>
</view>
<view v-if="!answer && !loading" class="empty-state">
<text class="empty-title">尚未生成预习</text>
<text class="empty-desc">选择主题后调用正式 SOP 知识库,不使用前端示例答案。</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { candidateRoutes } from '@/services/candidate-view';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/study/index';
const topics = [
'生活顾问接到业主投诉时,应该如何安抚、记录、跟进和反馈?',
'生活顾问接到报事报修后,应该如何登记、派单和回访?',
'生活顾问与情绪激动的业主沟通时,有哪些原则和禁忌?'
];
const queryText = ref(topics[0]);
const answer = ref('');
const keyPoints = ref<string[]>([]);
const cautions = ref<string[]>([]);
const references = ref<string[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearResult = () => {
answer.value = '';
keyPoints.value = [];
cautions.value = [];
references.value = [];
};
const loadStudy = async () => {
if (loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
const query = queryText.value.trim();
if (!query) {
message.value = '请输入预习主题';
return;
}
loading.value = true;
message.value = '';
clearResult();
try {
const result = await searchKnowledge(query, '生活顾问');
answer.value = result.answer;
keyPoints.value = result.keyPoints || [];
cautions.value = result.cautions || [];
references.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 5);
if (!answer.value) message.value = '正式知识库暂未返回可用答案';
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearResult();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '岗前预习生成失败';
}
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) clearResult();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.query-panel {
border-left: 4px solid #e60012;
}
.query-label {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.query-input {
min-height: 88px;
margin-top: 10px;
}
.topic-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.topic-button {
min-height: 34px;
padding: 0 10px;
border: 1px solid #cbd5e1;
background: #ffffff;
color: #475569;
font-size: 12px;
}
.topic-button.active {
border-color: #e60012;
background: #fff1f2;
color: #b91c1c;
}
.generate-button {
width: 100%;
margin-top: 16px;
}
.answer-text {
display: block;
margin-top: 12px;
color: #172033;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
}
.answer-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.section-title {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.point-row {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: flex-start;
}
.point-index {
display: flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.point-text,
.caution-text {
color: #334155;
font-size: 14px;
line-height: 1.6;
}
.caution-text {
display: block;
margin-top: 8px;
}
.reference-row {
display: flex;
gap: 8px;
margin-top: 10px;
align-items: flex-start;
}
.reference-dot {
color: #b91c1c;
font-weight: 800;
}
.reference-title {
overflow-wrap: anywhere;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
padding: 56px 24px;
text-align: center;
}
.empty-title {
display: block;
color: #172033;
font-size: 17px;
font-weight: 800;
}
.empty-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
</style>
@@ -0,0 +1,48 @@
<template>
<view class="page building-page">
<text class="page-title">{{ title }}</text>
<text class="page-subtitle">这个入口正在迁移到新版用户侧,当前先保留明确反馈。</text>
<view class="panel">
<text class="panel-title">下一步</text>
<text class="muted block">优先完成可真实闭环的页面,再补这个入口。</text>
<button class="primary-button back-button" @click="goBack">返回</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { resetPageScroll } from '@/services/navigation';
const title = ref('建设中');
onLoad((query) => {
resetPageScroll();
title.value = query?.title ? `${decodeURIComponent(String(query.title))} · 建设中` : '建设中';
});
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => uni.switchTab({ url: '/pages/user/today/index' })
});
};
</script>
<style scoped>
.building-page {
min-height: 100vh;
background: #f4f7fb;
}
.block {
display: block;
margin-top: 10px;
line-height: 1.6;
}
.back-button {
margin-top: 18px;
}
</style>
@@ -0,0 +1,432 @@
<template>
<view class="page assign-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" title="返回" :disabled="assigning" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">指派专项</text>
<text class="page-subtitle">{{ selectedTargetLabel }}</text>
</view>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后指派并查看最近专项。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel assignment-form">
<text class="field-label">训练对象</text>
<input
v-model="targetExtPartyId"
class="input field-input"
maxlength="32"
placeholder="输入手机号或外部ID"
@input="targetName = ''"
/>
<input
v-model="targetName"
class="input field-input"
maxlength="40"
placeholder="员工姓名(可选)"
/>
<text class="field-label section-label">训练场景</text>
<view class="scenario-row">
<button
v-for="item in supervisorScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }"
:disabled="assigning"
@click="assignmentScenarioId = item.id"
>
{{ item.name }}
</button>
</view>
<text class="field-label section-label">指派原因</text>
<textarea
v-model="assignmentReason"
class="textarea reason-input"
maxlength="180"
placeholder="填写本次专项训练原因"
/>
<button class="primary-button submit-button" :disabled="assigning || historyLoading" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }}
</button>
</view>
<view class="panel history-panel">
<view class="section-head">
<text class="panel-title">最近专项</text>
<button
class="ghost-button refresh-button"
:disabled="historyLoading || assigning"
@click="loadAssignments()"
>
{{ historyLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="recentAssignments.length" class="assignment-list">
<view v-for="item in recentAssignments" :key="item.id" class="assignment-row">
<view class="assignment-copy">
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
<text class="assignment-desc">{{ assignmentSummary(item) }}</text>
</view>
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
</view>
</view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeTeamSnapshot } from '@/services/practice';
import {
createAsyncGenerationGuard,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
manualPracticeAssignments,
supervisorScenarioOptions,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/assign/index';
const targetExtPartyId = ref('');
const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const assigning = ref(false);
const historyLoading = ref(false);
const message = ref('');
const historyRequests = createAsyncGenerationGuard();
const selectedTargetLabel = computed(() => {
if (!loggedIn.value) return '选择员工并配置训练任务';
const target = targetExtPartyId.value.trim();
if (!target) return '选择员工并配置训练任务';
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
});
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const queryText = (value: unknown) => {
if (typeof value !== 'string') return '';
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
};
const isScenarioId = (value: string): value is SupervisorScenarioId =>
supervisorScenarioOptions.some((item) => item.id === value);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const resetDraft = () => {
targetExtPartyId.value = '';
targetName.value = '';
assignmentScenarioId.value = 'fee-parking';
assignmentReason.value = '';
};
const clearPrivateState = () => {
resetDraft();
assignments.value = [];
historyLoading.value = false;
historyRequests.invalidate();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearPrivateState();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAssignments = async (force = false) => {
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
return;
}
if (!force && (historyLoading.value || assigning.value)) return;
const generation = historyRequests.begin();
historyLoading.value = true;
try {
const snapshot = await getPracticeTeamSnapshot();
if (historyRequests.isCurrent(generation)) {
accessDenied.value = false;
assignments.value = snapshot.assignments;
}
} catch (error) {
if (historyRequests.isCurrent(generation)) {
handleRequestError(error, '最近专项加载失败');
}
} finally {
if (historyRequests.isCurrent(generation)) {
historyLoading.value = false;
}
}
};
const loadPage = () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearPrivateState();
return;
}
void loadAssignments();
};
const assignPractice = async () => {
if (assigning.value || historyLoading.value) return;
const extPartyId = targetExtPartyId.value.trim();
if (!extPartyId) {
message.value = '请填写员工手机号或外部ID';
return;
}
assigning.value = true;
message.value = '';
try {
const scenario = supervisorScenarioOptions.find((item) => item.id === assignmentScenarioId.value);
await createPracticeAssignment({
extPartyId,
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
scenarioId: assignmentScenarioId.value,
scenarioName: scenario?.name,
reason: assignmentReason.value.trim() || '主管派发专项训练'
});
resetDraft();
uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true);
} catch (error) {
handleRequestError(error, '指派失败');
} finally {
assigning.value = false;
}
};
const assignmentSourceLabel = (source: string) => {
if (source === 'retry') return '再练';
if (source === 'camp') return '训练营';
return '专项';
};
const assignmentSummary = (item: PracticeAssignment) => {
const reason = item.reason?.trim() || '主管派发专项训练';
return `${item.createTime} · ${item.status} · ${reason}`;
};
const goLogin = () => {
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (assigning.value) return;
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onLoad((query) => {
targetExtPartyId.value = queryText(query?.extPartyId);
targetName.value = queryText(query?.name);
assignmentReason.value = queryText(query?.reason);
const requestedScenario = queryText(query?.scenarioId);
assignmentScenarioId.value = isScenarioId(requestedScenario)
? requestedScenario
: inferSupervisorScenarioId(assignmentReason.value);
});
onShow(() => {
resetPageScroll();
loadPage();
});
onBeforeUnmount(() => {
historyRequests.dispose();
});
</script>
<style scoped>
.assign-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.login-panel,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 82px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.field-label {
display: block;
color: #334155;
font-size: 13px;
font-weight: 800;
}
.section-label {
margin-top: 16px;
}
.field-input,
.reason-input,
.scenario-row,
.submit-button {
margin-top: 9px;
}
.field-input + .field-input {
margin-top: 10px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.scenario-chip {
border-radius: 8px;
}
.scenario-chip.active {
background: #fee2e2;
color: #b91c1c;
}
.submit-button {
width: 100%;
}
.assignment-list {
margin-top: 8px;
}
.assignment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid #e2e8f0;
}
.assignment-row:last-child {
border-bottom: 0;
}
.assignment-copy {
min-width: 0;
flex: 1;
}
.assignment-title,
.assignment-desc {
display: block;
}
.assignment-title {
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.assignment-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.assignment-source {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.compact-empty {
padding: 18px 8px 4px;
}
</style>
@@ -0,0 +1,444 @@
<template>
<view class="page supervisor-page">
<view class="page-head">
<view>
<text class="page-title">主管</text>
<text class="page-subtitle">团队训练工作台</text>
</view>
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看团队训练与待复盘事项。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel overview-panel">
<view class="section-head">
<text class="panel-title">团队概览</text>
<text class="muted">实时数据</text>
</view>
<view class="metric-grid">
<view
v-for="item in supervisorMetrics"
:key="item.key"
class="metric-item"
:class="{ primary: item.primary }"
>
<text class="metric-value">{{ item.value }}</text>
<text class="metric-label">{{ item.title }}</text>
<text class="metric-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="panel task-panel">
<view class="section-head">
<text class="panel-title">任务入口</text>
<text class="muted">{{ teamCount }} 人</text>
</view>
<view class="task-list">
<button class="task-row" @click="openPage('/pages/supervisor/team/index')">
<view class="task-copy">
<text class="task-title">团队画像</text>
<text class="task-meta">{{ teamCount }} 名成员</text>
</view>
<text class="task-arrow">›</text>
</button>
<button class="task-row" @click="openPage('/pages/supervisor/reviews/index')">
<view class="task-copy">
<text class="task-title">待复盘</text>
<text class="task-meta">{{ pendingReviewCount }} 条待处理</text>
</view>
<text class="task-arrow">›</text>
</button>
<button class="task-row" @click="openPage('/pages/supervisor/assign/index')">
<view class="task-copy">
<text class="task-title">指派专项</text>
<text class="task-meta">{{ pendingAssignmentCount }} 项待训练</text>
</view>
<text class="task-arrow">›</text>
</button>
</view>
</view>
<view class="panel alert-panel">
<view class="section-head">
<text class="panel-title">团队预警</text>
<text class="muted">{{ actionableAlerts.length }} 条</text>
</view>
<view v-if="alerts.length" class="alert-list">
<button
v-for="item in alerts"
:key="`${item.type}-${item.title}`"
class="alert-row"
:class="item.severity"
@click="openPage('/pages/supervisor/team/index')"
>
<view class="alert-copy">
<text class="alert-title">{{ item.title }}</text>
<text class="alert-desc">{{ item.desc }}</text>
</view>
<text class="alert-arrow">›</text>
</button>
</view>
<view v-else class="empty compact-empty">暂无团队预警</view>
</view>
<view v-if="pendingPreview.length" class="panel preview-panel">
<view class="section-head">
<text class="panel-title">待复盘预览</text>
<button class="text-button" @click="openPage('/pages/supervisor/reviews/index')">查看全部</button>
</view>
<view class="preview-list">
<button
v-for="item in pendingPreview"
:key="item.id || item.sessionId || item.time"
class="preview-row"
@click="openReview(item)"
>
<view class="preview-copy">
<text class="preview-title">{{ item.trainee }} · {{ item.scene }}</text>
<text class="preview-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<text class="preview-score">{{ item.score }}</text>
</button>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
PracticeAlert,
PracticeAssignment,
PracticeRecord,
PracticeTeamMember
} from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice';
import {
actionablePracticeAlerts,
buildSupervisorMetrics,
buildTeamProfiles,
isSupervisorAccessDenied
} from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/index/index';
const pendingReviews = ref<PracticeRecord[]>([]);
const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const supervisorMetrics = computed(() =>
buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value));
const teamCount = computed(() => buildTeamProfiles(records.value, assignments.value, members.value).length);
const pendingReviewCount = computed(() =>
supervisorMetrics.value.find((item) => item.key === 'pendingReviews')?.value || 0);
const pendingAssignmentCount = computed(() =>
supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0);
const pendingPreview = computed(() => pendingReviews.value.slice(0, 3));
const actionableAlerts = computed(() => actionablePracticeAlerts(alerts.value));
const clearData = () => {
pendingReviews.value = [];
records.value = [];
assignments.value = [];
members.value = [];
alerts.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearData();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextReviews, nextAlerts, snapshot] = await Promise.all([
getPracticeReviews(),
getPracticeAlerts(),
getPracticeTeamSnapshot()
]);
accessDenied.value = false;
pendingReviews.value = nextReviews;
alerts.value = nextAlerts;
records.value = snapshot.records;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
} catch (error) {
handleRequestError(error, '主管端数据加载失败');
} finally {
loading.value = false;
}
};
const openPage = (url: string) => {
uni.navigateTo({ url });
};
const openReview = (item: PracticeRecord) => {
if (!item.id) {
message.value = '该记录缺少复盘ID';
return;
}
openPage(`/pages/supervisor/review/index?id=${item.id}`);
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.supervisor-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.page-head,
.section-head,
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 84px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.metric-grid {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 1px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #e2e8f0;
}
.metric-item {
min-height: 104px;
padding: 14px;
background: #f8fafc;
}
.metric-item.primary {
background: #fff5f5;
}
.metric-item.primary .metric-value {
color: #e70014;
}
.metric-item.primary .metric-label {
color: #bd0010;
}
.metric-value,
.metric-label,
.metric-desc {
display: block;
}
.metric-value {
color: #0f172a;
font-size: 28px;
font-weight: 900;
line-height: 1;
}
.metric-label {
margin-top: 9px;
color: #1e293b;
font-size: 14px;
font-weight: 800;
}
.metric-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.35;
}
.task-list,
.alert-list,
.preview-list {
margin-top: 10px;
}
.task-row,
.alert-row,
.preview-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-radius: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
text-align: left;
}
.task-row:last-child,
.alert-row:last-child,
.preview-row:last-child {
border-bottom: 0;
}
.task-copy,
.alert-copy,
.preview-copy {
min-width: 0;
flex: 1;
}
.task-title,
.alert-title,
.preview-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.task-meta,
.alert-desc,
.preview-desc {
display: block;
margin-top: 5px;
overflow: hidden;
color: #64748b;
font-size: 12px;
line-height: 1.45;
text-overflow: ellipsis;
}
.preview-desc {
white-space: nowrap;
}
.task-arrow,
.alert-arrow {
flex: 0 0 auto;
color: #94a3b8;
font-size: 24px;
line-height: 1;
}
.alert-row.high .alert-title {
color: #b91c1c;
}
.alert-row.medium .alert-title {
color: #9a3412;
}
.text-button {
min-height: 32px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
font-weight: 800;
}
.preview-score {
min-width: 42px;
flex: 0 0 auto;
color: #dc2626;
font-size: 22px;
font-weight: 900;
text-align: right;
}
.compact-empty {
padding: 16px 8px 4px;
}
</style>
@@ -0,0 +1,639 @@
<template>
<view class="page review-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回待复盘" title="返回待复盘" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">复盘详情</text>
<text class="page-subtitle">{{ detail ? `${detail.trainee} · ${detail.scene}` : '训练记录' }}</text>
</view>
<button v-if="loggedIn && reviewId && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadDetail">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看并完成复盘。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else-if="detail">
<view class="panel summary-panel">
<view class="summary-head">
<view class="summary-copy">
<text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text>
<text class="detail-meta">{{ detail.time }} · {{ detail.status }}</text>
</view>
<text class="score-pill">{{ detail.score }}分</text>
</view>
<text class="detail-desc">{{ detail.aiComment || detail.summary }}</text>
</view>
<view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text>
<view class="score-list">
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view class="score-copy">
<text class="score-label">{{ item.label }}</text>
<text class="score-note">{{ item.note }}</text>
</view>
<text class="score-value">{{ item.value }}</text>
</view>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<view class="dialogue-list">
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
<view v-if="hasAudio(turn)" class="dialogue-audio-block">
<audio v-if="audioSrc(turn, index)" class="dialogue-audio" controls :src="audioSrc(turn, index)"></audio>
<button
v-else
class="secondary-button audio-button"
:disabled="audioLoading[audioKey(index)]"
@click="loadAudio(turn, index)"
>
{{ audioLoading[audioKey(index)] ? '加载中' : '加载录音' }}
</button>
<text v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</text>
</view>
</view>
</view>
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">标注与建议</text>
<view v-if="detail.annotations?.length" class="annotation-list">
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
<text class="annotation-sop">依据:{{ item.sopPoint }}</text>
</view>
</view>
<view v-if="detail.mentorRewrite" class="mentor-block">
<text class="subsection-title">推荐话术</text>
<text class="mentor-text">{{ detail.mentorRewrite }}</text>
</view>
</view>
<view v-if="editable" class="panel review-form">
<text class="panel-title">主管复盘</text>
<text class="field-label">复盘建议</text>
<textarea
v-model="advice"
class="textarea review-textarea"
maxlength="300"
placeholder="填写复盘建议,可留空直接标记"
/>
<text class="field-label">人工贡献分</text>
<input
v-model="incentivePoint"
class="input point-input"
type="number"
maxlength="3"
placeholder="可留空"
/>
<view class="next-training-section">
<text class="field-label">复盘后专项</text>
<view class="scenario-row">
<button class="chip scenario-chip" :class="{ active: !nextScenarioId }" @click="clearNextScenario">
暂不派发
</button>
<button
v-for="item in supervisorScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: nextScenarioId === item.id }"
@click="selectNextScenario(item.id)"
>
{{ item.name }}
</button>
</view>
<textarea
v-if="nextScenarioId"
v-model="nextReason"
class="textarea next-reason-input"
maxlength="180"
placeholder="填写下一次训练原因"
/>
</view>
<button class="primary-button submit-button" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中' : '标记已复盘' }}
</button>
</view>
<view v-else class="panel review-complete">
<text class="complete-title">复盘已完成</text>
<text class="complete-desc">{{ detail.reviewAdvice || '该训练记录已完成主管复盘。' }}</text>
<text v-if="detail.incentivePoint != null" class="complete-point">
人工贡献分 {{ detail.incentivePoint }}
</text>
</view>
</template>
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
<view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice';
import {
createAsyncGenerationGuard,
isReviewEditable,
isSupervisorAccessDenied,
reviewCompletionAction,
supervisorScenarioOptions,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/review/index';
const reviewId = ref(0);
const detail = ref<ReviewDetail | null>(null);
const advice = ref('');
const incentivePoint = ref('');
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
const nextReason = ref('');
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const marking = ref(false);
const message = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard();
const editable = computed(() => isReviewEditable(detail.value?.status));
const audioKey = (index: number) => `${reviewId.value || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
const audioSrc = (turn: ReviewDialogue, index: number) =>
audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const releaseAudioSources = () => {
audioRequests.invalidate();
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const clearDetail = () => {
releaseAudioSources();
detail.value = null;
advice.value = '';
incentivePoint.value = '';
nextScenarioId.value = '';
nextReason.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearDetail();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAudio = async (turn: ReviewDialogue, index: number) => {
const key = audioKey(index);
if (audioSrc(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
const generation = audioRequests.capture();
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const source = await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`);
if (!audioRequests.isCurrent(generation)) {
releaseAuthenticatedFileUrl(source);
return;
}
audioSources.value = {
...audioSources.value,
[key]: source
};
} catch (error) {
if (!audioRequests.isCurrent(generation)) return;
if (isSupervisorAccessDenied(error)) {
handleRequestError(error, '录音加载失败');
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
if (error instanceof Error && error.message === '网络请求失败') {
message.value = '录音加载失败,请检查网络后重试';
}
} finally {
if (audioRequests.isCurrent(generation)) {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
}
};
const loadDetail = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
if (!reviewId.value) {
clearDetail();
message.value = '缺少有效的复盘ID';
return;
}
if (loading.value) return;
loading.value = true;
releaseAudioSources();
try {
const nextDetail = await getPracticeReviewDetail(reviewId.value);
accessDenied.value = false;
detail.value = nextDetail;
advice.value = detail.value.reviewAdvice || '';
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
nextScenarioId.value = '';
nextReason.value = '';
} catch (error) {
clearDetail();
handleRequestError(error, '复盘详情加载失败');
} finally {
loading.value = false;
}
};
const selectNextScenario = (scenarioId: SupervisorScenarioId) => {
nextScenarioId.value = scenarioId;
const scenario = supervisorScenarioOptions.find((item) => item.id === scenarioId);
nextReason.value = `复盘后建议补练${scenario?.name || '专项训练'}`;
};
const clearNextScenario = () => {
nextScenarioId.value = '';
nextReason.value = '';
};
const markReviewed = async () => {
if (marking.value || !detail.value?.id) return;
if (!editable.value) {
message.value = '该记录已完成复盘,不能重复提交';
return;
}
const pointText = incentivePoint.value.trim();
const point = pointText ? Number(pointText) : undefined;
if (point !== undefined && !Number.isFinite(point)) {
message.value = '贡献分必须是数字';
return;
}
marking.value = true;
message.value = '';
const assignedNext = Boolean(nextScenarioId.value);
try {
await markPracticeReviewed(detail.value.id, {
reviewer: '主管',
advice: advice.value.trim(),
incentivePoint: point,
nextScenarioId: nextScenarioId.value || undefined,
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
});
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
const pages = getCurrentPages();
const previousRoute = pages.length > 1 ? pages[pages.length - 2]?.route : undefined;
clearDetail();
if (reviewCompletionAction(previousRoute) === 'back') {
uni.navigateBack({ delta: 1, fail: goToReviews });
} else {
goToReviews();
}
} catch (error) {
handleRequestError(error, '标记失败');
} finally {
marking.value = false;
}
};
const currentPath = () => reviewId.value ? `${basePath}?id=${reviewId.value}` : basePath;
const goLogin = () => {
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToReviews = () => {
uni.redirectTo({ url: '/pages/supervisor/reviews/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
goToReviews();
return;
}
uni.navigateBack({ delta: 1, fail: goToReviews });
};
onLoad((query) => {
const value = Number(query?.id);
reviewId.value = Number.isInteger(value) && value > 0 ? value : 0;
});
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
if (!detail.value && !loading.value) void loadDetail();
});
onBeforeUnmount(() => {
audioRequests.dispose();
releaseAudioSources();
});
</script>
<style scoped>
.review-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-panel,
.summary-head,
.score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.review-complete {
border-left: 4px solid #16a34a;
}
.complete-title,
.complete-desc,
.complete-point {
display: block;
}
.complete-title {
color: #166534;
font-size: 16px;
font-weight: 900;
}
.complete-desc,
.complete-point {
margin-top: 8px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.summary-copy,
.score-copy {
min-width: 0;
flex: 1;
}
.detail-title,
.detail-meta,
.detail-desc,
.score-label,
.score-note,
.dialogue-label,
.dialogue-text,
.annotation-issue,
.annotation-rewrite,
.annotation-sop,
.subsection-title,
.mentor-text,
.field-label {
display: block;
}
.detail-title {
color: #0f172a;
font-size: 17px;
font-weight: 900;
}
.detail-meta {
margin-top: 5px;
color: #64748b;
font-size: 12px;
}
.detail-desc {
margin-top: 12px;
color: #475569;
font-size: 14px;
line-height: 1.55;
}
.score-pill {
flex: 0 0 auto;
padding: 7px 10px;
border-radius: 8px;
background: #fee2e2;
color: #b91c1c;
font-size: 15px;
font-weight: 900;
}
.score-list,
.dialogue-list,
.annotation-list {
margin-top: 10px;
}
.score-row,
.dialogue-row,
.annotation-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.score-row:last-child,
.dialogue-row:last-child,
.annotation-row:last-child {
border-bottom: 0;
}
.score-label,
.dialogue-label,
.annotation-issue,
.subsection-title,
.field-label {
color: #1e293b;
font-size: 14px;
font-weight: 800;
}
.score-note,
.dialogue-text,
.annotation-rewrite,
.annotation-sop,
.mentor-text {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.score-value {
flex: 0 0 auto;
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.dialogue-audio-block {
margin-top: 10px;
}
.dialogue-audio {
display: block;
width: 100%;
}
.audio-button {
width: 104px;
min-height: 36px;
font-size: 13px;
}
.audio-error {
display: block;
margin-top: 6px;
color: #dc2626;
font-size: 12px;
}
.mentor-block {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.field-label {
margin-top: 16px;
}
.review-textarea,
.point-input,
.scenario-row,
.next-reason-input,
.submit-button {
margin-top: 9px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.scenario-chip {
border-radius: 8px;
}
.scenario-chip.active {
background: #fee2e2;
color: #b91c1c;
}
.next-training-section {
margin-top: 16px;
padding-top: 2px;
border-top: 1px solid #e2e8f0;
}
.submit-button,
.back-list-button {
width: 100%;
}
.empty-panel {
padding-bottom: 18px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<view class="page reviews-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回主管首页" title="返回主管首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">待复盘</text>
<text class="page-subtitle">{{ reviewCountText }}</text>
</view>
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看待复盘对练。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-else class="panel review-panel">
<view class="section-head">
<text class="panel-title">复盘队列</text>
<text class="muted">低分优先</text>
</view>
<view v-if="reviews.length" class="review-list">
<button
v-for="item in reviews"
:key="item.id || item.sessionId || item.time"
class="review-row"
@click="openReview(item)"
>
<view class="review-copy">
<text class="review-title">{{ item.trainee }} · {{ item.scene }}</text>
<text class="review-desc">{{ item.time }} · {{ item.summary }}</text>
<text class="review-status">{{ item.status }}</text>
</view>
<view class="review-score">
<text>{{ item.score }}</text>
<text>查看</text>
</view>
</button>
</view>
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeRecord } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/reviews/index';
const reviews = ref<PracticeRecord[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const pendingReviewTotal = computed(() =>
buildSupervisorMetrics(reviews.value, [], alerts.value)
.find((item) => item.key === 'pendingReviews')?.value || 0);
const reviewCountText = computed(() =>
pendingReviewTotal.value > reviews.value.length
? `${reviews.value.length}/${pendingReviewTotal.value} 条`
: `${reviews.value.length} 条`);
const clearData = () => {
reviews.value = [];
alerts.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearData();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextReviews, nextAlerts] = await Promise.all([
getPracticeReviews(),
getPracticeAlerts()
]);
accessDenied.value = false;
reviews.value = nextReviews;
alerts.value = nextAlerts;
} catch (error) {
handleRequestError(error, '待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const openReview = (item: PracticeRecord) => {
if (!item.id) {
message.value = '该记录缺少复盘ID';
return;
}
uni.navigateTo({ url: `/pages/supervisor/review/index?id=${item.id}` });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.reviews-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-panel,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.review-list {
margin-top: 8px;
}
.review-row {
display: flex;
width: 100%;
min-height: 88px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-radius: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
text-align: left;
}
.review-row:last-child {
border-bottom: 0;
}
.review-copy {
min-width: 0;
flex: 1;
}
.review-title,
.review-desc,
.review-status {
display: block;
}
.review-title {
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.review-desc {
margin-top: 5px;
overflow: hidden;
color: #64748b;
font-size: 12px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.review-status {
margin-top: 6px;
color: #c2410c;
font-size: 12px;
font-weight: 800;
}
.review-score {
display: flex;
min-width: 52px;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #64748b;
font-size: 12px;
}
.review-score text:first-child {
color: #dc2626;
font-size: 24px;
font-weight: 900;
}
</style>
@@ -0,0 +1,366 @@
<template>
<view class="page team-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回主管首页" title="返回主管首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">团队画像</text>
<text class="page-subtitle">{{ teamProfiles.length }} 名成员</text>
</view>
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看团队画像。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="risk-summary">
<view class="risk-item high">
<text class="risk-value">{{ riskCounts.high }}</text>
<text class="risk-label">高风险</text>
</view>
<view class="risk-item medium">
<text class="risk-value">{{ riskCounts.medium }}</text>
<text class="risk-label">待复盘</text>
</view>
<view class="risk-item low">
<text class="risk-value">{{ riskCounts.low }}</text>
<text class="risk-label">正常</text>
</view>
</view>
<view class="panel team-panel">
<view class="section-head">
<text class="panel-title">成员列表</text>
<text class="muted">按风险排序</text>
</view>
<view v-if="teamProfiles.length" class="team-list">
<view v-for="member in teamProfiles" :key="member.key" class="team-row" :class="member.risk">
<view class="team-main">
<view class="team-title-row">
<text class="team-name">{{ member.displayName }}</text>
<text class="team-score">{{ member.latestScoreText }}</text>
</view>
<text class="team-desc">{{ member.reason }}</text>
<view class="team-tags">
<text class="team-tag">待复盘 {{ member.pendingReview }}</text>
<text class="team-tag">待训练 {{ member.pendingAssignment }}</text>
<text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text>
</view>
</view>
<button class="secondary-button team-action" @click="assignMember(member)">派专项</button>
</view>
</view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice';
import {
buildTeamProfiles,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
type TeamProfile
} from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/team/index';
const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
const riskCounts = computed(() => teamProfiles.value.reduce(
(counts, item) => {
counts[item.risk] += 1;
return counts;
},
{ high: 0, medium: 0, low: 0 }
));
const clearData = () => {
records.value = [];
assignments.value = [];
members.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearData();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const snapshot = await getPracticeTeamSnapshot();
accessDenied.value = false;
records.value = snapshot.records;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
} catch (error) {
handleRequestError(error, '团队画像加载失败');
} finally {
loading.value = false;
}
};
const assignMember = (member: TeamProfile) => {
const scenarioId = inferSupervisorScenarioId(`${member.latestScene} ${member.reason}`);
const query = [
`extPartyId=${encodeURIComponent(member.extPartyId)}`,
`name=${encodeURIComponent(member.name)}`,
`scenarioId=${encodeURIComponent(scenarioId)}`,
`reason=${encodeURIComponent(member.reason)}`
].join('&');
uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.team-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.login-panel,
.section-head,
.team-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.risk-summary {
display: grid;
margin-top: 16px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
grid-template-columns: repeat(3, 1fr);
}
.risk-item {
padding: 13px 8px;
border-right: 1px solid #e2e8f0;
text-align: center;
}
.risk-item:last-child {
border-right: 0;
}
.risk-value,
.risk-label {
display: block;
}
.risk-value {
color: #0f172a;
font-size: 23px;
font-weight: 900;
}
.risk-label {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.risk-item.high .risk-value {
color: #b91c1c;
}
.risk-item.medium .risk-value {
color: #c2410c;
}
.risk-item.low .risk-value {
color: #15803d;
}
.team-list {
margin-top: 8px;
}
.team-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 0 14px 12px;
border-bottom: 1px solid #e2e8f0;
border-left: 4px solid #22c55e;
}
.team-row:last-child {
border-bottom: 0;
}
.team-row.high {
border-left-color: #dc2626;
}
.team-row.medium {
border-left-color: #f59e0b;
}
.team-main {
min-width: 0;
flex: 1;
}
.team-name {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 15px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-score {
flex: 0 0 auto;
color: #1d4ed8;
font-size: 14px;
font-weight: 900;
}
.team-desc {
display: block;
margin-top: 6px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.team-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.team-tag {
padding: 4px 7px;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.team-action {
min-width: 72px;
min-height: 38px;
flex: 0 0 auto;
padding: 0 10px;
font-size: 12px;
}
</style>
+380
View File
@@ -0,0 +1,380 @@
<template>
<view class="page cases-page">
<view class="subpage-header">
<button
class="subpage-back-button"
aria-label="返回今日"
title="返回今日"
@click="navigateBackToToday"
>
←
</button>
<view class="subpage-heading">
<text class="page-title">案例素材</text>
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能提交案例素材。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-else class="panel">
<text class="panel-title">提交素材</text>
<view class="file-box" :class="{ 'has-file': Boolean(selectedFile) }">
<text class="file-title">{{ selectedFile?.name || '未选择音频' }}</text>
<text class="file-desc">{{ selectedFile ? formatSize(selectedFile.size) : '支持 mp3 / wav / m4a / webm 等音频' }}</text>
</view>
<view class="inline-actions case-actions">
<button class="secondary-button" :disabled="busy" @click="selectAudio">选择音频</button>
<button class="primary-button" :disabled="busy || !selectedFile" @click="submitCase">
{{ busy ? statusText : '上传并入库' }}
</button>
</view>
</view>
<view v-if="uploadResult" class="panel">
<text class="panel-title">ASR 转写</text>
<view class="tag-row">
<text v-for="tag in uploadResult.tags" :key="tag.text" class="case-tag">{{ tag.text }}</text>
</view>
<text class="case-content">{{ uploadResult.transcript }}</text>
</view>
<view v-if="organizeResult" class="panel">
<text class="panel-title">结构化案例稿</text>
<view v-for="item in organizeResult.summary" :key="item.label" class="summary-row">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-text">{{ item.text }}</text>
</view>
<text class="case-note">{{ organizeResult.aiSummary }}</text>
</view>
<view v-if="curateResult?.records?.length" class="panel">
<text class="panel-title">入库结果</text>
<view v-for="record in curateResult.records" :key="record.title" class="record-row">
<view>
<text class="record-title">{{ record.title }}</text>
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
<text class="record-summary">{{ record.summary }}</text>
</view>
<text class="record-status">{{ record.status }}</text>
</view>
<text v-if="curateResult.sampleHint" class="case-note">{{ curateResult.sampleHint }}</text>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">最近案例</text>
<button class="ghost-button refresh-button" :disabled="recordsLoading" @click="refreshRecords">
刷新
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="record in records" :key="`${record.title}-${record.time}`" class="record-row">
<view>
<text class="record-title">{{ record.title }}</text>
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
<text class="record-summary">{{ record.summary }}</text>
</view>
<text class="record-status">{{ record.status }}</text>
</view>
</view>
<view v-else class="empty">暂无入库案例</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CaseCurateResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
import { chooseCaseAudio, curateCase, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
const loggedIn = ref(false);
const selectedFile = ref<CaseSelectedFile | null>(null);
const uploadResult = ref<CaseUploadResponse | null>(null);
const organizeResult = ref<CaseOrganizeResponse | null>(null);
const curateResult = ref<CaseCurateResponse | null>(null);
const records = ref<CaseRecord[]>([]);
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
const recordsLoading = ref(false);
const message = ref('');
const busy = computed(() => status.value !== 'idle');
const statusText = computed(() => {
if (status.value === 'uploading') return '转写中';
if (status.value === 'organizing') return '整理中';
if (status.value === 'curating') return '入库中';
return '处理中';
});
const resetResult = () => {
uploadResult.value = null;
organizeResult.value = null;
curateResult.value = null;
};
const clearCaseData = () => {
selectedFile.value = null;
records.value = [];
resetResult();
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
clearCaseData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const selectAudio = async () => {
if (busy.value || !loggedIn.value) return;
message.value = '';
try {
selectedFile.value = await chooseCaseAudio();
resetResult();
} catch (error) {
message.value = error instanceof Error ? error.message : '选择音频失败';
}
};
const loadRecords = async () => {
if (recordsLoading.value) return;
if (!loggedIn.value) {
records.value = [];
return;
}
recordsLoading.value = true;
try {
records.value = await getCaseRecords();
} catch (error) {
handleRequestError(error, '最近案例加载失败', true);
} finally {
recordsLoading.value = false;
}
};
const refreshRecords = () => {
if (!loggedIn.value) {
goLogin();
return;
}
void loadRecords();
};
const submitCase = async () => {
if (!selectedFile.value || busy.value) return;
message.value = '';
resetResult();
try {
status.value = 'uploading';
uploadResult.value = await uploadCaseAudio(selectedFile.value);
status.value = 'organizing';
organizeResult.value = await organizeCase(uploadResult.value.caseId);
status.value = 'curating';
curateResult.value = await curateCase(uploadResult.value.caseId);
await loadRecords();
selectedFile.value = null;
uni.showToast({ title: '案例已入库', icon: 'none' });
} catch (error) {
handleRequestError(error, '案例处理失败', true);
} finally {
status.value = 'idle';
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/cases/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const formatSize = (size: number) => {
if (!size) return '未知大小';
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
};
const handleShow = async () => {
resetPageScroll();
refreshAuthState();
if (loggedIn.value) {
if (!(await ensureEmployeePosition('/pages/user/cases/index'))) {
return;
}
void loadRecords();
} else {
clearCaseData();
}
};
onShow(() => {
void handleShow();
});
</script>
<style scoped>
.cases-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.login-button {
min-width: 84px;
}
.file-box {
margin-top: 14px;
padding: 14px;
border: 1px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
}
.file-box.has-file {
border-color: #168243;
background: #f2f8f4;
}
.file-title,
.file-desc,
.case-content,
.case-note,
.record-title,
.record-desc,
.record-summary {
display: block;
}
.file-title,
.record-title {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
.file-desc,
.record-desc {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.case-actions {
margin-top: 12px;
}
.case-actions button {
flex: 1;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.case-tag {
padding: 5px 8px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 800;
}
.case-content,
.case-note,
.record-summary {
margin-top: 12px;
color: #334155;
font-size: 14px;
line-height: 1.6;
}
.summary-row,
.record-row {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.summary-label {
display: block;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
}
.summary-text {
display: block;
margin-top: 6px;
color: #334155;
font-size: 14px;
line-height: 1.55;
}
.record-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.record-row > view {
min-width: 0;
flex: 1;
}
.record-status {
flex: 0 0 auto;
color: #16a34a;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
</style>

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