feat(aihr): complete uni-app role workflows

This commit is contained in:
2026-07-09 10:42:20 +08:00
parent e4070c8b8c
commit 678543e8a5
73 changed files with 15696 additions and 134 deletions
+2
View File
@@ -14,4 +14,6 @@ frontend/dist/
frontend/.vite/
mobile/node_modules/
mobile/dist/
mobile-uni/node_modules/
mobile-uni/dist/
.tmp/
+7 -6
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`。
- 除非确实要接业务模块,不要改上游框架默认配置;本地端口和开关优先放根目录脚本里覆盖。
@@ -22,7 +22,8 @@
- 本地私密配置:根目录 `.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` 启动 `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`,否则中文昵称可能按错误字符集导入并触发字段长度问题。
@@ -30,15 +31,15 @@
## 验证口径
- 前端首页:`http://127.0.0.1:5173/`
- 移动端首页:`http://127.0.0.1:5174/h5/user`
- 用户侧首页:`https://wygj-mobile-uni.localhost/h5/`
- 租户接口:`http://127.0.0.1:5173/dev-api/auth/tenant/list`
- 验证码接口:`http://127.0.0.1:5173/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,7 +56,7 @@
- 资料处理:`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 修正项,修正项优先于图面);员工端训练列表页是 `/h5/user/training`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入独立 `/h5/user/practice` 对话页,不能在训练列表里用锚点或页内展开。师傅四状态(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`。
+7 -5
View File
@@ -6,15 +6,15 @@
- 后端:`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`;员工端另有 `/h5/user/training` 训练列表页和 `/h5/user/practice` 对练页;已接手机号短信登录、`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` 拉取公司、部门、员工快照并刷新本地组织人员口径。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
@@ -22,9 +22,10 @@
```bash
./scripts/dev.sh
portless
```
首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。详细端口、默认账号和验证项见 [本地运行手册](docs/DEV_SETUP.md)。
首次启动或需要重建本地 MVP 数据库时执行 `./scripts/dev.sh --reset`。`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;`portless` 启动当前 `mobile-uni` 用户侧。详细端口、默认账号和验证项见 [本地运行手册](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 原件 |
@@ -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,13 +1,16 @@
package org.dromara.aihr.controller;
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.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
@@ -21,4 +24,13 @@ public class AihrOrgSyncController {
public R<SyncResponse> sync(@RequestBody(required = false) SyncRequest request) {
return R.ok(orgSyncService.sync(request));
}
@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 = "200") Integer limit) {
return R.ok(orgSyncService.snapshot(keyword, projectCode, positionLevel, status, limit));
}
}
@@ -128,7 +128,7 @@ public class AihrPracticeController {
if (username.isBlank()) {
return request;
}
return new StartRequest(username, request.scenarioId(), request.mode());
return new StartRequest(username, request.scenarioId(), request.mode(), request.assignmentId());
}
private static String currentAppUsername() {
@@ -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) {
@@ -30,4 +30,34 @@ public final class AihrOrgSyncDto {
List<String> warnings
) {
}
public record OrgSnapshotResponse(
int total,
int active,
int departed,
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(
@@ -97,7 +97,7 @@ public final class AihrPracticeDto {
) {
}
public record FinishRequest(String sessionId) {
public record FinishRequest(String sessionId, Long assignmentId) {
}
public record FinishResponse(
@@ -113,7 +113,7 @@ 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, String audioUrl, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
@@ -83,7 +83,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) {
@@ -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(
@@ -167,7 +167,7 @@ public class AihrMobileSeedService {
new PrimaryTaskResponse(
"重点训练",
"投诉接待三角色对练",
"3分钟语音练习 · 完成后自动评分",
"3分钟情景练习 · 完成后自动评分",
"1/1",
100,
"开始训练",
@@ -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;
@@ -65,7 +66,7 @@ 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();
@@ -112,6 +113,39 @@ public class AihrOrgSyncService {
);
}
public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status, Integer limit) {
requireSnapshotTable();
int safeLimit = clamp(limit, 200, 1, MAX_PAGE_SIZE);
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);
List<Object> rowArgs = new ArrayList<>(args);
rowArgs.add(safeLimit);
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 ?
""", (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, 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 +165,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 +224,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 +252,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) {
@@ -325,6 +360,69 @@ 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 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);
}
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("""
@@ -367,7 +465,7 @@ public class AihrOrgSyncService {
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 +515,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));
@@ -255,7 +255,7 @@ public class AihrPracticeSeedService {
String trainee = resolveTrainee(request, scenario);
List<String> customerLines = new ArrayList<>();
customerLines.add(firstRound.customer());
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>()));
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.now(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>()));
return new StartResponse(
sessionId,
scenario.id(),
@@ -411,8 +411,10 @@ public class AihrPracticeSeedService {
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
PracticeResult result = evaluate(activeSession, scenario);
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), result.total(), "待复盘", result.summary());
String extPartyId = activeSession == null ? trainee : activeSession.extPartyId();
RecordResponse record = new RecordResponse(null, sessionId, formatNow(), trainee, scenario.name(), result.total(), "待复盘", result.summary(), extPartyId);
savePracticeRecord(sessionId, activeSession, scenario, record, result);
markAssignmentCompleted(request == null ? null : request.assignmentId(), activeSession);
return new FinishResponse(
result.total(),
result.scores(),
@@ -459,7 +461,7 @@ public class AihrPracticeSeedService {
int safeLimit = normalizeLimit(limit);
if (isBlank(extPartyId)) {
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
@@ -467,7 +469,7 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, safeLimit);
}
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
ORDER BY finished_time DESC, id DESC
@@ -478,10 +480,10 @@ public class AihrPracticeSeedService {
public List<RecordResponse> pendingReviewRecords(int limit) {
ensurePracticeTable();
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
ORDER BY finished_time DESC, id DESC
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
}
@@ -729,7 +731,9 @@ public class AihrPracticeSeedService {
), TENANT_ID, party, party);
CompetencySnapshot snapshot = rows.isEmpty() ? new CompetencySnapshot(0, 82, 0, 82, 82, 82, 0) : rows.get(0);
int trainingMinutes = snapshot.completed() * 8;
int contribution = Math.min(100, snapshot.incentivePoints() + snapshot.completed() * 8 + Math.max(0, 100 - snapshot.pendingReview() * 10) / 5);
int contribution = snapshot.completed() == 0 && snapshot.incentivePoints() == 0
? 0
: Math.min(100, snapshot.incentivePoints() + snapshot.completed() * 8 + Math.max(0, 100 - snapshot.pendingReview() * 10) / 5);
int assessmentScore = snapshot.score();
String aiLevel = aiLevel(snapshot.completed(), assessmentScore);
return new CompetencyResponse(
@@ -827,7 +831,11 @@ public class AihrPracticeSeedService {
WHERE tenant_id = ?
AND (? = '' OR ext_party_id = ?)
AND (status = '待训练' OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE()))
ORDER BY create_time DESC, id DESC
ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC,
create_time DESC,
id DESC
LIMIT ?
""", this::mapAssignment, TENANT_ID, party, party, normalizeLimit(limit));
}
@@ -844,11 +852,7 @@ public class AihrPracticeSeedService {
if (existingToday >= 3) {
return;
}
List<DailyDrillSeed> drills = List.of(
new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
);
List<DailyDrillSeed> drills = dailyDrills();
int start = Math.max(0, existingToday);
for (int i = start; i < drills.size(); i++) {
DailyDrillSeed drill = drills.get(i);
@@ -864,7 +868,7 @@ public class AihrPracticeSeedService {
extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId,
scenario.id(),
scenario.name(),
drill.question() + " 参考答案:" + drill.referenceAnswer(),
drill.question(),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
@@ -880,15 +884,48 @@ public class AihrPracticeSeedService {
String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId);
String source = firstNonBlank(request == null ? null : request.source(), "manual");
String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练");
Timestamp createdAt = Timestamp.valueOf(now);
jdbcTemplate.update("""
INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?)
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, Timestamp.valueOf(now), Timestamp.valueOf(now));
List<PracticeAssignmentResponse> rows = assignments(extPartyId, 1);
""", TENANT_ID, extPartyId, traineeName, scenario.id(), firstNonBlank(request == null ? null : request.scenarioName(), scenario.name()), source, reason, createdAt, createdAt);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, feedback, correct
FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
ORDER BY id DESC
LIMIT 1
""", this::mapAssignment, TENANT_ID, extPartyId, scenario.id(), source, reason);
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0);
}
private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId;
if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) {
return;
}
ensureAssignmentTable();
jdbcTemplate.update("""
UPDATE aihr_practice_assignment
SET status = '已完成',
finished_time = ?,
update_time = ?
WHERE tenant_id = ?
AND id = ?
AND ext_party_id = ?
AND source <> 'daily'
AND status = '待训练'
""",
Timestamp.valueOf(LocalDateTime.now()),
Timestamp.valueOf(LocalDateTime.now()),
TENANT_ID,
assignmentId,
activeSession.extPartyId()
);
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
return answerDailyDrill(id, request, "");
}
@@ -900,7 +937,7 @@ public class AihrPracticeSeedService {
}
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, reason, source, status
SELECT id, reason, source, status, scenario_id
FROM aihr_practice_assignment
WHERE tenant_id = ? AND id = ? AND source = 'daily'
AND (? = '' OR ext_party_id = ?)
@@ -909,7 +946,7 @@ public class AihrPracticeSeedService {
if (rows.isEmpty()) {
return null;
}
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"));
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
DailyDrillScore score = scoreDailyDrill(answer, parts.referenceAnswer());
LocalDateTime now = LocalDateTime.now();
@@ -1233,7 +1270,8 @@ public class AihrPracticeSeedService {
rs.getString("scenario_name"),
rs.getInt("total_score"),
rs.getString("status"),
rs.getString("summary")
rs.getString("summary"),
rs.getString("ext_party_id")
);
}
@@ -1241,7 +1279,8 @@ public class AihrPracticeSeedService {
Timestamp createTime = rs.getTimestamp("create_time");
String source = rs.getString("source");
String reason = rs.getString("reason");
DailyDrillParts daily = dailyDrillParts(reason);
String status = rs.getString("status");
DailyDrillParts daily = dailyDrillParts(reason, rs.getString("scenario_id"));
Object correctValue = rs.getObject("correct");
return new PracticeAssignmentResponse(
rs.getLong("id"),
@@ -1250,23 +1289,36 @@ public class AihrPracticeSeedService {
rs.getString("scenario_id"),
rs.getString("scenario_name"),
source,
reason,
rs.getString("status"),
"daily".equals(source) ? daily.question() : reason,
status,
createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER),
"daily".equals(source) ? daily.question() : null,
"daily".equals(source) ? daily.referenceAnswer() : null,
"daily".equals(source) && "已完成".equals(status) ? daily.referenceAnswer() : null,
rs.getString("answer_text"),
rs.getString("feedback"),
"daily".equals(source) ? stripReferenceAnswer(rs.getString("feedback")) : rs.getString("feedback"),
correctValue == null ? null : rs.getBoolean("correct")
);
}
private DailyDrillParts dailyDrillParts(String reason) {
private List<DailyDrillSeed> dailyDrills() {
return List.of(
new DailyDrillSeed("complaint-water", "每日三题 1:业主投诉车库积水,第一句话先说什么?", "先承接情绪,再确认点位、责任人和首次反馈时间。"),
new DailyDrillSeed("fee-collection", "每日三题 2:业主拒缴物业费并质疑服务,如何拆分问题?", "把服务整改和费用沟通分开,说明复核、整改跟进和缴费节点。"),
new DailyDrillSeed("repair-leak", "每日三题 3:报修跟进超时,如何给下一步承诺?", "说明已核实的信息、责任人、到场或反馈时间,并主动同步变化。")
);
}
private DailyDrillParts dailyDrillParts(String reason, String scenarioId) {
String safeReason = firstNonBlank(reason, "");
String marker = "参考答案:";
int markerIndex = safeReason.indexOf(marker);
if (markerIndex < 0) {
return new DailyDrillParts(safeReason, "");
String reference = dailyDrills().stream()
.filter(drill -> drill.question().equals(safeReason) || drill.scenarioId().equals(scenarioId))
.map(DailyDrillSeed::referenceAnswer)
.findFirst()
.orElse("");
return new DailyDrillParts(safeReason, reference);
}
String question = safeReason.substring(0, markerIndex).trim();
String reference = safeReason.substring(markerIndex + marker.length()).trim();
@@ -1295,11 +1347,16 @@ public class AihrPracticeSeedService {
boolean correct = score >= 70;
String feedback = (correct ? "回答方向正确。" : "还缺关键动作。")
+ (hit.isEmpty() ? "" : "已命中:" + String.join("、", hit) + "。")
+ (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。")
+ (isBlank(reference) ? "" : "参考答案:" + reference);
+ (missing.isEmpty() ? "" : "建议补充:" + String.join("、", missing) + "。");
return new DailyDrillScore(correct, score, feedback);
}
private String stripReferenceAnswer(String feedback) {
String text = firstNonBlank(feedback, "");
int index = text.indexOf("参考答案:");
return index < 0 ? text : text.substring(0, index).trim();
}
private List<String> dailyDrillKeywords(String reference) {
List<String> keywords = List.of("承接情绪", "确认", "点位", "责任人", "反馈时间", "服务整改", "费用沟通", "节点", "核实信息", "到场", "同步变化");
List<String> result = new ArrayList<>();
@@ -2507,7 +2564,7 @@ public class AihrPracticeSeedService {
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, boolean mobile, LocalDateTime startedAt,
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence) {
}
@@ -2216,7 +2216,7 @@ public class AihrSopSeedService {
}
SearchDigest digest = searchDigest(queryText, hits);
if (digest == null) {
return noEvidenceResponse(queryText, responseCategory, extraCautions);
return rawHitResponse(responseCategory, queryText, List.copyOf(docs.values()), snippets, extraCautions);
}
if (isNoEvidenceAnswer(digest.answer())) {
return noEvidenceResponse(queryText, responseCategory, appendCautions(digest.cautions(), extraCautions));
@@ -2236,6 +2236,25 @@ public class AihrSopSeedService {
);
}
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
);
}
/**
* RAG 生成环节:把命中片段交给 chat 模型压成「直接回答+要点+注意事项」结构化概要。
* 模型未配置、超时或解析失败返回 null,由调用方回退片段原文拼接——检索永不因概要失败而报错。
@@ -2509,6 +2528,13 @@ 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());
}
@@ -0,0 +1,29 @@
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("未生成结构化概要,已返回命中原文片段。"));
}
}
@@ -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());
@@ -0,0 +1,231 @@
# 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/
candidate/index/
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 -> http://127.0.0.1:8080`。
## 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. 当前进展
- 第一批用户侧主链路已落地到 `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` 启动;脚本输出同步提示 `wygj-mobile-uni.localhost`。
- 主管端已接入最小复盘闭环: 团队概览、待复盘列表、复盘详情、标记已复盘。
- 候选人端已接入最小闭环: 候选首页、结构化面试、最近面试记录、补充资料列表、文件上传、审核状态展示。
- 主管到员工的训练闭环已接入: 主管指派专项、员工今日任务、每日三题提交与反馈。
- 三端登录返回已修正: 从主管/候选人/员工子页进入登录后返回原任务页,已有登录态进入登录页时自动回跳。
- 登录页已补方法级重复请求保护: 发验证码和登录除按钮禁用外,函数入口也会挡住重复触发。
- 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 分。
- 员工端训练前预习卡已接入: 新 uni-app 对练页复用 `/api/train/practice/scenarios/{id}/prep-card`,开始前展示要点、红线和可说话术。
- 员工端训练前预习卡与页头已收敛展示: 隐藏内部 promptVersion,训练卡标题跟随当前专项场景,页头不再固定写“投诉接待”。
- 员工端训练中已接 SOP 求助: 新 uni-app 对练页复用 `/api/knowledge/search`,展示正式检索答案和引用,失败明确报错不兜假答案。
- 员工端对练建议已接调节按钮: 老师傅建议生成后可按“再严肃/再温柔/更细致/更专业”调用 `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`;无项目范围接口时只显示空态,不再展示假项目样例。
## 7. 风险
- uni-app npm tag 不是普通语义化版本,依赖必须固定同一 `vue3` release train。
- H5 路由和将来小程序路径不完全一致,页面命名要早期克制。
- 旧 H5 在 Alpha 前仍承担演示兜底,新工程未完成替换前不能下线旧入口。
- 如果后端某些接口仍要求管理端 token,需要后端补移动端认证口径,不能在新前端绕过认证。
@@ -0,0 +1,72 @@
# 发布会演示候选题单
> 生成时间:2026-07-08
> 来源口径:基于已入库正式资料生成,不使用本地 smoke 题作为验收依据。
> 状态说明:本文件是开发方候选题单,最终题单需业务方确认后锁定。
## 生成依据
- 正式资料目录:`/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 | 信息安全 | 业主电话、客户资料能不能随便发到微信群,生活顾问应注意什么? | `信息安全管理培训.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 个视频完成转写后,再补一版“视频微课专项题单”。
+14 -12
View File
@@ -12,11 +12,11 @@
| 案例沉淀 `/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`,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed |
| 移动端手机号登录 | `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 保演示 |
| 用户侧三端首页 `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`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV |
| 移动端候选人闭环 | 面试复用 `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 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
| 移动端候选人闭环 | 面试复用 `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` 调用;APP 候选人资料、面试启动和面试记录以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
## 后端落点
@@ -43,9 +43,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,11 +92,13 @@ 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>
@@ -126,16 +128,16 @@ 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`;员工端子路由为 `/h5/user/training`(训练列表)和 `/h5/user/practice`(对练页)。当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
- 员工端训练:底部“练”进入 `/h5/user/training`,首屏展示场景模拟、开始练习和最近练过,每日三题后置;点“开始练习”切换到 `/h5/user/practice`,复用管理端三角色对练 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
npm --prefix mobile-uni run build:h5
curl -fsS http://127.0.0.1:8080/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
+4 -4
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,7 +15,7 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」;点底部“练”进入 `/h5/user/training`,首屏必须先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”必须切到 `/h5/user/practice` 对练页,完成两轮提交,再打开主管端;点快捷 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` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端今日页进入问师傅、练习、案例素材和个人页;练习页完成开始练习、提交回应、结束评分、每日三题提交;问师傅页可检索、反馈、生成总结卡;案例素材页可上传语音并回读入库记录;候选人端点“开始面试/面试练习”和“补充资料”;主管端查看团队画像、待复盘、标记已复盘并从画像派专项 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我);未实现入口进「建设中」整页(返回按钮),无死按钮;主管端待复盘列表增加,复盘详情可看评分、话术、导师改写,并可标记已复盘;从团队画像派专项后最近专项立即回读;候选人端可拉题/答题/评分,资料上传后显示审核状态;HR 审核后候选人端显示 `已通过/已驳回` |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 |
@@ -27,8 +27,8 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
| 2 | 员工端 `/h5/user` | 今日安排 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/supervisor` | 预警/待复盘 → 进入复盘详情 → 听回放/看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;员工端今日安排出现专项/再练任务 |
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 导出试点 CSV | 导出包含训练次数、完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率 |
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
+39 -10
View File
@@ -4,12 +4,13 @@
- 后端:`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:5174/h5/user`
- uni-app 用户侧:`https://wygj-mobile-uni.localhost/h5/`
- 后端:`http://127.0.0.1:8080/`
- MySQL:`127.0.0.1:13306`,数据库 `ry-vue`,账号 `root/root`
- Redis:`127.0.0.1:16379`,密码 `ruoyi123`
@@ -17,7 +18,28 @@
- 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/`
- 后端 API:`https://wygj-api.localhost`
启动 uni-app 用户侧:
```bash
portless
```
portless 会按根目录 `portless.json` 执行 `mobile-uni` 的 `npm run dev`。旧 `./scripts/dev.sh` 仍会拉起 `mobile/` 作为演示兜底;新用户侧开发测试优先看 `wygj-mobile-uni.localhost`。
若后端已经由 `./scripts/dev-backend.sh` 启动在 `8080`,注册一次静态别名:
```bash
portless alias wygj-api 8080
```
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`。
本地 `.env.local` 示例:
@@ -28,11 +50,14 @@ 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
@@ -47,8 +72,11 @@ AIHR_AI_SPEECH_ENABLED=true
```bash
./scripts/dev.sh
portless
```
`./scripts/dev.sh` 启动后端、管理端和旧移动端兜底;`portless` 启动当前 `mobile-uni` 用户侧。
首次启动或需要重建本地 `ry-vue` MVP 数据库时执行:
```bash
@@ -84,7 +112,7 @@ AIHR_AI_SPEECH_ENABLED=true
- 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
- 后端 `ruoyi-admin` 已完成 Maven 打包并启动在 `8080`
- 前端依赖已安装,Vite 已启动在 `5173`
- 移动端依赖已安装,Vite 已启动在 `5174`
- 旧移动端兜底依赖已安装,Vite 可启动在 `5174`;当前用户侧由 portless 启动 `mobile-uni`,入口 `https://wygj-mobile-uni.localhost/h5/`
- 移动端手机号登录页已接 `/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` 可审核为 `已通过/已驳回`
@@ -109,10 +137,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 页面验证
@@ -138,8 +166,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(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。点底部“练”进入 `http://127.0.0.1:5174/h5/user/training`,首屏应先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”进入 `http://127.0.0.1:5174/h5/user/practice`,两次“填入建议回复 / 提交本轮”后应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 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。
演示前可先跑最小预检:
+22
View File
@@ -0,0 +1,22 @@
# PC 管理端功能差异 TODO
> 依据:2026-07-09 浏览器实测管理端页面 + `docs/物业AI人力资源系统业务需求文档BRD.md` + `docs/银城员工端APP分阶段实施总纲.md` + `docs/AI陪练二期开发推进计划.md`。
> 口径:先支撑阶段一“陪练试点可用”,阶段二/三需求只登记,不抢跑。
| 优先级 | 差异项 | 当前状态 | 本次实现 | 验收口径 |
|---|---|---|---|---|
| P0 | 组织人员 / 岗位 / 项目权限运营页 | 组织同步 API 存在,侧栏未暴露运营视图 | 新增 `组织与权限` 页面,呈现组织人员快照、岗位映射、项目数据范围和同步预检入口 | PC 可查询人员快照,看到身份三轴、权限映射、同步结果边界 |
| P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 后端已有复盘、评分校准、派发接口;PC 只有散落入口 | 新增 `复盘校准` 页面,集中待复盘列表、详情、复盘建议、学分、下次训练派发 | 主管能从一页完成“看记录 -> 写建议 -> 派训练” |
| P0 | 能力画像 / 等级证据包 / 激励规则 | 后端已有画像和证据包接口;PC 无独立页 | 新增 `成长激励` 页面,展示画像、等级路径、证据包、预警、任务与最小规则表 | HR 能看到初/中/高级证据和学分规则口径 |
| P1 | 学模块课程 / 考试后台 | 文档要求阶段二建设;当前无页面 | 暂不实现,保留 TODO | 阶段二专项设计后再建课程、课件、考试、成绩表 |
| P1 | 大喇叭文件分发 | 总纲阶段二;当前无页面 | 暂不实现,保留 TODO | 半年会后按身份三轴做摘要、已读未读、补推 |
| P1 | 开放问题榜 | 阶段一只埋 `knowledge_gap`;当前无 UI | 暂不实现,保留 TODO | 阶段二消费 gap 池、上榜、最佳答案、沉淀入库 |
| P1 | 品检翻转 | 阶段二;当前无页面 | 暂不实现,保留 TODO | 依赖图片分析模板和业务巡检流程 |
| P2 | 北森 SSO / 考勤入口 | 阶段三;接口未知 | 暂不实现,保留 TODO | 阶段三拿到北森接口文档后做跳转/同步 |
## 本次交付边界
- 不新建数据库表。
- 不改登录、密钥、模型配置、支付、部署脚本。
- 优先复用已有 `/api/aihr/mobile/practice/*` 与 `/api/train/practice/*` 接口。
- 规则配置中心先做运营口径展示;需要真实持久化时再补 `incentive_rule` 等表。
+1
View File
@@ -13,6 +13,7 @@
| [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 |
| [银城员工端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(整合方案的输入源,只作留档) |
@@ -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 |
+231
View File
@@ -0,0 +1,231 @@
import request 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 }>;
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;
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;
limit?: number;
};
export type OrgSnapshotResponse = {
total: number;
active: number;
departed: number;
projects: OrgProjectOption[];
levels: string[];
rows: OrgPersonRow[];
};
export function listPracticeReviews(): Promise<ApiResult<ReviewRecord[]>> {
return request({
url: '/api/aihr/mobile/practice/reviews',
method: 'get'
});
}
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}`,
method: 'get'
});
}
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
});
}
@@ -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,
+358
View File
@@ -0,0 +1,358 @@
<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 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>
</div>
</div>
<div class="org-filters">
<el-input v-model="orgFilters.keyword" clearable placeholder="姓名/岗位/部门/外部ID" @keyup.enter="loadOrgSnapshot" />
<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="loadOrgSnapshot">查询</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>
</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>
<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 metrics = [
{ label: '权威组织源', value: '快照', note: '阶段三前用 aihr_org_snapshot' },
{ label: '数据范围主体', value: '项目', note: '主管只看本项目训练/复盘' },
{ label: '试点岗位', value: '管家(生活顾问)', note: 'position 标签沿用现有值' },
{ label: 'SSO', value: '暂缓', note: '北森对接归阶段三' }
];
const identityAxes = [
{ axis: '横向条线', source: 'position_code', example: '管家/客服、工程、保洁、保安', status: '已定义', type: 'success' },
{ axis: '纵向层级', source: '岗位/组织映射', example: '一线、主管、项目经理、片区经理', status: '最小集', type: 'warning' },
{ axis: '管辖范围', source: '组织树 + 项目', example: '单项目 / 多项目 / 城市 / 集团', status: 'P0', type: 'danger' }
];
const roleMappings = [
{ match: 'position = 生活顾问', role: '学员', scope: '本人训练与今日安排', owner: 'HR' },
{ match: 'position = 项目主管', 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 loadOrgSnapshot = async () => {
orgLoading.value = true;
try {
const result = await getOrgSnapshot({ ...orgFilters, limit: 200 });
orgSnapshot.value = result.data;
} catch (error) {
ElMessage.error('组织人员快照加载失败');
} finally {
orgLoading.value = false;
}
};
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;
}
.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;
}
.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>
+363
View File
@@ -0,0 +1,363 @@
<template>
<div class="growth-page">
<section class="page-head">
<div>
<h1>成长激励</h1>
<p>展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。</p>
</div>
<div class="head-actions">
<el-input v-model="extPartyId" clearable placeholder="员工手机号或 extPartyId" />
<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>
<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 { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { onMounted, ref } from 'vue';
import { listPracticeScenarios } from '@/api/aihr/practice';
import {
createPracticeAssignment,
getCompetencyProfile,
getPromotionEvidence,
listPracticeAlerts,
listPracticeAssignments,
type CompetencyProfile,
type PracticeAlert,
type PracticeAssignment,
type PromotionEvidence
} from '@/api/aihr/mobile-admin';
const extPartyId = 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 rules = [
{ source: '训练完成', points: '+5', guard: '只认完成记录,不按点击或打开计分' },
{ source: '主管复盘采纳', points: '+0~100', guard: '由主管复盘时人工确认' },
{ source: '案例被采纳', points: '+20', guard: '必须审核入库并署名' },
{ source: '课程考试通过', points: '+10', guard: '阶段二课程考试上线后启用' }
];
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 createManualAssignment = async () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
if (!party) {
ElMessage.warning('请先输入员工手机号或 extPartyId');
return;
}
try {
const scenarios = (await listPracticeScenarios()).data || [];
const scenario = scenarios.find((item) => item.enabled) || scenarios[0];
await createPracticeAssignment({
extPartyId: party,
traineeName: party,
scenarioId: scenario?.id || 'fee-parking',
scenarioName: scenario?.name,
source: 'manual',
reason: 'HR在成长激励页派发专项训练'
});
ElMessage.success('专项训练已派发');
await loadAll();
} catch (error) {
ElMessage.error('专项训练派发失败');
}
};
onMounted(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: 260px 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;
}
}
.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,
.metric-grid {
grid-template-columns: 1fr;
}
.page-head {
display: block;
}
.head-actions {
margin-top: 14px;
}
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<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">{{ reviews.length }} 条</el-tag>
</div>
<el-table v-loading="loading" :data="reviews" 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">
<strong>{{ turn.label }}</strong>
<p>{{ turn.text }}</p>
</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-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 { onMounted, reactive, ref } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { getPracticeReview, listPracticeReviews, markPracticeReviewed, 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 reviewForm = reactive({
reviewer: '主管',
advice: '',
incentivePoint: 10,
nextScenarioId: '',
nextReason: ''
});
const loadScenarios = async () => {
try {
const result = await listPracticeScenarios();
scenarios.value = result.data || [];
} catch (error) {
scenarios.value = [];
}
};
const loadReviews = async () => {
loading.value = true;
try {
const result = await listPracticeReviews();
reviews.value = result.data || [];
if (!detail.value && reviews.value.length > 0) {
await selectReview(reviews.value[0]);
}
} catch (error) {
ElMessage.error('待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const selectReview = async (row: ReviewRecord) => {
const result = await getPracticeReview(row.id);
detail.value = result.data;
reviewForm.advice = result.data.reviewAdvice || '';
reviewForm.incentivePoint = result.data.incentivePoint || 10;
reviewForm.nextScenarioId = '';
reviewForm.nextReason = '';
};
const submitReview = async () => {
if (!detail.value) return;
saving.value = true;
try {
await markPracticeReviewed(detail.value.id, {
reviewer: reviewForm.reviewer,
advice: reviewForm.advice,
incentivePoint: reviewForm.incentivePoint,
nextScenarioId: reviewForm.nextScenarioId,
nextReason: reviewForm.nextReason
});
ElMessage.success('已完成复盘处理');
detail.value = null;
await loadReviews();
} catch (error) {
ElMessage.error('复盘提交失败');
} finally {
saving.value = false;
}
};
onMounted(() => {
loadScenarios();
loadReviews();
});
</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;
}
}
.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;
}
}
.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,
.score-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
+7310
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"$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",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"vue": "3.5.30"
},
"devDependencies": {
"@dcloudio/types": "3.4.31",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020120260706001",
"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 };
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "银城员工端",
"appid": "__UNI__AIHR",
"description": "物业AI人力资源系统用户侧",
"versionName": "0.1.0",
"versionCode": "100",
"h5": {
"router": {
"base": "/h5/"
}
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"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/auth/login/index",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/common/building/index",
"style": {
"navigationBarTitleText": "建设中"
}
},
{
"path": "pages/supervisor/index/index",
"style": {
"navigationBarTitleText": "主管"
}
},
{
"path": "pages/candidate/index/index",
"style": {
"navigationBarTitleText": "候选人"
}
}
],
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "银城员工端",
"navigationBarBackgroundColor": "#f8fafc",
"backgroundColor": "#f5f7fb"
},
"tabBar": {
"color": "#64748b",
"selectedColor": "#0f172a",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"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": "我"
}
]
}
}
+159
View File
@@ -0,0 +1,159 @@
<template>
<view class="page login-page">
<view class="login-head">
<text class="page-title">银城员工端</text>
<text class="page-subtitle">使用手机号登录后,回到当前任务。</text>
</view>
<view class="panel login-card">
<text class="panel-title">手机号登录</text>
<input
v-model="phone"
class="input"
type="number"
maxlength="11"
placeholder="请输入手机号"
/>
<view class="inline-actions">
<input
v-model="smsCode"
class="input code-input"
type="number"
maxlength="6"
placeholder="验证码"
/>
<button class="secondary-button code-button" :disabled="sending" @click="sendCode">
{{ sending ? '发送中' : '发验证码' }}
</button>
</view>
<button class="primary-button" :disabled="loggingIn" @click="login">
{{ loggingIn ? '登录中' : '登录' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { 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, validatePhone } from '@/services/auth';
const stored = getAuth();
const phone = ref(stored.phone || uni.getStorageSync(phoneKey) || '');
const smsCode = ref('');
const sending = ref(false);
const loggingIn = ref(false);
const message = ref('');
const tabPages = new Set([
'/pages/user/today/index',
'/pages/user/practice/index',
'/pages/user/sop/index',
'/pages/user/profile/index'
]);
const goAfterLogin = () => {
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
if (tabPages.has(redirect)) {
uni.switchTab({ url: redirect });
return;
}
uni.redirectTo({ url: redirect });
};
const sendCode = async () => {
if (sending.value) return;
const value = phone.value.trim();
if (!validatePhone(value)) {
message.value = '请输入正确手机号';
return;
}
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;
const value = phone.value.trim();
if (!validatePhone(value)) {
message.value = '请输入正确手机号';
return;
}
if (!smsCode.value.trim()) {
message.value = '请输入短信验证码';
return;
}
loggingIn.value = true;
message.value = '';
try {
const result = await apiRequest<LoginResult>({
url: '/auth/mobile/sms-login',
method: 'POST',
data: {
phonenumber: value,
smsCode: smsCode.value.trim(),
tenantId: '000000'
},
auth: false
});
if (!result.access_token || !result.client_id) {
throw new Error('登录响应缺少认证信息');
}
saveAuth(result, value);
goAfterLogin();
} catch (error) {
message.value = error instanceof Error ? error.message : '登录失败';
} finally {
loggingIn.value = false;
}
};
onShow(() => {
if (isLoggedIn()) {
goAfterLogin();
}
});
</script>
<style scoped>
.login-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
}
.login-head {
margin-bottom: 10px;
}
.login-card {
display: flex;
flex-direction: column;
gap: 14px;
}
.code-input {
flex: 1;
}
.code-button {
width: 112px;
flex: none;
font-size: 14px;
}
</style>
@@ -0,0 +1,746 @@
<template>
<view class="page candidate-page">
<view class="page-head">
<view>
<text class="page-title">候选人</text>
<text class="page-subtitle">{{ candidateSubtitle }}</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">刷新</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>
<view v-if="candidatePrimary" class="panel task-panel">
<text class="task-badge">{{ candidatePrimary.badge }}</text>
<text class="task-title">{{ candidatePrimary.title }}</text>
<text class="task-desc">{{ candidatePrimary.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${candidatePrimary.progress}%` }" />
</view>
<text class="muted">{{ candidatePrimary.progressText }}</text>
<button class="primary-button interview-start" :disabled="primaryBusy" @click="runPrimaryAction">
{{ interviewStatus === 'active' && !needsMaterialUpload ? '重新生成题目' : primaryBusy ? '处理中' : candidatePrimary.cta }}
</button>
</view>
<view class="panel progress-panel">
<text class="panel-title">进度通知</text>
<text class="progress-notice">{{ progressNotice }}</text>
</view>
<view v-if="interviewQuestions.length" class="panel">
<view class="section-head">
<text class="panel-title">面试问答</text>
</view>
<view class="question-list">
<view v-for="item in interviewQuestions" :key="item.questionId" class="question-card">
<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>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
提交评分
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="section-head">
<text class="panel-title">面试结果</text>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
</view>
<text class="result-title">{{ interviewResult.result }}</text>
<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>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
</view>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<text class="muted">{{ interviewRecords.length }} 条</text>
</view>
<view v-if="interviewRecords.length" class="interview-list">
<view v-for="item in interviewRecords" :key="`${item.time}-${item.candidate}-${item.score}`" class="interview-row">
<view class="interview-copy">
<text class="interview-title">{{ item.candidate }} · {{ item.position }}</text>
<text class="interview-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="interview-score">
<text>{{ item.score }}</text>
<text>{{ item.result }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无面试记录</view>
</view>
<view class="panel">
<view class="section-head">
<text class="panel-title">岗前预习</text>
<button class="ghost-button study-button" :disabled="studyLoading" @click="loadStudy">
{{ loggedIn ? studyLoading ? '检索中' : '生成预习' : '登录查看' }}
</button>
</view>
<text v-if="studyAnswer" class="study-answer">{{ studyAnswer }}</text>
<view v-if="studyRefs.length" class="study-refs">
<text v-for="item in studyRefs" :key="item" class="study-ref">{{ item }}</text>
</view>
<view v-if="!studyAnswer" class="empty">{{ loggedIn ? '点击生成预习,从正式知识库生成要点' : '登录后从正式知识库生成预习要点' }}</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">补充资料</text>
<button class="primary-button upload-button" :disabled="uploading" @click="uploadMaterial">
{{ uploading ? '上传中' : '上传' }}
</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-copy">
<text class="material-title">{{ item.fileName }}</text>
<text class="material-desc">OSS {{ item.ossId }} · {{ item.materialType }} · {{ formatSize(item.fileSize) }}</text>
</view>
<view class="status-pill" :class="statusTone(item.status)">
{{ item.status }}
</view>
</view>
</view>
<view v-else class="empty">暂无已提交资料</view>
</view>
<view class="panel">
<text class="panel-title">流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.title" class="step-row">
<text class="step-dot">{{ step.index }}</text>
<view>
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewFinishResponse, InterviewQuestion, InterviewRecord, PrimaryTask, RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import {
chooseCandidateFile,
finishCandidateInterview,
getCandidateInterviewRecords,
getCandidateName,
getCandidateMaterials,
saveInterviewAnswer,
startCandidateInterview,
uploadCandidateMaterial
} from '@/services/candidate';
const home = ref<RoleHome | null>(null);
const materials = ref<CandidateMaterial[]>([]);
const interviewRecords = 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 loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const studyLoading = ref(false);
const studyAnswer = ref('');
const studyRefs = ref<string[]>([]);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const latestMaterialStatus = computed(() => materials.value[0]?.status || '');
const candidateSubtitle = computed(() => loggedIn.value ? `尾号${getCandidateName().slice(-4)},面试、补资料、等通知` : '面试、补资料、等通知');
const hasInterview = computed(() => interviewRecords.value.length > 0 || interviewStatus.value === 'finished');
const needsMaterialUpload = computed(() =>
loggedIn.value && hasInterview.value && (!materials.value.length || latestMaterialStatus.value === '已驳回')
);
const primaryBusy = computed(() => loading.value || interviewBusy.value || (needsMaterialUpload.value && uploading.value));
const defaultCandidateTask: PrimaryTask = {
badge: '待面试',
title: '结构化面试',
desc: '按岗位题目完成回答,提交后生成面试记录。',
progress: 0,
progressText: '0/2',
cta: '开始面试'
};
const candidatePrimary = computed<PrimaryTask | null>(() => {
if (!loggedIn.value) return null;
const base = home.value?.primary || defaultCandidateTask;
const materialStatus = latestMaterialStatus.value;
if (!hasInterview.value) return { ...base, badge: '待面试', progress: 0, progressText: '0/2', cta: '开始面试' };
if (materialStatus === '已通过') return { ...base, badge: '资料已通过', title: '等待后续通知', desc: '面试和补充资料已完成,留意 HR 通知。', progress: 100, progressText: '2/2', cta: '刷新进度' };
if (materialStatus === '已驳回') return { ...base, badge: '资料被驳回', title: '重新提交资料', desc: '请按驳回结果补充或替换资料。', progress: 50, progressText: '1/2', cta: '重新上传' };
if (materials.value.length) return { ...base, badge: '审核中', title: '资料审核中', desc: '资料已提交,等待 HR 审核。', progress: 75, progressText: '1/2', cta: '刷新进度' };
return { ...base, badge: '待补资料', title: '上传补充资料', desc: '面试已完成,请补充简历、证书或图片资料。', progress: 50, progressText: '1/2', cta: '上传资料' };
});
const progressNotice = computed(() => {
if (!loggedIn.value) return '登录后查看面试和资料进度';
if (!hasInterview.value) return '待完成结构化面试';
if (!materials.value.length) return '面试已完成,请上传补充资料';
if (latestMaterialStatus.value === '已通过') return '资料已通过,等待后续通知';
if (latestMaterialStatus.value === '已驳回') return '资料被驳回,请重新上传';
return '资料已提交,等待 HR 审核';
});
const steps = [
{ index: 1, title: '完成面试', desc: '按岗位题目回答,生成面试记录' },
{ index: 2, title: '补充资料', desc: '上传简历、证书或图片资料' },
{ index: 3, title: '等待审核', desc: 'HR 审核后同步显示状态' }
];
const clearCandidatePrivateData = () => {
materials.value = [];
interviewRecords.value = [];
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
studyAnswer.value = '';
studyRefs.value = [];
};
const clearInterviewDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
clearCandidatePrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadPage = async () => {
refreshAuthState();
const wasLoggedIn = loggedIn.value;
loading.value = true;
message.value = '';
try {
home.value = await getMobileHome('candidate');
} catch (error) {
home.value = null;
}
try {
if (!loggedIn.value) {
clearCandidatePrivateData();
return;
}
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
interviewRecords.value = nextRecords;
} catch (error) {
handleRequestError(error, '候选人数据加载失败', wasLoggedIn);
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id} / OSS ${saved.ossId}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败', true);
} finally {
uploading.value = false;
}
};
const loadStudy = async () => {
if (studyLoading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
studyLoading.value = true;
message.value = '';
try {
const result = await searchKnowledge('生活顾问岗前预习 投诉处理 报事报修 客户沟通');
studyAnswer.value = result.answer;
studyRefs.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 3);
} catch (error) {
handleRequestError(error, '岗前预习生成失败', true);
} finally {
studyLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
clearInterviewDraft();
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, '面试启动失败', true);
}
};
const runPrimaryAction = () => {
if (needsMaterialUpload.value) {
void uploadMaterial();
return;
}
if (hasInterview.value && materials.value.length) {
void loadPage();
return;
}
void startInterview();
};
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).filter(([, value]) => value).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
interviewRecords.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败', true)) {
interviewStatus.value = 'active';
}
}
};
const formatSize = (size: number) => {
if (!size) return '0KB';
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
const statusTone = (status: string) => {
if (status === '已通过') return 'passed';
if (status === '已驳回') return 'rejected';
return 'pending';
};
const goLogin = () => {
rememberLoginRedirect('/pages/candidate/index/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onShow(loadPage);
</script>
<style scoped>
.candidate-page {
min-height: 100vh;
background: #f4f7fb;
}
.page-head,
.section-head,
.login-panel,
.interview-row,
.material-row,
.step-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button,
.login-button,
.upload-button,
.study-button {
min-width: 84px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.task-panel {
background: #fff7ed;
}
.task-badge {
display: inline-flex;
padding: 5px 9px;
border-radius: 999px;
background: #fed7aa;
color: #9a3412;
font-size: 12px;
font-weight: 800;
}
.task-title {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
}
.task-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.progress-bar {
height: 8px;
margin: 16px 0 10px;
overflow: hidden;
border-radius: 999px;
background: #ffedd5;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #ea580c;
}
.progress-panel {
background: #eff6ff;
}
.progress-notice {
display: block;
margin-top: 10px;
color: #1e3a8a;
font-size: 15px;
font-weight: 800;
line-height: 1.45;
}
.interview-start,
.finish-button {
margin-top: 16px;
}
.question-list,
.dimension-list {
margin-top: 14px;
}
.question-card {
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #f8fafc;
}
.question-meta {
color: #ea580c;
font-size: 12px;
font-weight: 800;
}
.question-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 16px;
font-weight: 900;
line-height: 1.45;
}
.question-point {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.answer-textarea {
margin-top: 12px;
}
.result-score {
padding: 6px 10px;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 14px;
font-weight: 900;
}
.result-title {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.dimension-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.dimension-title {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.dimension-note {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.dimension-value {
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.interview-list {
margin-top: 14px;
}
.interview-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.interview-copy {
flex: 1;
min-width: 0;
}
.interview-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.interview-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.interview-score {
display: flex;
min-width: 64px;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.interview-score text:first-child {
color: #ea580c;
font-size: 22px;
font-weight: 900;
}
.study-answer {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 14px;
line-height: 1.6;
}
.study-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.study-ref {
padding: 5px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.material-list,
.step-list {
margin-top: 14px;
}
.material-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.material-copy {
flex: 1;
min-width: 0;
}
.material-title,
.step-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.step-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 6px 9px;
border-radius: 999px;
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;
}
.step-row {
justify-content: flex-start;
margin-top: 12px;
align-items: flex-start;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e0f2fe;
color: #0369a1;
font-size: 13px;
font-weight: 900;
}
</style>
@@ -0,0 +1,43 @@
<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';
const title = ref('建设中');
onLoad((query) => {
title.value = query?.title ? `${decodeURIComponent(String(query.title))} · 建设中` : '建设中';
});
const goBack = () => {
uni.navigateBack({ delta: 1 });
};
</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>
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
<template>
<view class="page cases-page">
<text class="page-title">案例素材</text>
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
<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">
<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';
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`;
};
onShow(() => {
refreshAuthState();
if (loggedIn.value) {
void loadRecords();
} else {
clearCaseData();
}
});
</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-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;
justify-content: space-between;
gap: 12px;
}
.record-status {
flex: 0 0 auto;
color: #16a34a;
font-size: 13px;
font-weight: 900;
}
</style>
@@ -0,0 +1,832 @@
<template>
<view class="page practice-page">
<view class="practice-head">
<text class="page-title">场景练</text>
<text class="page-subtitle">按当前场景完成三角色对练,结束后自动评分并写入训练记录。</text>
</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-if="loggedIn" class="panel assignment-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row">
<view class="assignment-copy">
<text class="assignment-badge">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
maxlength="220"
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
</view>
<view v-if="loggedIn" class="panel scenario-card">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">业主投诉服务或设施问题时,练习承接情绪、说明流程、给出反馈节点。</text>
<view v-if="prepCard" class="prep-card">
<view class="prep-head">
<text>训练前预习卡</text>
</view>
<view class="prep-block">
<text class="prep-label">要点</text>
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">红线</text>
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
</view>
<view class="prep-block">
<text class="prep-label">话术</text>
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
</view>
</view>
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
<button
v-if="status === 'idle' || status === 'finished'"
class="primary-button"
:disabled="busy"
@click="start()"
>
{{ status === 'finished' ? '再练一次' : '开始练习' }}
</button>
</view>
<view v-if="turns.length" class="panel dialogue-card">
<view class="dialogue-head">
<text class="panel-title">对话</text>
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
</view>
<view v-if="hasCoachState" class="coach-state">
<view class="state-row">
<text>情绪 {{ emotionScore }}</text>
<view class="state-bar">
<view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" />
</view>
</view>
<view class="state-row">
<text>信任 {{ trustScore }}</text>
<view class="state-bar">
<view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" />
</view>
</view>
<text v-if="redFlagActive" class="red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
</view>
<view class="turn-list">
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
<text class="turn-text">{{ turn.text }}</text>
</view>
</view>
<view v-if="canAdjustCoach" class="style-actions">
<button
v-for="item in styleOptions"
:key="item.value"
class="chip style-chip"
:disabled="busy || Boolean(adjustingStyle)"
@click="adjustCoachHint(item.value)"
>
{{ adjustingStyle === item.value ? '调整中' : item.label }}
</button>
</view>
</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card">
<text class="panel-title">你的回应</text>
<textarea
v-model="draft"
class="textarea"
maxlength="500"
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
/>
<view class="reply-actions">
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
{{ helpLoading ? '求助中' : '求助' }}
</button>
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
<button class="primary-button" :disabled="busy" @click="submit">
{{ status === 'submitting' ? '提交中' : '提交回应' }}
</button>
</view>
</view>
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
<text class="panel-title">SOP 提示</text>
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-refs">
<text v-for="item in helpRefs" :key="item" class="help-ref">{{ item }}</text>
</view>
</view>
<view v-if="result" class="panel result-card">
<text class="result-label">本次评分</text>
<text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text>
<view v-if="result.mentorRewrite" class="mentor-rewrite">
<text>老师傅建议</text>
<text>{{ result.mentorRewrite }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTurn
} from '@/types/api';
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
const draft = ref('');
const message = ref('');
const turns = ref<PracticeTurn[]>([]);
const result = ref<PracticeFinishResponse | null>(null);
const selectedScenarioId = ref(defaultScenarioId);
const prepCard = ref<PracticePrepCard | null>(null);
const prepLoading = ref(false);
const activeScenarioName = ref(defaultScenarioName);
const helpLoading = ref(false);
const helpAnswer = ref('');
const helpRefs = ref<string[]>([]);
const lastTraineeText = ref('');
const lastCoachRoundIndex = ref<number | null>(null);
const adjustingStyle = ref('');
const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
const assignments = ref<PracticeAssignment[]>([]);
const drillAnswers = ref<Record<number, string>>({});
const assignmentLoading = ref(false);
const answeringId = ref<number | null>(null);
const activeAssignmentId = ref<number | undefined>();
const loggedIn = ref(false);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
const styleOptions = [
{ value: 'serious', label: '再严肃' },
{ value: 'gentle', label: '再温柔' },
{ value: 'detailed', label: '更细致' },
{ value: 'professional', label: '更专业' }
];
const roleLabel = (role: PracticeRole) => {
if (role === 'customer') return '业主';
if (role === 'coach') return '老师傅';
return '我';
};
const assignmentDesc = (item: PracticeAssignment) => {
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
return item.feedback || item.reason;
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/practice/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearPracticeData = () => {
assignments.value = [];
drillAnswers.value = {};
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
result.value = null;
sessionId.value = '';
roundIndex.value = 0;
draft.value = '';
status.value = 'idle';
selectedScenarioId.value = defaultScenarioId;
activeScenarioName.value = defaultScenarioName;
helpLoading.value = false;
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
answeringId.value = null;
activeAssignmentId.value = undefined;
};
const requireLogin = () => {
refreshAuthState();
if (loggedIn.value) return true;
message.value = '请先登录后再开始训练';
goLogin();
return false;
};
const appendTurn = (role: PracticeRole, text?: string) => {
if (!text) return;
turns.value.push({ role, text });
};
const handleError = (error: unknown, fallback: string) => {
const text = error instanceof Error ? error.message : fallback;
refreshAuthState();
if (!loggedIn.value) {
clearPracticeData();
message.value = '登录已过期,请重新登录';
goLogin();
return;
}
message.value = text;
if (text.includes('认证') || text.includes('登录') || text.includes('401')) {
goLogin();
}
};
const loadAssignments = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPracticeData();
return;
}
assignmentLoading.value = true;
try {
assignments.value = await getPracticeAssignments(true);
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
} catch (error) {
handleError(error, '今日任务加载失败');
} finally {
assignmentLoading.value = false;
}
};
const submitDaily = async (item: PracticeAssignment) => {
if (answeringId.value) return;
const answer = (drillAnswers.value[item.id] || '').trim();
if (!answer) {
message.value = '请先填写处理话术';
return;
}
answeringId.value = item.id;
message.value = '';
try {
const result = await answerDailyDrill(item.id, answer);
assignments.value = assignments.value.map((row) =>
row.id === item.id
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
: row
);
message.value = `每日三题得分 ${result.score}`;
} catch (error) {
handleError(error, '每日三题提交失败');
} finally {
answeringId.value = null;
}
};
const startAssignment = (item: PracticeAssignment) => {
if (!canStartPractice.value) return;
selectedScenarioId.value = item.scenarioId || defaultScenarioId;
activeScenarioName.value = item.scenarioName || defaultScenarioName;
void loadPrepCard(selectedScenarioId.value);
void start(item.scenarioId || defaultScenarioId, item.id);
};
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
refreshAuthState();
if (!loggedIn.value) {
prepCard.value = null;
return;
}
prepLoading.value = true;
try {
prepCard.value = await getPracticePrepCard(scenarioId);
} catch (error) {
prepCard.value = null;
handleError(error, '预习卡加载失败');
} finally {
prepLoading.value = false;
}
};
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
if (!canStartPractice.value) return;
if (!requireLogin()) return;
selectedScenarioId.value = scenarioId;
status.value = 'starting';
message.value = '';
result.value = null;
turns.value = [];
draft.value = '';
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
roundIndex.value = 0;
activeAssignmentId.value = assignmentId;
try {
const data = await startPractice(scenarioId, assignmentId);
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText);
status.value = 'active';
} catch (error) {
status.value = 'idle';
activeAssignmentId.value = undefined;
handleError(error, '训练启动失败');
}
};
const loadHelp = async () => {
if (helpLoading.value || busy.value) return;
if (!requireLogin()) return;
helpLoading.value = true;
message.value = '';
try {
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
if (!data.answer) {
throw new Error('未检索到可用 SOP 提示');
}
helpAnswer.value = data.answer;
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
} catch (error) {
handleError(error, '求助失败');
} finally {
helpLoading.value = false;
}
};
const replaceLastCoachHint = (text?: string) => {
if (!text) return;
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
if (turns.value[index].role === 'coach') {
turns.value[index] = { role: 'coach', text };
return;
}
}
appendTurn('coach', text);
};
const discardLastCoachAttempt = () => {
const next = [...turns.value];
if (next[next.length - 1]?.role === 'coach') next.pop();
if (next[next.length - 1]?.role === 'trainee') next.pop();
turns.value = next;
};
const adjustCoachHint = async (style: string) => {
if (busy.value || adjustingStyle.value) return;
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
adjustingStyle.value = style;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: lastCoachRoundIndex.value,
traineeText: lastTraineeText.value,
regenerate: true,
style
});
replaceLastCoachHint(data.coachHint);
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
if (data.message) message.value = data.message;
} catch (error) {
handleError(error, '建议调整失败');
} finally {
adjustingStyle.value = '';
}
};
const submit = async () => {
if (busy.value) return;
const text = draft.value.trim();
if (!text) {
message.value = '请先输入回应';
return;
}
status.value = 'submitting';
message.value = '';
draft.value = '';
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
if (retryingRedFlag) discardLastCoachAttempt();
appendTurn('trainee', text);
try {
const submittedRound = roundIndex.value;
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: submittedRound,
traineeText: text
});
lastTraineeText.value = text;
lastCoachRoundIndex.value = submittedRound;
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
if (data.redFlag) {
message.value = '命中红线,请按老师傅建议重说本轮';
status.value = 'active';
return;
}
if (data.finished) {
await finish(true);
return;
}
appendTurn('customer', data.customerText);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {
status.value = 'active';
handleError(error, '提交失败');
}
};
const finish = async (force = false) => {
if (!force && busy.value) return;
if (!sessionId.value) return;
if (!canFinishPractice.value) {
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
return;
}
status.value = 'submitting';
message.value = '';
try {
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新';
activeAssignmentId.value = undefined;
await loadAssignments();
} catch (error) {
status.value = 'active';
handleError(error, '评分失败');
}
};
onShow(() => {
refreshAuthState();
void loadAssignments();
void loadPrepCard();
});
</script>
<style scoped>
.practice-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.practice-head {
padding-top: 8px;
}
.scenario-card,
.reply-card,
.help-card,
.result-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.assignment-head,
.daily-answer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.refresh-button,
.small-button {
min-width: 82px;
}
.assignment-list {
margin-top: 12px;
}
.assignment-row {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.assignment-badge {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.assignment-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 15px;
font-weight: 900;
line-height: 1.45;
}
.assignment-desc,
.assignment-reference {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.daily-answer {
margin-top: 10px;
align-items: flex-end;
}
.daily-textarea {
min-height: 82px;
}
.scenario-badge {
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
}
.scenario-title {
color: #0f172a;
font-size: 24px;
font-weight: 900;
}
.scenario-desc {
color: #64748b;
font-size: 15px;
line-height: 1.55;
}
.prep-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.prep-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.prep-head text:first-child,
.prep-label {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.prep-head text:last-child {
color: #94a3b8;
font-size: 12px;
}
.prep-block {
display: flex;
flex-direction: column;
gap: 5px;
}
.prep-line {
color: #475569;
font-size: 13px;
line-height: 1.45;
}
.dialogue-head,
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.round-label {
color: #64748b;
font-size: 13px;
}
.coach-state {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 8px;
}
.state-row {
display: grid;
align-items: center;
grid-template-columns: 72px 1fr;
gap: 10px;
color: #475569;
font-size: 13px;
font-weight: 800;
}
.state-bar {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.state-fill {
height: 100%;
border-radius: 999px;
}
.emotion-fill {
background: #f97316;
}
.trust-fill {
background: #16a34a;
}
.red-flag {
padding: 9px 10px;
border-radius: 12px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
}
.turn-list {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 10px;
}
.turn-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
}
.turn-row.trainee {
background: #eff6ff;
}
.turn-row.coach {
background: #fff7ed;
}
.turn-role {
color: #334155;
font-size: 13px;
font-weight: 900;
}
.turn-text {
color: #172033;
font-size: 15px;
line-height: 1.55;
}
.style-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.style-chip {
width: auto;
}
.reply-actions button {
flex: 1;
}
.help-answer {
color: #334155;
font-size: 15px;
line-height: 1.6;
}
.help-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.help-ref {
padding: 5px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.result-label {
color: #64748b;
font-size: 14px;
font-weight: 800;
}
.result-score {
color: #1d4ed8;
font-size: 44px;
font-weight: 900;
line-height: 1;
}
.result-comment,
.mentor-rewrite text:last-child {
color: #334155;
font-size: 15px;
line-height: 1.55;
}
.mentor-rewrite {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.mentor-rewrite text:first-child {
color: #0f172a;
font-weight: 900;
}
</style>
+445
View File
@@ -0,0 +1,445 @@
<template>
<view class="page profile-page">
<text class="page-title">我</text>
<text class="page-subtitle">{{ phone || '未登录' }}</text>
<view v-if="message" class="status-message">{{ message }}</view>
<view class="panel account-card">
<view>
<text class="panel-title">账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
</view>
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
<button v-else class="secondary-button account-action" @click="logout">退出</button>
</view>
<view v-if="profile" class="panel">
<text class="panel-title">成长摘要</text>
<view class="score-row">
<view class="score-main">
<text class="score-value">{{ profile.score }}</text>
<text class="score-label">综合能力</text>
</view>
<view class="metric-grid">
<view class="metric">
<text>{{ profile.completed }}</text>
<text>已训练</text>
</view>
<view class="metric">
<text>{{ profile.pendingReview }}</text>
<text>待复盘</text>
</view>
<view class="metric">
<text>{{ profile.trainingMinutes || 0 }}</text>
<text>分钟</text>
</view>
<view class="metric">
<text>{{ profile.aiLevel || '初级' }}</text>
<text>AI等级</text>
</view>
</view>
</view>
<view v-if="profile.dimensions?.length" class="dimension-list">
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
<view>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-score">{{ item.value }}</text>
</view>
</view>
<view v-if="profile.growthPath?.length" class="growth-section">
<text class="section-label">成长路径</text>
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
<view class="growth-copy">
<text class="growth-title">{{ stage.level }} · {{ stage.status }}</text>
<text class="growth-desc">{{ stage.evidence }}</text>
</view>
<view class="growth-progress">
<view class="growth-bar">
<view class="growth-fill" :style="{ width: `${stage.progress}%` }" />
</view>
<text>{{ stage.progress }}%</text>
</view>
</view>
</view>
</view>
<view v-if="evidence" class="panel evidence-panel">
<text class="panel-title">成长证据包</text>
<view class="evidence-grid">
<view class="evidence-metric">
<text>{{ evidence.currentLevel }}</text>
<text>当前等级</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.assessmentScore }}</text>
<text>测评分</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.trainingMinutes }}</text>
<text>训练分钟</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.contribution }}</text>
<text>贡献度</text>
</view>
</view>
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="history-head">
<text class="panel-title">最近练过</text>
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
</view>
<view v-if="history.length" class="history-list">
<view v-for="item in history" :key="item.id || item.sessionId || item.time" class="history-row">
<view>
<text class="history-title">{{ item.scene }}</text>
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="history-score">
<text>{{ item.score }}</text>
<text>{{ item.status }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无训练记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
const loggedIn = ref(false);
const phone = ref('');
const loading = ref(false);
const message = ref('');
const profile = ref<CompetencyProfile | null>(null);
const history = ref<PracticeRecord[]>([]);
const evidence = ref<PromotionEvidence | null>(null);
const refreshAuth = () => {
const auth = getAuth();
loggedIn.value = isLoggedIn();
phone.value = auth.phone;
};
const clearProfileData = () => {
profile.value = null;
history.value = [];
evidence.value = null;
};
const loadData = async () => {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
return;
}
loading.value = true;
message.value = '';
try {
const [nextHistory, nextProfile, nextEvidence] = await Promise.all([
getPracticeHistory(),
getCompetencyProfile(),
getPromotionEvidence()
]);
history.value = nextHistory || [];
profile.value = nextProfile || null;
evidence.value = nextEvidence || null;
} catch (error) {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : '我的数据加载失败';
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/profile/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const logout = () => {
clearAuth();
clearProfileData();
refreshAuth();
uni.showToast({ title: '已退出', icon: 'none' });
};
onShow(loadData);
</script>
<style scoped>
.profile-page {
padding-bottom: 92px;
background: #f4f7fb;
}
.account-card,
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-phone {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
}
.account-action {
min-width: 84px;
}
.score-row {
display: grid;
margin-top: 16px;
grid-template-columns: 104px 1fr;
gap: 14px;
}
.score-main {
display: flex;
min-height: 124px;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #eff6ff;
}
.score-value {
color: #1d4ed8;
font-size: 42px;
font-weight: 900;
line-height: 1;
}
.score-label {
margin-top: 8px;
color: #475569;
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border-radius: 14px;
background: #f8fafc;
}
.metric text:first-child {
color: #0f172a;
font-size: 18px;
font-weight: 900;
}
.metric text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.evidence-panel {
background: #fff7ed;
}
.evidence-grid {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.evidence-metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border-radius: 14px;
background: #ffffff;
}
.evidence-metric text:first-child {
color: #9a3412;
font-size: 18px;
font-weight: 900;
}
.evidence-metric text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.evidence-list {
margin-top: 12px;
}
.evidence-item {
display: block;
padding: 9px 0;
border-top: 1px solid #fed7aa;
color: #7c2d12;
font-size: 13px;
line-height: 1.45;
}
.dimension-list,
.history-list {
margin-top: 14px;
}
.growth-section {
margin-top: 18px;
}
.section-label {
display: block;
color: #334155;
font-size: 14px;
font-weight: 900;
}
.growth-row {
display: grid;
grid-template-columns: 1fr 96px;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e2e8f0;
}
.growth-row:first-of-type {
margin-top: 10px;
}
.growth-title,
.growth-desc {
display: block;
}
.growth-title {
color: #0f172a;
font-size: 14px;
font-weight: 900;
}
.growth-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.growth-progress {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 6px;
color: #1d4ed8;
font-size: 12px;
font-weight: 900;
}
.growth-bar {
width: 88px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #dbeafe;
}
.growth-fill {
height: 100%;
border-radius: inherit;
background: #2563eb;
}
.dimension-row,
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e2e8f0;
}
.dimension-title,
.history-title {
display: block;
color: #0f172a;
font-weight: 900;
}
.dimension-note,
.history-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.dimension-score {
color: #1d4ed8;
font-size: 20px;
font-weight: 900;
}
.refresh-button {
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.history-score {
display: flex;
min-width: 52px;
flex-direction: column;
align-items: flex-end;
}
.history-score text:first-child {
color: #1d4ed8;
font-size: 20px;
font-weight: 900;
}
.history-score text:last-child {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<template>
<view class="page sop-page">
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案来自知识库检索,引用在后面。</text>
<view class="panel query-panel">
<textarea
v-model="query"
class="textarea"
maxlength="200"
placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?"
/>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
{{ item }}
</button>
</view>
<button class="primary-button" :disabled="loading" @click="search">
{{ loading ? '检索中' : '查询' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="result" class="panel answer-panel">
<text class="panel-title">直给答案</text>
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
<view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
{{ index + 1 }}. {{ point }}
</text>
</view>
<view v-if="result.cautions?.length" class="cautions">
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
</view>
<view class="feedback-actions">
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('up')"
>
有用
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')"
>
不准
</button>
</view>
</view>
<view v-if="result?.snippets?.length" class="panel">
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
</view>
</view>
<view v-if="result" class="panel summary-panel">
<text class="panel-title">总结卡</text>
<text class="muted">需要时再生成,避免每次查询都调用模型。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
{{ summaryLoading ? '生成中' : '生成总结卡' }}
</button>
</view>
<view v-if="summaryCard" class="panel">
<text class="panel-title">{{ summaryCard.title }}</text>
<view class="summary-block">
<text class="summary-heading">步骤</text>
<text v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`" class="summary-line">
{{ index + 1 }}. {{ step.label }}: {{ step.desc }}
</text>
</view>
<view v-if="summaryCard.scripts.length" class="summary-block">
<text class="summary-heading">可说话术</text>
<text v-for="item in summaryCard.scripts" :key="item" class="summary-line">{{ item }}</text>
</view>
<view v-if="summaryCard.objections.length" class="summary-block">
<text class="summary-heading">异议回应</text>
<text v-for="item in summaryCard.objections" :key="item.concern" class="summary-line">
{{ item.concern }}: {{ item.response }}
</text>
</view>
<view v-if="summaryCard.reminders.length" class="summary-block">
<text class="summary-heading">提醒</text>
<text v-for="item in summaryCard.reminders" :key="item" class="summary-line">{{ item }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
SummaryCardResponse
} from '@/types/api';
import {
generateSummaryCard,
searchKnowledge,
submitAnswerFeedback
} from '@/services/knowledge';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
const quickQuestions = ['业主投诉噪音怎么安抚', '停车费催缴怎么说', '生活顾问的一天做什么'];
const query = ref('');
const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
const loading = ref(false);
const summaryLoading = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
resetResultState();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const search = async () => {
if (loading.value) return;
const value = query.value.trim();
if (!value) {
message.value = '请先输入问题';
return;
}
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, value);
goLogin();
return;
}
loading.value = true;
message.value = '';
resetResultState();
try {
result.value = await searchKnowledge(value);
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
} catch (error) {
handleRequestError(error, '检索失败', true);
} finally {
loading.value = false;
}
};
const useQuickQuestion = (value: string) => {
query.value = value;
void search();
};
const snippetExcerpt = (text = '') => {
const value = text.trim();
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
};
const feedback = async (verdict: AnswerFeedbackVerdict) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from(
new Set(
(result.value.snippets || [])
.map((item) => item.fragmentId)
.filter((id): id is number => typeof id === 'number' && id > 0)
)
);
if (!fragmentIds.length) {
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
return;
}
feedbackSubmitting.value = true;
try {
await submitAnswerFeedback({
queryText: result.value.queryText || query.value,
fragmentIds,
verdict
});
feedbackVerdict.value = verdict;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) {
if (!handleRequestError(error, '反馈失败', true)) {
uni.showToast({
title: message.value,
icon: 'none'
});
}
} finally {
feedbackSubmitting.value = false;
}
};
const buildSummaryCard = async () => {
if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim();
if (!value) return;
summaryLoading.value = true;
message.value = '';
summaryCard.value = null;
try {
summaryCard.value = await generateSummaryCard(value);
} catch (error) {
handleRequestError(error, '总结卡生成失败', true);
} finally {
summaryLoading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
refreshAuthState();
if (!loggedIn.value) {
resetResultState();
}
const pending = uni.getStorageSync(pendingSopQueryKey);
if (!pending) {
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
void search();
}
return;
}
query.value = String(pending);
if (loggedIn.value) {
uni.removeStorageSync(pendingSopQueryKey);
void search();
} else {
message.value = '登录后继续查询该问题';
}
});
</script>
<style scoped>
.sop-page {
padding-bottom: 48px;
}
.query-panel,
.answer-panel,
.summary-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
}
.login-button {
min-width: 84px;
}
.answer-text {
color: #0f172a;
font-size: 18px;
font-weight: 700;
line-height: 1.65;
}
.keypoints {
display: flex;
flex-direction: column;
gap: 8px;
}
.keypoint {
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
font-size: 15px;
line-height: 1.5;
}
.cautions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
font-size: 13px;
line-height: 1.45;
}
.feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-actions .active {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
}
.snippet {
display: flex;
margin-top: 12px;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.snippet-title {
color: #0f172a;
font-weight: 800;
}
.snippet-content {
color: #475569;
font-size: 14px;
line-height: 1.55;
}
.snippet-id {
color: #94a3b8;
font-size: 12px;
}
.summary-block {
display: flex;
margin-top: 14px;
flex-direction: column;
gap: 8px;
}
.summary-heading {
color: #1d4ed8;
font-weight: 800;
}
.summary-line {
color: #334155;
font-size: 15px;
line-height: 1.55;
}
</style>
+614
View File
@@ -0,0 +1,614 @@
<template>
<view class="page today-page">
<view v-if="message" class="status-message">{{ message }}</view>
<view class="mentor-hero">
<view class="hero-copy">
<text class="hero-role">生活顾问</text>
<text class="hero-title">客服管家</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">
<button class="voice-primary" @click="goSop">
<text class="voice-mark">问</text>
<text>问老师傅</text>
</button>
<button class="voice-secondary" @click="goSop">
<text class="keyboard-mark">键</text>
<text>打字问</text>
</button>
</view>
<view class="mentor-chips">
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" @click="ask(item)">
{{ item }}
</button>
</view>
</view>
<view class="section-card login-section" v-if="!loggedIn">
<view>
<text class="login-title">登录后查看今日安排</text>
<text class="login-desc">训练任务、复习提醒和成长数据会按你的手机号展示。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view class="section-card" v-else-if="home?.primary">
<view class="section-head">
<view class="section-title">
<text class="section-icon">日</text>
<text>今日安排</text>
</view>
<button class="section-link" @click="openTool('训练')">查看全部</button>
</view>
<view class="schedule-list">
<view class="schedule-row">
<text class="step-dot blue">1</text>
<view class="schedule-copy">
<text class="schedule-title">每日小训:{{ home.primary.title.replace('三角色对练', '') }}</text>
<text class="schedule-desc">{{ home.primary.desc }}</text>
</view>
<button class="schedule-action blue-action" @click="openTool(home.primary.cta)">开始</button>
</view>
<view class="schedule-row" v-if="home.review">
<text class="step-dot orange">2</text>
<view class="schedule-copy">
<text class="schedule-title">今日复习:{{ home.review.title }}</text>
<text class="schedule-desc">{{ home.review.desc }}</text>
</view>
<button class="schedule-action orange-action" @click="ask(home.review.title)">去复习</button>
</view>
</view>
</view>
<view v-if="loggedIn" class="section-card">
<view class="section-head">
<view class="section-title">
<text class="section-icon">图</text>
<text>{{ loggedIn ? '我的地图' : '常用入口' }}</text>
</view>
</view>
<view v-if="mapTiles.length" class="map-grid">
<button
v-for="tile in mapTiles"
:key="tile.title"
class="map-tile"
:class="tile.tone"
@click="openTool(tile.title)"
>
<image class="map-icon" :src="tile.icon" mode="aspectFit" />
<view class="map-copy">
<text class="map-title">{{ tile.title }}</text>
<text class="map-desc">{{ tile.desc }}</text>
</view>
<text class="map-arrow">›</text>
</button>
</view>
<view v-else class="empty">暂无工具入口</view>
</view>
<button class="tip-card" @click="ask('催费话术')">
<text class="tip-mark">贴</text>
<text>今日小贴士:催费沟通先共情,再说明,再给方案。</text>
<text class="tip-arrow">›</text>
</button>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import { getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
const home = ref<RoleHome | null>(null);
const message = ref('');
const loggedIn = ref(false);
const quickQuestions = ['催费话术', '投诉应对', '收费标准'];
const pendingSopQueryKey = 'aihr_sop_pending_query';
const iconFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return '/static/map-icons/ask.png';
if (title.includes('练')) return '/static/map-icons/practice.png';
if (title.includes('学') || title.includes('案例')) return '/static/map-icons/learn.png';
return '/static/map-icons/report.png';
};
const toneFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return 'ask';
if (title.includes('练')) return 'practice';
if (title.includes('学') || title.includes('案例')) return 'learn';
return 'report';
};
const mapTiles = computed(() => {
if (!home.value?.tools?.length) return [];
return home.value.tools.map((item) => ({
title: item.title,
desc: item.desc.replace(/\n/g, ' / '),
tone: toneFor(item.title),
icon: iconFor(item.title)
}));
});
const mentorName = computed(() => {
if (!loggedIn.value) return '同事';
const phone = getAuth().phone.trim();
return phone ? `尾号${phone.slice(-4)}的同事` : '同事';
});
const mentorBubbleText = computed(() =>
loggedIn.value ? `${mentorName.value},今天先做两件事` : '同事,登录后看今天任务'
);
const loadHome = async () => {
loggedIn.value = isLoggedIn();
message.value = '';
if (!loggedIn.value) {
home.value = null;
return;
}
try {
home.value = await getMobileHome('user');
} catch (error) {
home.value = null;
message.value = error instanceof Error ? error.message : '首页数据加载失败';
}
};
const goSop = () => {
uni.switchTab({ url: '/pages/user/sop/index' });
};
const ask = (query: string) => {
uni.setStorageSync(pendingSopQueryKey, query);
uni.switchTab({ url: '/pages/user/sop/index' });
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/today/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const openTool = (title: string) => {
if (title.includes('问') || title.includes('SOP') || title.includes('sop')) {
goSop();
return;
}
if (title.includes('练') || title.includes('训练') || title.includes('开始')) {
uni.switchTab({ url: '/pages/user/practice/index' });
return;
}
if (title.includes('案例') || title.includes('素材') || title.includes('提交')) {
uni.navigateTo({ url: '/pages/user/cases/index' });
return;
}
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
};
onShow(loadHome);
</script>
<style scoped>
.today-page {
min-height: 100vh;
padding: 0 14px 86px;
background: #f4f7fb;
}
.mentor-hero {
position: relative;
height: 224px;
margin: 0 -14px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(237, 247, 255, 0.72), rgba(244, 247, 251, 0.98)),
linear-gradient(135deg, #d9ecff 0%, #f7fbff 45%, #dfeff2 100%);
}
.mentor-hero::before {
position: absolute;
right: -36px;
bottom: 12px;
width: 190px;
height: 96px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.46);
content: '';
}
.hero-copy {
position: absolute;
left: 22px;
top: 34px;
z-index: 2;
display: flex;
flex-direction: column;
}
.hero-role,
.hero-title {
color: #11235a;
font-size: 22px;
font-weight: 900;
line-height: 1.08;
}
.hero-sub {
margin-top: 8px;
color: #1e3a8a;
font-size: 13px;
font-weight: 700;
}
.mentor-image {
position: absolute;
right: 88px;
bottom: 14px;
z-index: 1;
width: 104px;
height: 138px;
border-radius: 24px;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
}
.mentor-bubble {
position: absolute;
right: 16px;
top: 80px;
z-index: 3;
width: 128px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
}
.mentor-bubble text {
color: #111827;
font-size: 17px;
font-weight: 700;
line-height: 1.45;
}
.voice-panel {
position: relative;
z-index: 4;
display: grid;
margin: -34px 8px 0;
padding: 16px;
grid-template-columns: 1fr 1fr;
gap: 14px;
border-radius: 22px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.12);
}
.voice-primary,
.voice-secondary {
display: flex;
min-height: 64px;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 18px;
background: transparent;
color: #1d4ed8;
font-size: 20px;
font-weight: 800;
}
.voice-secondary {
color: #4b5563;
background: #f1f5f9;
}
.voice-primary text:last-child,
.voice-secondary text:last-child {
white-space: nowrap;
}
.voice-mark,
.keyboard-mark {
display: flex;
width: 46px;
height: 46px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1d9bf0);
color: #ffffff;
font-size: 18px;
font-weight: 900;
}
.keyboard-mark {
background: #e5e7eb;
color: #4b5563;
}
.mentor-chips {
margin-top: 14px;
}
.mentor-chips .chip-row {
justify-content: space-between;
gap: 8px;
}
.mentor-chips .chip {
min-height: 42px;
flex: 1;
border: 1px solid rgba(203, 213, 225, 0.7);
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
color: #1f2937;
font-size: 15px;
font-weight: 800;
}
.section-card {
margin-top: 18px;
padding: 18px;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07);
}
.login-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-title,
.login-desc {
display: block;
}
.login-title {
color: #111827;
font-size: 18px;
font-weight: 900;
}
.login-desc {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.login-button {
min-width: 84px;
}
.section-head,
.section-title {
display: flex;
align-items: center;
}
.section-head {
justify-content: space-between;
}
.section-title {
gap: 9px;
color: #111827;
font-size: 21px;
font-weight: 900;
}
.section-icon {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #dbeafe;
color: #2563eb;
font-size: 13px;
font-weight: 900;
}
.section-link {
color: #64748b;
font-size: 14px;
}
.schedule-list {
display: flex;
margin-top: 16px;
flex-direction: column;
gap: 10px;
}
.schedule-row {
display: flex;
min-height: 82px;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 15px;
background: #f8fafc;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #ffffff;
font-size: 15px;
font-weight: 900;
}
.step-dot.blue {
background: #2563eb;
}
.step-dot.orange {
background: #f97316;
}
.schedule-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 5px;
}
.schedule-title {
color: #111827;
font-size: 16px;
font-weight: 900;
line-height: 1.25;
}
.schedule-desc {
color: #64748b;
font-size: 13px;
line-height: 1.35;
}
.schedule-action {
width: 68px;
min-height: 42px;
flex: none;
border-radius: 12px;
color: #ffffff;
font-size: 16px;
font-weight: 900;
}
.blue-action {
background: #2563eb;
}
.orange-action {
background: #f97316;
}
.map-grid {
display: grid;
margin-top: 16px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.map-tile {
position: relative;
display: flex;
min-height: 108px;
align-items: center;
gap: 12px;
padding: 16px 12px;
border-radius: 17px;
overflow: hidden;
text-align: left;
}
.map-tile.ask {
background: linear-gradient(135deg, #1d4ed8, #2682ff);
}
.map-tile.practice {
background: linear-gradient(135deg, #16a34a, #22c55e);
}
.map-tile.learn {
background: linear-gradient(135deg, #f59e0b, #fb923c);
}
.map-tile.report {
background: linear-gradient(135deg, #ef4444, #f97316);
}
.map-icon {
width: 42px;
height: 42px;
flex: none;
filter: brightness(0) invert(1);
}
.map-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.map-title {
color: #ffffff;
font-size: 24px;
font-weight: 900;
line-height: 1.1;
}
.map-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
line-height: 1.35;
}
.map-arrow {
color: #ffffff;
font-size: 28px;
font-weight: 500;
}
.tip-card {
display: flex;
margin-top: 18px;
min-height: 58px;
align-items: center;
gap: 10px;
padding: 0 14px;
border-radius: 16px;
background: #ffffff;
color: #475569;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
.tip-mark {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fef3c7;
color: #d97706;
font-size: 13px;
font-weight: 900;
}
.tip-card text:nth-child(2) {
flex: 1;
font-size: 14px;
line-height: 1.45;
}
.tip-arrow {
color: #94a3b8;
font-size: 24px;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
import type { ApiPayload } from '@/types/api';
import { clearAuth, getAuth } from './auth';
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface RequestOptions {
url: string;
method?: HttpMethod;
data?: unknown;
header?: Record<string, string>;
timeout?: number;
auth?: boolean;
}
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
export const apiUrl = normalizeUrl;
export const authHeaders = (json = true, includeAuth = true) => {
const auth = getAuth();
return {
...(json ? { 'Content-Type': 'application/json' } : {}),
...(includeAuth && auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
...(includeAuth && auth.clientId ? { clientid: auth.clientId } : {})
};
};
export const readPayload = <T>(statusCode: number, data: unknown): T => {
const payload = data as ApiPayload<T>;
const code = typeof payload?.code === 'number' ? payload.code : undefined;
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
if (statusCode === 401 || statusCode === 403 || code === 401 || code === 403) {
clearAuth();
}
throw new Error(payload?.msg || `HTTP ${statusCode}`);
}
return payload?.data as T;
};
export const parsePayloadText = (statusCode: number, text: string) => {
try {
return JSON.parse(text || '{}');
} catch (error) {
if (statusCode === 401 || statusCode === 403) clearAuth();
throw error instanceof SyntaxError
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
: error;
}
};
export const readTextPayload = <T>(statusCode: number, text: string): T =>
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
const method = options.method || 'GET';
const hasBody = method !== 'GET' && options.data !== undefined;
const response = await new Promise<{ status: number; data: unknown }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, normalizeUrl(options.url));
xhr.timeout = options.timeout || 30000;
Object.entries({
...authHeaders(hasBody, options.auth !== false),
...options.header
}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
xhr.onload = () => {
try {
resolve({ status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null') });
} catch {
reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
}
};
xhr.onerror = () => reject(new Error('网络请求失败'));
xhr.ontimeout = () => reject(new Error('网络请求超时'));
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
});
return readPayload<T>(response.status, response.data);
};
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
if (typeof XMLHttpRequest !== 'undefined') {
return requestWithXhr<T>(options);
}
const response = await new Promise<any>((resolve, reject) => {
uni.request({
url: normalizeUrl(options.url),
method: options.method || 'GET',
data: options.data as string | ArrayBuffer | Record<string, unknown> | undefined,
header: {
...authHeaders(options.method !== 'GET', options.auth !== false),
...options.header
},
timeout: options.timeout || 30000,
success: resolve,
fail: reject
});
});
return readPayload<T>(response.statusCode, response.data);
};
+49
View File
@@ -0,0 +1,49 @@
import type { LoginResult } from '@/types/api';
export const tokenKey = 'aihr_mobile_access_token';
export const clientIdKey = 'aihr_mobile_client_id';
export const phoneKey = 'aihr_mobile_phone';
export const loginRedirectKey = 'aihr_mobile_login_redirect';
export const phonePattern = /^1[3-9]\d{9}$/;
export interface AuthState {
token: string;
clientId: string;
phone: string;
}
export const getAuth = (): AuthState => ({
token: uni.getStorageSync(tokenKey) || '',
clientId: uni.getStorageSync(clientIdKey) || '',
phone: uni.getStorageSync(phoneKey) || ''
});
export const isLoggedIn = () => {
const auth = getAuth();
return Boolean(auth.token && auth.clientId && auth.phone);
};
export const saveAuth = (result: LoginResult, phone: string) => {
uni.setStorageSync(tokenKey, result.access_token);
uni.setStorageSync(clientIdKey, result.client_id);
uni.setStorageSync(phoneKey, phone);
};
export const clearAuth = () => {
uni.removeStorageSync(tokenKey);
uni.removeStorageSync(clientIdKey);
uni.removeStorageSync(phoneKey);
uni.removeStorageSync(loginRedirectKey);
};
export const rememberLoginRedirect = (url: string) => {
uni.setStorageSync(loginRedirectKey, url);
};
export const consumeLoginRedirect = () => {
const url = uni.getStorageSync(loginRedirectKey) || '';
uni.removeStorageSync(loginRedirectKey);
return url;
};
export const validatePhone = (phone: string) => phonePattern.test(phone);
+114
View File
@@ -0,0 +1,114 @@
import type {
CandidateMaterial,
CandidateSelectedFile,
InterviewFinishResponse,
InterviewRecord,
InterviewStartResponse
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getAuth } from './auth';
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
export const getCandidateId = () => {
const phone = getAuth().phone.trim();
if (!phone) {
throw new Error('请先登录后再查看候选人进度');
}
return phone;
};
export const getCandidateName = () => {
const phone = getAuth().phone.trim();
return phone ? `候选人${phone.slice(-4)}` : '候选人';
};
export const getCandidateMaterials = () =>
apiRequest<CandidateMaterial[]>({
url: `/api/aihr/mobile/candidate/materials?candidateId=${encodeURIComponent(getCandidateId())}`,
method: 'GET',
timeout: 15000
});
export const getCandidateInterviewRecords = () =>
apiRequest<InterviewRecord[]>({
url: `/api/recruit/interview/records?limit=5&candidateId=${encodeURIComponent(getCandidateId())}`,
method: 'GET',
timeout: 15000
});
export const chooseCandidateFile = () =>
new Promise<CandidateSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: materialExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'candidate-material',
size: Number(file.size || 0)
});
},
fail: () => reject(new Error('未选择文件'))
});
});
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
new Promise<CandidateMaterial>((resolve, reject) => {
uni.uploadFile({
url: apiUrl('/api/aihr/mobile/candidate/materials'),
filePath: file.path,
name: 'file',
header: authHeaders(false),
formData: {
candidateId: getCandidateId(),
candidateName: getCandidateName(),
materialType
},
success: (response) => {
try {
resolve(readTextPayload<CandidateMaterial>(response.statusCode, response.data));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error('资料上传失败'))
});
});
export const startCandidateInterview = () =>
apiRequest<InterviewStartResponse>({
url: '/api/recruit/interview/start',
method: 'POST',
data: {
candidateId: getCandidateId(),
candidateName: getCandidateName(),
positionCode: 'customer-service',
mode: 'mobile'
},
timeout: 30000
});
export const saveInterviewAnswer = (sessionId: string, questionId: string, answerText: string) =>
apiRequest<void>({
url: '/api/recruit/interview/answer',
method: 'POST',
data: { sessionId, questionId, answerText },
timeout: 30000
});
export const finishCandidateInterview = (sessionId: string, answers: Record<string, string>) =>
apiRequest<InterviewFinishResponse>({
url: '/api/recruit/interview/finish',
method: 'POST',
data: { sessionId, answers },
timeout: 45000
});
+80
View File
@@ -0,0 +1,80 @@
import type {
CaseCurateResponse,
CaseOrganizeResponse,
CaseRecord,
CaseSelectedFile,
CaseUploadResponse
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
export const chooseCaseAudio = () =>
new Promise<CaseSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: audioExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'case-audio',
size: Number(file.size || 0)
});
},
fail: () => reject(new Error('未选择音频'))
});
});
export const uploadCaseAudio = (file: CaseSelectedFile) =>
new Promise<CaseUploadResponse>((resolve, reject) => {
uni.uploadFile({
url: apiUrl('/api/knowledge/case/upload'),
filePath: file.path,
name: 'file',
header: authHeaders(false),
formData: {},
success: (response) => {
try {
resolve(readTextPayload<CaseUploadResponse>(response.statusCode, response.data));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error('案例音频上传失败'))
});
});
export const organizeCase = (caseId: string) =>
apiRequest<CaseOrganizeResponse>({
url: '/api/knowledge/case/organize',
method: 'POST',
data: { caseId },
timeout: 45000
});
export const curateCase = (caseId: string) =>
apiRequest<CaseCurateResponse>({
url: '/api/knowledge/case/curate',
method: 'POST',
data: {
caseId,
criteria: '一线可复用培训案例',
limit: 1
},
timeout: 30000
});
export const getCaseRecords = () =>
apiRequest<CaseRecord[]>({
url: '/api/knowledge/case/records?limit=10',
method: 'GET',
timeout: 15000
});
+10
View File
@@ -0,0 +1,10 @@
import type { RoleHome, RoleKey } from '@/types/api';
import { apiRequest } from './api';
export const getMobileHome = (role: RoleKey) =>
apiRequest<RoleHome>({
url: `/api/aihr/mobile/home/${role}`,
method: 'GET',
auth: false,
timeout: 15000
});
+65
View File
@@ -0,0 +1,65 @@
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
KnowledgeSnippet,
SummaryCardResponse
} from '@/types/api';
import { apiRequest } from './api';
type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string;
text?: string;
};
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
...snippet,
docTitle: snippet.docTitle || snippet.title || '',
content: snippet.content || snippet.text || ''
});
export const searchKnowledge = async (queryText: string) => {
const response = await apiRequest<KnowledgeSearchResponse>({
url: '/api/knowledge/search',
method: 'POST',
data: {
queryText,
category: 'sop',
limit: 5
},
timeout: 30000
});
return {
...response,
snippets: (response.snippets || []).map((item) => normalizeSnippet(item as RawKnowledgeSnippet))
};
};
export const submitAnswerFeedback = (params: {
queryText: string;
fragmentIds: number[];
verdict: AnswerFeedbackVerdict;
}) =>
apiRequest<{ saved: number }>({
url: '/api/knowledge/answer-feedback',
method: 'POST',
data: {
queryText: params.queryText,
fragmentIds: params.fragmentIds,
verdict: params.verdict,
category: 'sop',
position: '生活顾问',
source: 'mobile_uni_sop'
},
timeout: 15000
});
export const generateSummaryCard = (queryText: string) =>
apiRequest<SummaryCardResponse>({
url: '/api/knowledge/summary-card',
method: 'POST',
data: {
queryText,
category: 'sop'
},
timeout: 45000
});
+154
View File
@@ -0,0 +1,154 @@
import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeRecord,
PracticeStartResponse,
PracticeTurnResponse,
CompetencyProfile,
ReviewAdviceRequest,
ReviewDetail,
PracticeAssignment,
PracticeAssignmentRequest,
DailyDrillAnswerResponse,
PracticeAlert,
PromotionEvidence
} from '@/types/api';
import { apiRequest } from './api';
import { getAuth } from './auth';
const defaultScenarioId = 'complaint-water';
const currentExtPartyId = () => {
const phone = getAuth().phone.trim();
if (!phone) {
throw new Error('请先登录后再查看训练数据');
}
return phone;
};
export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
apiRequest<PracticePrepCard>({
url: `/api/train/practice/scenarios/${encodeURIComponent(scenarioId)}/prep-card`,
method: 'GET',
timeout: 15000
});
export const startPractice = (scenarioId = defaultScenarioId, assignmentId?: number) =>
apiRequest<PracticeStartResponse>({
url: '/api/train/practice/start',
method: 'POST',
data: {
extPartyId: currentExtPartyId(),
scenarioId,
mode: 'mobile',
assignmentId
},
timeout: 30000
});
export const submitPracticeTurn = (params: {
sessionId: string;
roundIndex: number;
traineeText: string;
regenerate?: boolean;
style?: string;
}) =>
apiRequest<PracticeTurnResponse>({
url: '/api/train/practice/turn',
method: 'POST',
data: params,
timeout: 45000
});
export const finishPractice = (sessionId: string, assignmentId?: number) =>
apiRequest<PracticeFinishResponse>({
url: '/api/train/practice/finish',
method: 'POST',
data: { sessionId, assignmentId },
timeout: 45000
});
export const getPracticeHistory = () => {
const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<PracticeRecord[]>({
url: `/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`,
method: 'GET',
timeout: 15000
});
};
export const getCompetencyProfile = () => {
const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<CompetencyProfile>({
url: `/api/aihr/mobile/profile?extPartyId=${extPartyId}`,
method: 'GET',
timeout: 15000
});
};
export const getPromotionEvidence = () => {
const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<PromotionEvidence>({
url: `/api/aihr/mobile/profile/promotion-evidence?extPartyId=${extPartyId}`,
method: 'GET',
timeout: 15000
});
};
export const getPracticeReviews = () =>
apiRequest<PracticeRecord[]>({
url: '/api/aihr/mobile/practice/reviews',
method: 'GET',
timeout: 15000
});
export const getPracticeReviewDetail = (id: number) =>
apiRequest<ReviewDetail>({
url: `/api/aihr/mobile/practice/reviews/${id}`,
method: 'GET',
timeout: 15000
});
export const markPracticeReviewed = (id: number, data: ReviewAdviceRequest) =>
apiRequest<void>({
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
method: 'POST',
data,
timeout: 15000
});
export const getPracticeAssignments = (ownOnly = false) => {
const extPartyId = ownOnly ? encodeURIComponent(currentExtPartyId()) : '';
return apiRequest<PracticeAssignment[]>({
url: `/api/aihr/mobile/practice/assignments${ownOnly ? `?extPartyId=${extPartyId}` : ''}`,
method: 'GET',
timeout: 15000
});
};
export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
apiRequest<PracticeAssignment>({
url: '/api/aihr/mobile/practice/assignments',
method: 'POST',
data: {
source: 'manual',
scenarioId: 'fee-parking',
...data
},
timeout: 15000
});
export const answerDailyDrill = (id: number, answer: string) =>
apiRequest<DailyDrillAnswerResponse>({
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
method: 'POST',
data: { answer },
timeout: 15000
});
export const getPracticeAlerts = () =>
apiRequest<PracticeAlert[]>({
url: '/api/aihr/mobile/practice/alerts',
method: 'GET',
timeout: 15000
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+156
View File
@@ -0,0 +1,156 @@
page {
min-height: 100%;
background: #f5f7fb;
color: #172033;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
view,
text,
input,
button {
box-sizing: border-box;
}
button {
margin: 0;
border: 0;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
button::after {
border: 0;
}
.page {
min-height: 100vh;
padding: 20px 16px 32px;
}
.page-title {
display: block;
font-size: 28px;
font-weight: 800;
line-height: 1.15;
color: #0f172a;
}
.page-subtitle {
display: block;
margin-top: 8px;
font-size: 15px;
line-height: 1.6;
color: #64748b;
}
.panel {
margin-top: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #ffffff;
}
.panel-title {
font-size: 18px;
font-weight: 800;
color: #1e293b;
}
.muted {
color: #64748b;
}
.primary-button,
.secondary-button,
.ghost-button {
display: flex;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 0 16px;
font-weight: 700;
}
.primary-button {
background: #e60012;
color: #ffffff;
}
.secondary-button {
border: 1px solid #cbd5e1;
background: #ffffff;
color: #1e293b;
}
.ghost-button {
background: #eff6ff;
color: #1d4ed8;
}
.primary-button[disabled],
.secondary-button[disabled],
.ghost-button[disabled] {
opacity: 0.55;
}
.input {
width: 100%;
min-height: 48px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #0f172a;
font-size: 16px;
}
.textarea {
width: 100%;
min-height: 112px;
padding: 12px 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #0f172a;
font-size: 16px;
line-height: 1.5;
}
.inline-actions {
display: flex;
gap: 10px;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: #f1f5f9;
color: #334155;
font-size: 14px;
}
.status-message {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
font-size: 14px;
line-height: 1.5;
}
.empty {
padding: 24px 12px;
color: #64748b;
text-align: center;
}
+380
View File
@@ -0,0 +1,380 @@
export interface ApiPayload<T> {
code?: number;
msg?: string;
data?: T;
}
export interface LoginResult {
access_token: string;
client_id: string;
}
export type RoleKey = 'user' | 'supervisor' | 'candidate';
export interface ToolItem {
title: string;
desc: string;
tone?: string;
}
export interface PrimaryTask {
badge: string;
title: string;
desc: string;
progressText: string;
progress: number;
cta: string;
}
export interface TodoItem extends ToolItem {
count: number;
}
export interface ReviewTask {
title: string;
desc: string;
}
export interface RoleHome {
role: RoleKey;
roleLabel: string;
greeting: string;
location: string;
toolTitle?: string;
recommend?: string;
primary?: PrimaryTask;
review?: ReviewTask;
tools?: ToolItem[];
todos?: TodoItem[];
}
export interface KnowledgeSnippet {
docTitle: string;
content: string;
source?: string;
fragmentId?: number | null;
}
export interface KnowledgeDoc {
title: string;
category?: string;
score?: number;
}
export interface KnowledgeSearchResponse {
queryText: string;
category: string;
answer: string;
reference: string;
docs: KnowledgeDoc[];
snippets: KnowledgeSnippet[];
training: string[];
records: unknown[];
keyPoints?: string[];
cautions?: string[];
}
export type AnswerFeedbackVerdict = 'up' | 'down';
export interface SummaryCardStep {
label: string;
desc: string;
}
export interface SummaryCardObjection {
concern: string;
response: string;
}
export interface SummaryCardResponse {
title: string;
steps: SummaryCardStep[];
objections: SummaryCardObjection[];
scripts: string[];
reminders: string[];
}
export type PracticeRole = 'customer' | 'trainee' | 'coach';
export interface PracticeTurn {
role: PracticeRole;
text: string;
}
export interface PracticeStartResponse {
sessionId: string;
scenarioId: string;
scenarioName: string;
trainee: string;
customerText: string;
emotion?: number;
trust?: number;
customerAudioUrl?: string;
}
export interface PracticePrepCard {
keyPoints: string[];
redLines: string[];
scripts: string[];
promptVersion: string;
}
export interface PracticeTurnResponse {
customerText: string;
customerAudioUrl?: string;
emotion?: number;
trust?: number;
coachHint?: string;
redFlag?: boolean;
roundIndex: number;
finished: boolean;
message?: string;
}
export interface PracticeDimensionScore {
name: string;
score: number;
comment?: string;
}
export interface PracticeFinishResponse {
totalScore: number;
score?: PracticeDimensionScore[];
mentorRewrite: string;
aiComment: string;
trustCurve?: number[];
}
export interface PracticeRecord {
id?: number;
sessionId?: string;
extPartyId?: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
}
export interface ReviewDialogue {
role: string;
label: string;
text: string;
audioUrl?: string;
}
export interface ReviewAnnotation {
turnIndex: number;
sourceText: string;
issue: string;
rewrite: string;
sopPoint: string;
}
export interface ReviewDetail extends PracticeRecord {
mentorRewrite: string;
aiComment: string;
reviewAdvice?: string;
incentivePoint?: number;
scoreItems: CompetencyDimension[];
dialogue: ReviewDialogue[];
annotations: ReviewAnnotation[];
}
export interface ReviewAdviceRequest {
reviewer?: string;
advice?: string;
incentivePoint?: number;
nextScenarioId?: string;
nextReason?: string;
}
export interface 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 | null;
}
export interface PracticeAssignmentRequest {
extPartyId: string;
traineeName?: string;
scenarioId?: string;
scenarioName?: string;
source?: string;
reason?: string;
}
export interface DailyDrillAnswerResponse {
id: number;
status: string;
correct: boolean;
score: number;
feedback: string;
referenceAnswer: string;
}
export interface PracticeAlert {
type: string;
title: string;
desc: string;
severity: string;
extPartyId?: string;
}
export interface CandidateMaterial {
id: number | string;
ossId: number | string;
fileName: string;
materialType: string;
fileSize: number;
status: string;
time: string;
}
export interface CandidateSelectedFile {
path: string;
name: string;
size: number;
}
export interface CaseSelectedFile {
path: string;
name: string;
size: number;
}
export interface CaseTag {
text: string;
type: string;
}
export interface CaseSummaryItem {
label: string;
text: string;
}
export interface CaseRecord {
title: string;
type: string;
status: string;
time: string;
owner: string;
summary: string;
}
export interface CaseUploadResponse {
caseId: string;
fileName: string;
transcript: string;
tags: CaseTag[];
source: string;
}
export interface CaseOrganizeResponse {
caseId: string;
summary: CaseSummaryItem[];
tags: CaseTag[];
aiSummary: string;
source: string;
}
export interface CaseCurateResponse {
selectedCaseIds: string[];
records: CaseRecord[];
sampleHint: string;
}
export interface InterviewQuestion {
questionId: string;
seq: number;
questionText: string;
point: string;
scenario: string;
sampleAnswer: string;
}
export interface InterviewStartResponse {
sessionId: string;
candidateId: string;
candidateName: string;
positionCode: string;
questions: InterviewQuestion[];
source: string;
}
export interface InterviewDimension {
label: string;
value: number | string;
note: string;
}
export interface InterviewRecord {
time: string;
candidate: string;
position: string;
score: number;
result: string;
summary: string;
}
export interface InterviewFinishResponse {
totalScore: number;
suggestion: string;
result: string;
aiSummary: string;
evidence: string;
dimensions: InterviewDimension[];
records: InterviewRecord[];
source: string;
}
export interface CompetencyDimension {
label: string;
value: number | string;
note: string;
}
export interface GrowthStage {
level: string;
progress: number;
status: string;
evidence: string;
}
export interface CompetencyProfile {
score: number;
completed: number;
pendingReview: number;
trainingMinutes?: number;
contribution?: number;
assessmentScore?: number;
aiLevel?: string;
dimensions: CompetencyDimension[];
growthPath?: GrowthStage[];
}
export interface PromotionEvidence {
extPartyId: string;
currentLevel: string;
assessmentScore: number;
trainingMinutes: number;
contribution: number;
completed: number;
pendingReview: number;
dimensions: CompetencyDimension[];
latestRecords: PracticeRecord[];
assignments: PracticeAssignment[];
evidenceItems: string[];
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["@dcloudio/types"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import uniPlugin from '@dcloudio/vite-plugin-uni';
const uni = typeof uniPlugin === 'function' ? uniPlugin : uniPlugin.default;
const devPort = Number(process.env.PORT) || 5175;
const devHost = process.env.HOST || '0.0.0.0';
export default defineConfig({
plugins: [uni()],
server: {
host: devHost,
port: devPort,
strictPort: true,
proxy: {
'/dev-api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dev-api/, '')
}
}
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"apps": {
"mobile-uni": {
"name": "wygj-mobile-uni",
"script": "dev"
}
}
}
+25 -22
View File
@@ -3,7 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:5173}"
MOBILE_URL="${MOBILE_URL:-http://127.0.0.1:5174}"
MOBILE_URL="${MOBILE_URL:-https://wygj-mobile-uni.localhost}"
cd "$ROOT_DIR"
@@ -26,7 +26,7 @@ check_url() {
local name="$1"
local url="$2"
local code
code="$(curl -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
code="$(curl -k -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
[[ "$code" == "200" ]] || fail "$name returned HTTP $code"
echo "OK: $name"
}
@@ -75,7 +75,7 @@ check_pilot_samples() {
need curl
check_url "frontend" "$FRONTEND_URL/"
check_url "mobile" "$MOBILE_URL/h5/user"
check_url "mobile-uni" "$MOBILE_URL/h5/"
tenant_payload="$(curl -fsS "$FRONTEND_URL/dev-api/auth/tenant/list")"
[[ "$tenant_payload" == *'"tenantId":"000000"'* ]] || fail "tenant list missing tenant 000000"
@@ -91,20 +91,23 @@ contains frontend/src/router/index.ts "/knowledge/cases"
contains frontend/src/router/index.ts "/knowledge/sop"
contains frontend/src/router/index.ts "/system/model"
contains mobile/src/App.vue "早上好,王敏"
contains mobile/src/App.vue "AI结构化面试"
contains mobile/src/App.vue "团队今日动态"
contains mobile/src/App.vue "sendSmsCode"
contains mobile/src/App.vue "/dev-api/resource/sms/code"
contains mobile/src/App.vue "/dev-api/auth/mobile/sms-login"
contains mobile/src/App.vue "fetchMobileHome"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/home/"
contains mobile/src/App.vue "startMobilePractice"
contains mobile/src/App.vue "/dev-api/api/train/practice/start"
contains mobile/src/App.vue "训练完成,主管端待复盘已更新"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/practice/history"
contains mobile/src/App.vue "competencyProfile"
contains mobile/package.json "aihr-mobile"
contains mobile-uni/package.json "aihr-mobile-uni"
contains mobile-uni/src/pages.json "pages/user/today/index"
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
contains mobile-uni/src/pages.json "pages/candidate/index/index"
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
contains mobile-uni/src/pages/auth/login/index.vue "/auth/mobile/sms-login"
contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
contains mobile-uni/src/services/practice.ts "/api/train/practice/start"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/history"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/profile/promotion-evidence"
contains mobile-uni/src/pages/user/practice/index.vue "训练完成,主管端待复盘已更新"
contains mobile-uni/src/pages/user/profile/index.vue "贡献度"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/knowledge/cases.vue "真实语音"
@@ -133,21 +136,21 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
contains frontend/src/views/train/practice.vue "practice_help"
contains mobile/src/App.vue "practice_help"
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
contains mobile/src/App.vue "新员工每日三题"
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains mobile/src/App.vue "贡献度加分"
contains mobile-uni/src/pages/supervisor/index/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile/src/App.vue "标记已复盘"
contains mobile-uni/src/pages/supervisor/index/index.vue "标记已复盘"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
contains mobile/src/App.vue "submitDailyDrill"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
+19 -3
View File
@@ -2,12 +2,28 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if [[ -f "$ROOT_DIR/.env.local" ]]; then
load_env_file() {
local env_file="$1"
if [[ -f "$env_file" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env.local"
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
}
load_env_file "$ROOT_DIR/.env.local"
if [[ -f "$ROOT_DIR/backend/.env" ]]; then
load_env_file "$ROOT_DIR/backend/.env"
if [[ -n "${client_id:-}" ]]; then
export AIHR_ORG_SYNC_CLIENT_ID="$client_id"
fi
if [[ -n "${client_secret:-}" ]]; then
export AIHR_ORG_SYNC_CLIENT_SECRET="$client_secret"
fi
fi
cd "$ROOT_DIR/backend"
+4 -3
View File
@@ -8,7 +8,7 @@ usage() {
cat <<'EOF'
Usage: ./scripts/dev.sh [start|stop|restart|status|--reset]
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and mobile frontend.
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and legacy mobile fallback.
start start local demo services
stop stop backend and frontend dev processes
restart stop backend and frontend, then start again
@@ -148,9 +148,10 @@ PIDS+=("$!")
echo "Backend: http://127.0.0.1:8080/"
echo "Admin: http://127.0.0.1:5173/"
echo "Mobile: http://127.0.0.1:5174/h5/user"
echo "Legacy mobile fallback: http://127.0.0.1:5174/h5/user"
echo "Current user side: https://wygj-mobile-uni.localhost/h5/ (run: portless)"
echo "Qdrant: http://127.0.0.1:6333/"
echo "Press Ctrl+C to stop backend, admin frontend and mobile frontend."
echo "Press Ctrl+C to stop backend, admin frontend and legacy mobile frontend."
while :; do
for pid in "${PIDS[@]}"; do
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const args = new Map(process.argv.slice(2).map((arg) => {
const [key, ...rest] = arg.replace(/^--/, '').split('=');
return [key, rest.join('=') || 'true'];
}));
const baseUrl = args.get('base-url') || process.env.AIHR_BASE_URL || 'http://127.0.0.1:8080';
const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演示候选题单.md');
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
const limit = Number(args.get('limit') || 0);
function readEnv(filePath) {
const env = {};
const text = fs.readFileSync(filePath, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (!match) continue;
env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return env;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
const clientid = process.env.AIHR_CLIENT_ID || frontendEnv.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY);
function aesEncrypt(text, keyText) {
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64');
}
function aesDecrypt(base64Text, key) {
const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8');
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const encryptedBody = aesEncrypt(JSON.stringify(payload), keyText);
const keyBase64 = Buffer.from(keyText, 'utf8').toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(keyBase64, 'utf8')
).toString('base64');
return { encryptedBody, encryptedKey };
}
function decryptResponse(text, encryptedKey) {
if (!encryptedKey) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(encryptedKey, 'base64')
).toString('utf8');
return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64')));
}
async function request(method, endpoint, body, token, encrypted = false) {
const headers = { clientid, 'Content-Language': 'zh_CN' };
let payload;
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const encryptedPayload = encryptPayload(body);
payload = encryptedPayload.encryptedBody;
headers['encrypt-key'] = encryptedPayload.encryptedKey;
} else {
payload = JSON.stringify(body);
}
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, { method, headers, body: payload, signal: controller.signal });
const text = await response.text();
const data = encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
return { status: response.status, data };
} finally {
clearTimeout(timeout);
}
}
async function login() {
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
const codeResponse = await request('GET', '/auth/code');
const uuid = codeResponse.data?.data?.uuid;
if (!uuid) throw new Error('captcha uuid missing');
const redisKey = `global:captcha_codes:${uuid}`;
const redisValue = execFileSync(
'docker',
['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', redisKey],
{ encoding: 'utf8' }
).trim().split('\n').pop().replace(/^"|"$/g, '');
const loginResponse = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code: redisValue,
uuid,
clientId: clientid,
grantType: 'password'
}, undefined, true);
const token = loginResponse.data?.data?.access_token;
if (!token) throw new Error(`login failed: ${loginResponse.data?.msg || loginResponse.status}`);
return token;
}
function parseQuestions(markdown) {
const rows = [];
for (const line of markdown.split(/\r?\n/)) {
if (!line.startsWith('|')) continue;
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
if (cells.length < 5 || !/^[A-Z]\d+$/u.test(cells[0])) continue;
if (!prefixes.some((prefix) => cells[0].startsWith(prefix))) continue;
rows.push({
id: cells[0],
scene: cells[1],
question: cells[2],
sources: [...cells[3].matchAll(/`([^`]+)`/g)].map((match) => match[1])
});
}
return limit > 0 ? rows.slice(0, limit) : rows;
}
function hasExpectedSource(result, expectedSources) {
if (!expectedSources.length) return true;
const haystack = [
...(result.docs || []).map((item) => item.title || item.docName || item.name || ''),
...(result.snippets || []).map((item) => item.title || item.docName || item.name || '')
].join('\n');
return expectedSources.some((source) => haystack.includes(source.replace(/\s+/g, ' ').trim()));
}
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
if (!questions.length) {
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
process.exit(1);
}
const token = await login();
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
const vectorStatus = vector.data?.data;
if (!vectorStatus?.matched) {
console.error(`FAIL: vector index mismatch: ${vectorStatus?.message || 'unknown'}`);
process.exit(1);
}
let failed = 0;
let warned = 0;
console.log(`VERIFY ${questions.length} question(s), prefix=${prefixes.join(',')}, base=${baseUrl}`);
for (const item of questions) {
const response = await request('POST', '/api/knowledge/search', {
queryText: item.question,
category: 'sop',
position: '生活顾问'
}, token);
const data = response.data?.data || {};
const docs = data.docs || [];
const snippets = data.snippets || [];
const ok = response.status === 200 && response.data?.code === 200 && Boolean((data.answer || '').trim()) && (docs.length || snippets.length);
const sourceOk = hasExpectedSource(data, item.sources);
if (!ok) {
failed += 1;
console.log(`FAIL ${item.id} ${item.scene}: ${item.question}`);
continue;
}
if (!sourceOk) {
warned += 1;
console.log(`WARN ${item.id} ${item.scene}: answered, but top evidence missed listed source`);
} else {
console.log(`OK ${item.id} ${item.scene}`);
}
}
console.log(`SUMMARY ok=${questions.length - failed}/${questions.length} warnings=${warned} vector=${vectorStatus.embeddedFragments}/${vectorStatus.fragments}`);
process.exit(failed ? 1 : 0);