From 87360a25d343ec65703b2353ba325a25c8b5b873 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 02:04:36 +0800 Subject: [PATCH] feat(aihr): align figma pages for release --- AGENTS.md | 2 +- README.md | 4 +- .../aihr/controller/AihrMobileController.java | 51 +- .../controller/AihrPracticeController.java | 21 + .../dromara/aihr/domain/AihrInterviewDto.java | 6 +- .../dromara/aihr/domain/AihrPracticeDto.java | 26 + .../dromara/aihr/domain/vo/AihrMobileDto.java | 8 + .../aihr/service/AihrInterviewService.java | 42 +- .../aihr/service/AihrMobileSeedService.java | 4 + .../aihr/service/AihrPracticeSeedService.java | 116 ++++ .../aihr/service/AihrUploadQueueService.java | 127 ++++- .../sql/aihr_interview_result_mysql8.sql | 1 + docs/API_INTEGRATION.md | 5 +- docs/DEV_SETUP.md | 2 +- docs/FIGMA_DESIGN_INVENTORY.md | 5 +- docs/FIGMA_DESIGN_SYSTEM_GUIDE.md | 183 +++++++ docs/FIGMA_IMPLEMENTATION_MATRIX.md | 134 +++++ docs/FIGMA_ONLINE_DELTA.md | 34 ++ docs/README.md | 2 + frontend/src/api/aihr/practice.ts | 64 +++ frontend/src/api/aihr/sop.ts | 36 ++ frontend/src/router/index.ts | 18 + frontend/src/views/knowledge/processing.vue | 15 +- frontend/src/views/knowledge/sop.vue | 133 ++++- frontend/src/views/system/prompts.vue | 242 ++++++++ frontend/src/views/train/practice.vue | 25 +- frontend/src/views/train/reviews.vue | 131 ++++- frontend/src/views/train/rubrics.vue | 315 +++++++++++ frontend/src/views/train/scenarios.vue | 346 ++++++++++++ .../src/components/SupervisorTabBar.vue | 80 +++ mobile-uni/src/pages.json | 16 +- mobile-uni/src/pages/auth/login/index.vue | 40 +- .../src/pages/candidate/progress/index.vue | 515 +++++++----------- .../src/pages/supervisor/assign/index.vue | 123 ++++- .../src/pages/supervisor/index/index.vue | 27 +- .../src/pages/supervisor/profile/index.vue | 108 ++++ .../src/pages/supervisor/review/index.vue | 14 +- .../src/pages/supervisor/reviews/index.vue | 16 +- .../src/pages/supervisor/team/index.vue | 186 ++++++- mobile-uni/src/pages/user/history/index.vue | 343 ++++++++++++ mobile-uni/src/pages/user/practice/index.vue | 163 +++--- mobile-uni/src/pages/user/profile/index.vue | 152 ++++-- mobile-uni/src/pages/user/sop/index.vue | 222 +++++--- mobile-uni/src/pages/user/today/index.vue | 4 +- mobile-uni/src/services/auth.ts | 3 + mobile-uni/src/services/position.ts | 31 +- mobile-uni/src/services/practice.ts | 7 + mobile-uni/src/styles/base.css | 22 +- mobile-uni/src/types/api.ts | 15 + mobile-uni/tests/mobile-user-pages.test.mjs | 11 +- scripts/demo-check.sh | 8 + 51 files changed, 3575 insertions(+), 629 deletions(-) create mode 100644 docs/FIGMA_DESIGN_SYSTEM_GUIDE.md create mode 100644 docs/FIGMA_IMPLEMENTATION_MATRIX.md create mode 100644 docs/FIGMA_ONLINE_DELTA.md create mode 100644 frontend/src/views/system/prompts.vue create mode 100644 frontend/src/views/train/rubrics.vue create mode 100644 frontend/src/views/train/scenarios.vue create mode 100644 mobile-uni/src/components/SupervisorTabBar.vue create mode 100644 mobile-uni/src/pages/supervisor/profile/index.vue create mode 100644 mobile-uni/src/pages/user/history/index.vue diff --git a/AGENTS.md b/AGENTS.md index 260e9a64..17de1ebe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,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-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`,失败明确报错可重试,不兜假数据。 +- 移动端三端首页:`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 修正项,修正项优先于图面);问答卡两侧图片容器统一 `64×64`,横向内容垂直居中;员工端训练页是 `/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`。 diff --git a/README.md b/README.md index 2c3d9be6..cdd81f46 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。 - 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。 - 用户侧 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 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。 +- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。 - 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 @@ -45,6 +45,8 @@ |---|---| | [文档目录](docs/README.md) | docs 区完整索引 | | [Figma 设计资产清单](docs/FIGMA_DESIGN_INVENTORY.md) | 79 个页面、71 张正式画板的节点索引、批次范围和一致性审计结果 | +| [Figma 视觉系统使用规范](docs/FIGMA_DESIGN_SYSTEM_GUIDE.md) | 通用视觉元素、业务元素、状态映射和 LLM 组页规则 | +| [Figma 与线上 H5 差异矩阵](docs/FIGMA_ONLINE_DELTA.md) | 线上真实页面与 Figma 目标的差异、调整顺序和验收口径 | | [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 56493b4c..672ccce5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -16,6 +16,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; +import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse; +import org.dromara.aihr.service.AihrOrgSyncService; import org.dromara.aihr.service.AihrMobileSeedService; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; @@ -44,6 +46,7 @@ import java.util.Map; public class AihrMobileController { private final AihrMobileSeedService mobileSeedService; + private final AihrOrgSyncService orgSyncService; private final ISysOssService ossService; @SaIgnore @@ -52,11 +55,42 @@ public class AihrMobileController { return R.ok(mobileSeedService.home(role)); } + /** + * Return the authenticated APP user's mobile role from the latest org snapshot. + * The login response intentionally stays compatible with the shared OAuth contract. + */ + @GetMapping("/me") + public R me() { + String phone = currentAppUsername(); + if (phone.isBlank()) { + return R.ok(new IdentityResponse("user", "员工端", "", "一线")); + } + var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 1, 1); + var row = snapshot.rows().stream().findFirst(); + if (row.isEmpty()) { + return R.ok(new IdentityResponse("user", "员工端", "", "一线")); + } + var person = row.get(); + boolean supervisor = "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel()); + return R.ok(new IdentityResponse( + supervisor ? "supervisor" : "user", + supervisor ? "主管端" : "员工端", + person.positionName(), + person.positionLevel() + )); + } + @GetMapping("/practice/history") public R> practiceHistory(@RequestParam(required = false) String extPartyId) { return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId))); } + @GetMapping("/practice/history/{id}") + public R> practiceHistoryDetail(@PathVariable Long id) { + ReviewDetailResponse detail = mobileSeedService.practiceHistoryDetail(id, ownMobileExtPartyId(null)); + return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail)); + } + @GetMapping("/practice/reviews") public R> practiceReviews(@RequestParam(required = false) String projectCode, @RequestParam(required = false) String extPartyId, @@ -172,7 +206,7 @@ public class AihrMobileController { Map item = new LinkedHashMap<>(); item.put("role", turn.role()); item.put("label", turn.label()); - item.put("text", turn.text()); + item.put("text", maskSensitiveText(turn.text())); item.put("audioUrl", turn.audioUrl()); item.put("audioOssId", turn.audioOssId()); return item; @@ -184,10 +218,17 @@ public class AihrMobileController { private static Map annotationPayload(AnnotationResponse annotation) { return Map.of( "turnIndex", annotation.turnIndex(), - "sourceText", annotation.sourceText(), - "issue", annotation.issue(), - "rewrite", annotation.rewrite(), - "sopPoint", annotation.sopPoint() + "sourceText", maskSensitiveText(annotation.sourceText()), + "issue", maskSensitiveText(annotation.issue()), + "rewrite", maskSensitiveText(annotation.rewrite()), + "sopPoint", maskSensitiveText(annotation.sopPoint()) ); } + + private static String maskSensitiveText(String value) { + if (value == null || value.isBlank()) { + return value; + } + return value.replaceAll("(1\\d{2})\\d{4}(\\d{4})", "$1****$2"); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index d22d448a..70320775 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -12,6 +12,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; @@ -86,6 +89,24 @@ public class AihrPracticeController { return R.ok(practiceSeedService.rubrics(scenarioId)); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/rubrics/manage") + public R> manageRubrics() { + return R.ok(practiceSeedService.manageRubrics()); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping("/rubrics/manage") + public R saveRubric(@RequestBody RubricSaveRequest request) { + return R.ok(practiceSeedService.saveRubric(request)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PatchMapping("/rubrics/{code}/enabled") + public R updateRubricEnabled(@PathVariable String code, @RequestBody RubricEnabledRequest request) { + return practiceSeedService.updateRubricEnabled(code, request) ? R.ok() : R.fail("评分规则不存在"); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @GetMapping("/export") public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java index 27b3aef0..e6c0c3a8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java @@ -41,6 +41,10 @@ public final class AihrInterviewDto { public record DimensionResponse(String label, Object value, String note) { } - public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary) { + public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) { + } + + public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary, + List questions) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 778c9e96..06561529 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -78,6 +78,32 @@ public final class AihrPracticeDto { public record RubricDimensionResponse(String code, String name, Double weight, String description) { } + public record RubricAdminResponse( + String code, + String scenarioCode, + String scenarioName, + String name, + String version, + Boolean enabled, + String description, + List dimensions + ) { + } + + public record RubricSaveRequest( + String code, + String scenarioCode, + String name, + String version, + Boolean enabled, + String description, + List dimensions + ) { + } + + public record RubricEnabledRequest(Boolean enabled) { + } + public record PrepCardResponse(List keyPoints, List redLines, List scripts, String promptVersion) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java index 76d219a3..d5388386 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/vo/AihrMobileDto.java @@ -10,6 +10,14 @@ public final class AihrMobileDto { private AihrMobileDto() { } + public record IdentityResponse( + String role, + String roleLabel, + String positionName, + String positionLevel + ) { + } + public record HomeResponse( String role, String roleLabel, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index 9d34592c..62475111 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -11,10 +11,12 @@ import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse; +import org.dromara.aihr.domain.AihrInterviewDto.QuestionRecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import java.sql.ResultSet; @@ -88,7 +90,8 @@ public class AihrInterviewService { } ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session)); String time = LocalDateTime.now().format(TIME_FORMAT); - RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary()); + List questions = questionRecords(session); + RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary(), questions); saveResult(sessionKey, session, score); return new FinishResponse( score.total(), @@ -110,7 +113,7 @@ public class AihrInterviewService { ensureResultTable(); int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100)); StringBuilder sql = new StringBuilder(""" - SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary + SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary, questions_json FROM aihr_interview_result WHERE tenant_id = '000000' """); @@ -134,7 +137,8 @@ public class AihrInterviewService { rs.getString("position_code"), rs.getInt("total_score"), rs.getString("result"), - rs.getString("ai_summary") + rs.getString("ai_summary"), + parseQuestionRecords(rs.getString("questions_json")) ); } @@ -143,8 +147,8 @@ public class AihrInterviewService { jdbcTemplate.update(""" INSERT INTO aihr_interview_result (tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result, - suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time) - VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now()) + suggestion, ai_summary, evidence, source, answers_json, questions_json, dimensions_json, finished_time, create_time, update_time) + VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now()) ON DUPLICATE KEY UPDATE candidate_id = VALUES(candidate_id), candidate_name = VALUES(candidate_name), @@ -156,6 +160,7 @@ public class AihrInterviewService { evidence = VALUES(evidence), source = VALUES(source), answers_json = VALUES(answers_json), + questions_json = VALUES(questions_json), dimensions_json = VALUES(dimensions_json), finished_time = VALUES(finished_time), update_time = now() @@ -171,6 +176,7 @@ public class AihrInterviewService { score.evidence(), score.source(), json(session.answers()), + json(questionRecords(session)), json(score.dimensions()) ); } @@ -198,6 +204,7 @@ public class AihrInterviewService { `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', `source` varchar(40) DEFAULT NULL COMMENT '评分来源', `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', + `questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON', `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', `finished_time` datetime DEFAULT NULL COMMENT '完成时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', @@ -208,10 +215,35 @@ public class AihrInterviewService { KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果'; """); + try { + jdbcTemplate.execute("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json"); + } catch (DataAccessException error) { + if (!String.valueOf(error.getMessage()).contains("Duplicate column")) { + throw error; + } + } tableReady = true; } } + private List questionRecords(InterviewSession session) { + return session.questions().stream().map(question -> { + String answer = clean(session.answers().get(question.questionId())); + String rating = answer.length() >= 32 ? "优秀" : answer.length() >= 12 ? "良好" : "待提升"; + return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating); + }).toList(); + } + + private List parseQuestionRecords(String value) { + if (isBlank(value)) return List.of(); + try { + return objectMapper.readValue(value, + objectMapper.getTypeFactory().constructCollectionType(List.class, QuestionRecordResponse.class)); + } catch (Exception e) { + return List.of(); + } + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index 8d17797c..c37b86e5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -77,6 +77,10 @@ public class AihrMobileSeedService { return practiceSeedService.reviewDetail(id, supervisorExtPartyId); } + public ReviewDetailResponse practiceHistoryDetail(Long id, String extPartyId) { + return practiceSeedService.historyDetail(id, extPartyId); + } + public boolean markReviewed(Long id) { return markReviewed(id, null); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index d4f9a33c..dbfee1e6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -28,7 +28,10 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; @@ -380,6 +383,79 @@ public class AihrPracticeSeedService { return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim())); } + public List manageRubrics() { + ensureRubricTables(); + return jdbcTemplate.query(""" + SELECT r.rubric_code, r.scenario_code, COALESCE(s.scenario_name, r.scenario_code) AS scenario_name, + r.rubric_name, r.version, r.enabled, r.description + FROM aihr_practice_rubric r + LEFT JOIN aihr_practice_scenario s + ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code + WHERE r.tenant_id = ? + ORDER BY r.scenario_code, r.version DESC, r.id + """, (rs, rowNum) -> new RubricAdminResponse( + rs.getString("rubric_code"), + rs.getString("scenario_code"), + rs.getString("scenario_name"), + rs.getString("rubric_name"), + rs.getString("version"), + rs.getBoolean("enabled"), + rs.getString("description"), + rubricDimensions(rs.getString("rubric_code")) + ), TENANT_ID); + } + + @Transactional(rollbackFor = Exception.class) + public RubricAdminResponse saveRubric(RubricSaveRequest request) { + ensureRubricTables(); + String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), "rubric-" + System.currentTimeMillis())); + String scenarioCode = firstNonBlank(request == null ? null : request.scenarioCode(), "complaint-water"); + String name = firstNonBlank(request == null ? null : request.name(), "生活顾问评分规则"); + String version = firstNonBlank(request == null ? null : request.version(), "v1"); + boolean enabled = request == null || request.enabled() == null || request.enabled(); + Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_rubric + (tenant_id, rubric_code, scenario_code, rubric_name, version, enabled, description, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + scenario_code = VALUES(scenario_code), rubric_name = VALUES(rubric_name), version = VALUES(version), + enabled = VALUES(enabled), description = VALUES(description), update_time = VALUES(update_time) + """, + TENANT_ID, code, scenarioCode, name, version, enabled, + request == null ? null : request.description(), now, now); + jdbcTemplate.update("DELETE FROM aihr_practice_rubric_dimension WHERE tenant_id = ? AND rubric_code = ?", TENANT_ID, code); + List dimensions = request == null || request.dimensions() == null ? List.of() : request.dimensions(); + int sort = 10; + for (RubricDimensionResponse dimension : dimensions) { + if (dimension == null || isBlank(dimension.code()) || isBlank(dimension.name())) { + continue; + } + double weight = Math.max(0, Math.min(1, dimension.weight() == null ? 0 : dimension.weight())); + jdbcTemplate.update(""" + INSERT INTO aihr_practice_rubric_dimension + (tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, TENANT_ID, code, dimension.code().trim(), dimension.name().trim(), weight, + dimension.description(), sort, now, now); + sort += 10; + } + return manageRubrics().stream().filter(item -> code.equals(item.code())).findFirst() + .orElseThrow(() -> new ServiceException("评分规则保存失败")); + } + + public boolean updateRubricEnabled(String code, RubricEnabledRequest request) { + ensureRubricTables(); + if (isBlank(code)) { + return false; + } + return jdbcTemplate.update(""" + UPDATE aihr_practice_rubric SET enabled = ?, update_time = ? + WHERE tenant_id = ? AND rubric_code = ? + """, request == null || request.enabled() == null || request.enabled(), + Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, code.trim()) > 0; + } + public StartResponse start(StartRequest request) { ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null); RoundSeed firstRound = scenario.rounds().get(0); @@ -999,6 +1075,11 @@ public class AihrPracticeSeedService { return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail; } + public ReviewDetailResponse historyDetail(Long id, String extPartyId) { + ReviewDetailResponse detail = reviewDetail(id); + return detail == null || isBlank(extPartyId) || !extPartyId.equals(reviewExtPartyId(id)) ? null : detail; + } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id) { return markReviewed(id, null); @@ -2853,6 +2934,41 @@ public class AihrPracticeSeedService { } } + private void ensureRubricTables() { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) DEFAULT '000000', + `rubric_code` varchar(100) NOT NULL, + `scenario_code` varchar(80) NOT NULL, + `rubric_name` varchar(100) NOT NULL, + `version` varchar(30) DEFAULT 'v1', + `enabled` tinyint DEFAULT 1, + `description` varchar(500) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) DEFAULT '000000', + `rubric_code` varchar(100) NOT NULL, + `dimension_code` varchar(50) NOT NULL, + `dimension_name` varchar(50) NOT NULL, + `weight` decimal(5,2) NOT NULL, + `description` varchar(500) DEFAULT NULL, + `sort_order` int DEFAULT 0, + `create_time` datetime DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + } + private void ensureCalibrationTable() { if (calibrationTableReady) { return; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java index e036959e..af65a50e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java @@ -19,12 +19,15 @@ import java.nio.file.StandardCopyOption; import java.sql.ResultSet; import java.sql.SQLException; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; /** * 批量上传队列:上传请求只做「暂存文件 + 入队 + 秒回」,解析/归类/向量化由本服务的后台 worker @@ -39,6 +42,9 @@ public class AihrUploadQueueService { private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; /** 视频单独放宽:培训视频普遍大于文档;更大的走服务端目录导入。 */ private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024; + private static final long MAX_ARCHIVE_BYTES = 500L * 1024 * 1024; + private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; + private static final int MAX_ARCHIVE_ENTRIES = 1000; private static final int MAX_ERROR_CHARS = 480; /** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */ private static final int WORKER_PERMITS = 2; @@ -76,12 +82,16 @@ public class AihrUploadQueueService { throw new ServiceException("上传文件不能为空"); } String fileName = sanitizeFileName(file.getOriginalFilename()); - if (!AihrSopSeedService.supportedFile(fileName)) { - throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频文件"); + boolean archive = isZip(fileName); + if (!archive && !AihrSopSeedService.supportedFile(fileName)) { + throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件"); } - long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; + long maxBytes = archive ? MAX_ARCHIVE_BYTES + : AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; if (file.getSize() > maxBytes) { - throw new ServiceException(AihrVideoService.videoFile(fileName) + throw new ServiceException(archive + ? "ZIP不能超过 500MB" + : AihrVideoService.videoFile(fileName) ? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入" : "文件不能超过 100MB"); } @@ -135,11 +145,11 @@ public class AihrUploadQueueService { throw new ServiceException("重试条目不存在"); } ItemRow row = jdbcTemplate.query(""" - select id, file_name, category, staging_path, status + select id, batch_id, file_name, category, staging_path, status from aihr_knowledge_upload_item where tenant_id = ? and id = ? """, rs -> rs.next() - ? new ItemRow(rs.getLong("id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) + ? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) : null, TENANT_ID, id); if (row == null) { throw new ServiceException("重试条目不存在"); @@ -190,17 +200,16 @@ public class AihrUploadQueueService { List candidates = jdbcTemplate.query(""" select i.id, i.file_name from aihr_knowledge_upload_item i where i.tenant_id = ? and i.status = 0 + and (? = 0 or i.file_name not regexp '\\\\.(mp4|mov|avi|mkv|webm|m4v)$') and not exists ( select 1 from (select file_name from aihr_knowledge_upload_item where tenant_id = ? and status = 1) p where p.file_name = i.file_name ) order by i.id asc limit 10 - """, (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)), TENANT_ID, TENANT_ID); + """, (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)), + TENANT_ID, videoBusy ? 1 : 0, TENANT_ID); for (QueueCandidate candidate : candidates) { - if (videoBusy && AihrVideoService.videoFile(candidate.fileName())) { - continue; - } int updated = jdbcTemplate.update(""" update aihr_knowledge_upload_item set status = 1, update_time = now() @@ -229,11 +238,11 @@ public class AihrUploadQueueService { private void processItem(Long id) { ItemRow row = jdbcTemplate.query(""" - select id, file_name, category, staging_path, status + select id, batch_id, file_name, category, staging_path, status from aihr_knowledge_upload_item where tenant_id = ? and id = ? """, rs -> rs.next() - ? new ItemRow(rs.getLong("id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) + ? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status")) : null, TENANT_ID, id); if (row == null) { return; @@ -244,6 +253,18 @@ public class AihrUploadQueueService { return; } try { + if (isZip(row.fileName())) { + int extracted = expandArchive(row); + int updated = jdbcTemplate.update(""" + update aihr_knowledge_upload_item + set status = 2, fragment_count = ?, update_time = now() + where tenant_id = ? and id = ? and status = 1 + """, extracted, TENANT_ID, id); + if (updated == 1) { + deleteQuietly(staged); + } + return; + } UploadResponse result = sopSeedService.processStagedDocument(row.fileName(), row.category(), staged); // 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因 String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null; @@ -264,6 +285,82 @@ public class AihrUploadQueueService { } } + private int expandArchive(ItemRow archive) throws IOException { + List files = new ArrayList<>(); + long unpackedBytes = 0; + int entries = 0; + try (var input = Files.newInputStream(Path.of(archive.stagingPath())); + var zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (++entries > MAX_ARCHIVE_ENTRIES) { + throw new IOException("ZIP文件数量不能超过 1000 个"); + } + if (entry.isDirectory()) { + continue; + } + String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/'); + Path entryPath = Path.of(entryName).normalize(); + if (entryName.isBlank() || entryPath.isAbsolute() || entryPath.startsWith("..")) { + throw new IOException("ZIP包含不安全的文件路径"); + } + String fileName = sanitizeFileName(entryPath.getFileName().toString()); + if (isZip(fileName)) { + throw new IOException("不支持嵌套 ZIP 文件"); + } + if (!AihrSopSeedService.supportedFile(fileName)) { + continue; + } + long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES; + Path extracted = stagingPath(fileName); + long entryBytes = 0; + try (var output = Files.newOutputStream(extracted)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = zip.read(buffer)) != -1) { + entryBytes += read; + unpackedBytes += read; + if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) { + throw new IOException("ZIP解压后文件超过大小限制"); + } + output.write(buffer, 0, read); + } + } + files.add(new ExtractedFile(fileName, extracted)); + } + } catch (IOException e) { + files.forEach(file -> deleteQuietly(file.stagedPath())); + throw e; + } + if (files.isEmpty()) { + throw new IOException("ZIP内没有支持的资料文件"); + } + try { + for (ExtractedFile file : files) { + jdbcTemplate.update(""" + insert into aihr_knowledge_upload_item + (tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time) + values (?, ?, ?, ?, ?, 0, now(), now()) + """, TENANT_ID, archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString()); + } + } catch (RuntimeException e) { + for (ExtractedFile file : files) { + jdbcTemplate.update("delete from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?", + TENANT_ID, file.stagedPath().toString()); + deleteQuietly(file.stagedPath()); + } + throw new IOException("ZIP文件入队失败", e); + } + return files.size(); + } + + private static boolean isZip(String fileName) { + return fileName.toLowerCase().endsWith(".zip"); + } + + private record ExtractedFile(String fileName, Path stagedPath) { + } + /** 清扫卡在「处理中」的孤儿条目(重启/异常导致),重置回待处理后再触发一轮加工;顺带回收过期暂存文件。 */ private void sweepAndTrigger() { try { @@ -362,7 +459,9 @@ public class AihrUploadQueueService { } private Path stagingPath(String fileName) { - return stagingRoot.resolve(UUID.randomUUID() + "_" + fileName); + String lower = fileName.toLowerCase(); + String extension = lower.contains(".") ? lower.substring(lower.lastIndexOf('.')) : ""; + return stagingRoot.resolve(UUID.randomUUID() + extension); } /** 去掉路径分隔与控制字符,防止暂存路径逃逸出 stagingRoot。 */ @@ -422,6 +521,6 @@ public class AihrUploadQueueService { } } - private record ItemRow(Long id, String fileName, String category, String stagingPath, int status) { + private record ItemRow(Long id, String batchId, String fileName, String category, String stagingPath, int status) { } } diff --git a/backend/script/sql/aihr_interview_result_mysql8.sql b/backend/script/sql/aihr_interview_result_mysql8.sql index bf226241..9d4cf0d8 100644 --- a/backend/script/sql/aihr_interview_result_mysql8.sql +++ b/backend/script/sql/aihr_interview_result_mysql8.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', `source` varchar(40) DEFAULT NULL COMMENT '评分来源', `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', + `questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON', `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', `finished_time` datetime DEFAULT NULL COMMENT '完成时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 4a5c8a1b..05baeffc 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -12,10 +12,11 @@ | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 | | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed | -| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | +| 资料处理 `/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)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | +| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | | 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | @@ -67,7 +68,7 @@ SOP 文档上传第三片已经落最小后端边界: | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 | | 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 | -| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;后台 worker 并发 2 复用同一解析/归类/向量化链路,同名文件串行防撞唯一键;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 保留给 SOP 页单文件即时预览 | +| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;支持的子文件写回同一 batch 继续复用解析链路;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无 asr 且无 vision 结果时按「待处理」落库不报错;视频同时只加工 1 个 | 模型能力第二片已经落最小后端边界: diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index e844753b..aa0b3a8e 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -121,7 +121,7 @@ AIHR_AI_SPEECH_ENABLED=true - `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` - `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot` - 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围 -- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时 +- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。资料处理中心异步接口另支持 `.zip`(≤500MB),后台安全解压后逐文件入队;最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 diff --git a/docs/FIGMA_DESIGN_INVENTORY.md b/docs/FIGMA_DESIGN_INVENTORY.md index 2d493038..63f616d2 100644 --- a/docs/FIGMA_DESIGN_INVENTORY.md +++ b/docs/FIGMA_DESIGN_INVENTORY.md @@ -13,7 +13,7 @@ ## 已有正式页面 -- [x] 员工端:今日 +- [x] 员工端:今日(Figma `23:3`) - [x] 员工端:练(训练中心入口) - [-] 员工端:问(仅提问态,缺结果与反馈) - [-] 员工端:我(有成长摘要,缺完整画像/荣誉/偏好) @@ -146,7 +146,7 @@ - 一期范围外的复杂多组织流程 - 未完成授权对接前的北森、考勤、请假等伪数据页面 -## 全文件设计一致性审计(2026-07-13) +## 全文件设计一致性审计(2026-07-14) - [x] 建立 Figma 首屏“00|设计索引与审计”(Figma `101:3`) - [x] 79 个页面、71 张正式画板均有内容,无重复页面名和空业务页 @@ -156,4 +156,5 @@ - [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50% - [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐 - [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界 +- [x] 员工端「页面|今日」已完成首页高保真复核:问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出 - [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序 diff --git a/docs/FIGMA_DESIGN_SYSTEM_GUIDE.md b/docs/FIGMA_DESIGN_SYSTEM_GUIDE.md new file mode 100644 index 00000000..fefb5953 --- /dev/null +++ b/docs/FIGMA_DESIGN_SYSTEM_GUIDE.md @@ -0,0 +1,183 @@ +# 银城员工端 Figma 视觉系统使用规范 + +## 目的 + +本文件规定员工端页面如何从通用视觉元素组合页面,再放置具体业务元素。 + +后续 LLM 打开 Figma 时,必须遵循: + +```text +先识别页面结构 +→ 选择通用视觉组件 +→ 填入业务对象和字段 +→ 映射业务状态 +→ 补齐响应式与交互状态 +``` + +不要把业务文案、业务状态或业务指标直接当成视觉组件。 + +## 一、四层模型 + +### 1. 业务元素 + +业务元素是产品要表达的对象、字段和动作,不属于视觉库。 + +员工端「我」页面示例: + +- 用户身份:姓名、头像、工号、岗位、组织 +- 成长数据:成长值、等级、训练次数、完成率 +- 能力数据:能力维度、当前分数、趋势、短板 +- 记录对象:训练记录、复盘记录、收藏内容 +- 激励对象:积分、徽章、荣誉、排行榜名次 +- 用户动作:查看详情、继续训练、查看荣誉、修改偏好 + +### 2. 业务状态 + +业务状态描述业务对象当前情况,也不属于视觉库。 + +示例: + +- 训练:未开始、进行中、已完成、待复盘、已过期 +- 审核:待审核、已通过、已驳回 +- 任务:未完成、已完成、逾期 +- 数据:加载中、暂无数据、加载失败 + +业务状态需要映射到通用组件,而不是创建同名视觉组件。 + +### 3. 通用视觉元素 + +通用视觉元素是可以跨业务复用的组件和布局结构。 + +基础元素: + +- Color、Typography、Spacing、Radius、Shadow、Icon +- Avatar、Divider、Image、Grid + +通用组件: + +- PageHeader +- SectionHeader +- Card +- MetricCard +- ListItem +- Label +- Tag / Badge / StatusBadge +- Progress / Score +- Button / IconButton +- Tabs / BottomTabBar +- Modal / Drawer / Toast +- Loading / EmptyState / ErrorState + +### 4. 页面组合 + +页面组合是业务元素使用通用视觉元素后的结果,不应反向污染视觉库。 + +示例: + +```text +成长摘要卡 = Card + MetricCard + Progress + 业务成长数据 +训练记录项 = ListItem + StatusBadge + Score + Button + 训练记录字段 +个人资料区 = Card + Avatar + Label + 业务身份字段 +荣誉墙 = SectionHeader + Grid + Badge + 业务荣誉数据 +``` + +### 员工端首页并列入口的尺寸规则 + +「问老师傅」和「打字问」是同一问答卡中的并列入口,属于页面组合,不新增业务组件。两侧图片容器统一使用 `64×64`,横向内容行使用垂直居中;图形本身可按图标复杂度保持内部留白,但不能再出现一侧 `80×80`、另一侧 `40×40` 的视觉层级错位。 + +## 二、状态映射规则 + +业务状态只负责提供语义,视觉组件负责提供表现。 + +| 业务语义 | 推荐组件 | 视觉职责 | +|---|---|---| +| 未开始 | StatusBadge / Button | 中性状态、引导操作 | +| 进行中 | StatusBadge / Progress | 强调进行状态和完成比例 | +| 已完成 | StatusBadge / Score | 正向反馈、展示结果 | +| 待复盘 | StatusBadge / Button | 提示下一步动作 | +| 已过期 | StatusBadge | 警示和不可继续操作 | +| 暂无数据 | EmptyState | 解释原因和可执行动作 | +| 加载失败 | ErrorState / Button | 错误说明和重试 | + +文案如“训练未开始”是业务 Label 的内容,不是新的视觉组件。 + +## 三、Figma 命名规则 + +### 组件命名 + +组件用通用结构命名,不用业务文案命名: + +```text +正确:StatusBadge、MetricCard、ListItem +错误:训练未开始标签、成长值卡片、待复盘卡片 +``` + +### 业务实例命名 + +实例可以带业务语义,但必须保留通用组件名: + +```text +StatusBadge / training / not-started +MetricCard / growth-score +ListItem / practice-history +``` + +### 变体命名 + +优先使用结构化属性: + +```text +size = sm | md | lg +tone = neutral | brand | success | warning | danger +state = default | loading | disabled | error +showIcon = true | false +``` + +不要为每个业务状态复制一套组件。 + +## 四、LLM 读取和生成页面的顺序 + +LLM 处理新页面时,按以下顺序工作: + +1. 识别页面目标和用户任务。 +2. 把页面内容拆成业务对象、字段、状态和动作。 +3. 从 Figma 组件库选择页面骨架和通用组件。 +4. 根据布局规则组合组件。 +5. 将业务字段填入组件实例。 +6. 将业务状态映射到 `tone`、`state`、`progress` 或操作按钮。 +7. 检查加载、空、错误、禁用和完成状态。 +8. 最后才调整页面级视觉,不新增业务专用组件。 + +## 五、「我」页面的推荐骨架 + +```text +Page +├── PageHeader +│ └── 业务元素:页面标题、设置入口 +├── ProfileCard +│ └── Avatar + 业务身份字段 +├── GrowthSummary +│ └── MetricCard + Progress + 业务成长数据 +├── CapabilitySection +│ └── SectionHeader + Score / Progress + 业务能力数据 +├── PracticeHistory +│ └── SectionHeader + ListItem + StatusBadge + 训练记录 +├── HonorSection +│ └── SectionHeader + Badge / Grid + 荣誉数据 +└── BottomTabBar + └── 通用导航结构:今日 / 练 / 问 / 我 +``` + +这里的 `ProfileCard`、`GrowthSummary` 等是页面组合名称;真正进入通用视觉库的仍是 `Card`、`MetricCard`、`Progress`、`ListItem`、`Badge` 等组件。 + +## 六、设计检查清单 + +- 是否能把业务文案替换成另一种业务,而不重画组件? +- 组件名称是否描述结构,而不是描述某个业务? +- 业务状态是否通过 `tone`、`state` 或内容字段表达? +- 是否复用了已有 Token 和组件? +- 是否覆盖加载、空、错误、禁用和完成状态? +- 页面是否由清晰的组件组合构成? +- 是否能让 LLM 根据名称判断组件用途和边界? + +判断标准:如果一个元素离开当前业务仍然有意义,它才适合进入通用视觉库。 diff --git a/docs/FIGMA_IMPLEMENTATION_MATRIX.md b/docs/FIGMA_IMPLEMENTATION_MATRIX.md new file mode 100644 index 00000000..9514bc40 --- /dev/null +++ b/docs/FIGMA_IMPLEMENTATION_MATRIX.md @@ -0,0 +1,134 @@ +# Figma 页面实现矩阵 + +更新时间:2026-07-13 + +## 统计口径 + +- Figma 当前索引:78 个页面、70 张正式画板。 +- `docs/FIGMA_DESIGN_INVENTORY.md` 的历史清单记录为 79 个页面、71 张正式画板,属于设计索引更新前的数字。 +- Figma 文件 API 当前实际暴露 1 个顶层页面 `101:2`(设计索引与审计);“78 个页面”是索引中的审计统计值,不是 78 个可单独定位的 Figma 顶层页面。 +- 因此不能凭空补出 8 个页面名称或 node id;本表按可定位、可验收的 70 张正式业务画板建立。 +- `[x]` 只表示 Figma 画板已完成,不表示代码或线上已完成。 +- `已实现`:仓库存在对应页面,并有真实业务接口或明确的降级链。 +- `部分实现`:有入口或局部状态,但没有完成 Figma 对应的完整业务闭环。 +- `未实现`:没有对应业务页面,或只有建设中占位。 +- `线上待修正`:本地代码已有,但线上包、角色路由或视觉仍未达到当前版本。 +- “Figma 位置”使用设计清单中的 node id;未标 node id 的项目不是正式画板。 + +## 与《AI陪练二期开发推进计划》对账 + +这两份文档不是同一张验收表: + +- 本矩阵回答“Figma 设计的页面在代码/线上完成到什么程度”,覆盖 MVP、二期、阶段二/三和已取消设计。 +- 二期计划回答“生活顾问 AI 陪练阶段一的工程闭环和 M5 试点条件是否满足”,不要求实现全部 70 张 Figma 正式画板。 +- 因此,矩阵中的“未实现”不等于二期延期;个人 AI 助理、开放问题榜、积分/荣誉、完整偏好体系等属于计划明确暂缓的阶段二/三范围。 +- 矩阵中的“已实现”也不等于 M5 业务验收通过;评分校准、SOP 评审、正式组织身份和真实试点数据仍以严格预检为准。 + +与二期计划的主要对应关系: + +| 计划范围 | 矩阵行 | 对账结论 | +|---|---:|---| +| M1 场景、Rubric、提示词模板 | 5、23、24、25 | 工程能力已实现;场景/Rubric保留轻量入口,未做计划明确不要求的复杂 CRUD。提示词模板已挂在模型配置页,按计划完成;按 Figma 独立画板仍是部分对齐。 | +| M2 预习卡、情绪/红线、求助、知识缺口 | 5、6、7、27、28 | 代码闭环已实现;问答反馈和完整视觉状态仍是 Figma 对齐缺口。开放问题榜不属于本期。 | +| M3 录音、回放、逐句标注 | 8、11、27 | 工程能力已实现;线上主管角色路由和真实音频/线上包仍需验证。 | +| M4 画像、预警、派发、复盘建议、校准 | 9—14、26、46—47、50、61—62 | 主链路已实现;完整 Figma 看板、晋升和游戏化页面不属于本期必做。 | +| M5 试点收口 | 26 | 只有工程闸门和导出能力已实现;正式业务是否通过不能由本矩阵判定,必须看真实组织数据和人工评审样本。 | + +结论:本矩阵与二期计划**功能边界基本一致,但原表缺少计划对账层**;本节已补上,后续不再把 Figma 全量完成度当成二期完成度。 + +## 当前剩余动作 + +| 优先级 | 项目 | 需要什么才能继续 | +|---|---|---| +| P0 | 员工今日线上视觉 | 发布当前 `mobile-uni` 静态包并做线上浏览器回归;本地源码不再继续堆视觉代码。 | +| P1 | 案例学习/视频详情 | 先补真实案例详情模型:`caseId`、媒体 URL、学习点、主管点评、相关训练场景;字段和 API 确定后再实现页面。 | +| P1 | 正式组织数据 | 用正式员工、项目和试点时间窗口验证团队量化、成长证据和 M5 导出;不使用本地 smoke 样本替代。 | +| P2 | 三期页面 | 个人 AI 助理、开放问题榜、积分/荣誉、完整七维偏好、独立 AI 教练编排按阶段二/三单独立项。 | + + +## 70 张正式业务画板矩阵 + +| # | 页面/状态 | Figma 位置 | 代码路径 | 当前实现 | 是否修正 | +|---:|---|---|---|---|---| +| 1 | 员工端:今日 | — | `mobile-uni/src/pages/user/today/index.vue` | [x] 已实现 | 本地源码已对齐;线上待发布,线上仍是旧视觉 | +| 2 | 员工端:练入口 | — | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证场景首屏、最近练过、每日三题和独立练习入口;蓝白视觉已复核 | +| 3 | 员工端:问 | — | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已验证搜索、引用、反馈状态和总结卡 | +| 4 | 员工端:我 | — | `mobile-uni/src/pages/user/profile/index.vue` | [x] 已实现 | 已验证登录、岗位、成长摘要、训练历史和详情入口 | +| 5 | 训练前预习卡 | `53:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证预习卡、开始练习与真实对话状态;视觉已复核 | +| 6 | 正式语音对练 | `54:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证文字提交、情绪/信任、求助和录音入口;失败保留文本降级 | +| 7 | 训练完成结果 | `55:2` | `mobile-uni/src/pages/user/practice/index.vue` | [x] 已实现 | 已验证两轮真实提交、评分结果和主管待复盘更新;视觉已复核 | +| 8 | 训练历史详情 | `56:2` | `mobile-uni/src/pages/user/history/index.vue` | [x] 已实现 | 已验证员工归属、评分明细、对话、复盘建议和刷新返回 | +| 9 | 主管今日工作台 | `57:2` | `mobile-uni/src/pages/supervisor/index/index.vue` | [x] 已实现 | 已用主管账号验证团队概览、任务入口和预警;蓝白视觉已复核 | +| 10 | 主管待复盘列表 | `58:2` | `mobile-uni/src/pages/supervisor/reviews/index.vue` | [x] 已实现 | 已验证真实待复盘列表、低分优先和详情跳转 | +| 11 | 主管复盘详情 | `59:2` | `mobile-uni/src/pages/supervisor/review/index.vue` | [x] 已实现 | 已验证评分明细、对话、标注、建议、贡献分和标记已复盘 | +| 12 | 员工能力详情 | `61:2` | `mobile-uni/src/pages/supervisor/team/index.vue` | [x] 已实现 | 已验证点击成员展示最新评分、训练次数、待复盘数量、真实训练趋势和成长证据;400px 主管画布、蓝白卡片、层级和间距已复核;未虚构 Figma 未提供的数据 | +| 13 | 训练任务派发 | `61:530` | `mobile-uni/src/pages/supervisor/assign/index.vue` | [x] 已实现 | 已验证按项目范围选择员工、场景、原因并真实创建专项任务 | +| 14 | 派发进度与预警 | `62:2` | `mobile-uni/src/pages/supervisor/assign/index.vue` | [x] 已实现 | 已验证真实派发进度计数(待训练/已完成/低分再练)和低分训练预警;400px 主管画布、表单/列表/进度卡层级已复核;截止时间与下滑趋势暂无后端字段,未伪造 | +| 15 | 候选人面试首页 | `67:2` | `mobile-uni/src/pages/candidate/index/index.vue` | [x] 已实现 | 已修正 | +| 16 | 候选人面试答题 | `68:2` | `mobile-uni/src/pages/candidate/interview/index.vue` | [x] 已实现 | 已修正 | +| 17 | 候选人面试完成评分 | `69:26` | `mobile-uni/src/pages/candidate/interview/index.vue` | [x] 已实现 | 已修正 | +| 18 | 候选人历史/人工复核 | `69:559` | `mobile-uni/src/pages/candidate/progress/index.vue` | [x] 已实现 | 已对齐面试记录、评分摘要、真实问答快照、人工复核状态和返回候选人中心;浏览器已验证真实面试提交后可查看 3 条问答记录 | +| 19 | 候选人补充资料上传 | `69:599` | `mobile-uni/src/pages/candidate/materials/index.vue` | [x] 已实现 | 已修正 | +| 20 | 候选人资料审核状态 | `69:629` | `mobile-uni/src/pages/candidate/progress/index.vue` | [x] 已实现 | 已修正 | +| 21 | 管理端 AI 面试列表 | `70:2` | `frontend/src/views/recruit/interview.vue` | [x] 已实现 | 已修正 | +| 22 | 管理端 AI 面试详情复核 | `71:2` | `frontend/src/views/recruit/interview.vue` | [x] 已实现 | 已修正 | +| 23 | 场景库列表/编辑/启停 | `71:628` | `frontend/src/views/train/scenarios.vue` | [x] 已实现 | 已按 Figma 独立工作台实现筛选、分页、列表、Rubric/状态展示、编辑、新增和启停;真实浏览器已验证编辑保存及停用/启用恢复 | +| 24 | Rubric 维度/版本管理 | `72:2` | `frontend/src/views/train/rubrics.vue` | [x] 已实现 | 已按 Figma 实现版本列表、维度权重编辑、保存发布、复制版本与启停;真实浏览器已验证权重保存、复制版本落库及启用/停用恢复 | +| 25 | 提示词模板管理 | `73:2` | `frontend/src/views/system/prompts.vue` | [x] 已实现 | 已按 Figma 独立工作台实现模板列表、名称/代码/输出文体/系统提示词/模板/变量编辑、保存草稿、保存并启用;真实浏览器已验证编辑保存、新增启用、刷新持久化,临时样本已清理 | +| 26 | 人工评分校准/指标导出 | `74:2` | `frontend/src/views/train/reviews.vue`、`frontend/src/views/train/practice.vue` | [x] 已实现 | 已按 Figma 补齐日期窗口、岗位/组织范围、校准样本指标卡和独立 CSV 导出;真实浏览器已验证页面加载、导出下载和复盘校准表单,正式试点数据仍需真实样本 | +| 27 | SOP 回答结果 | `79:11` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已按 Figma 补齐「师傅回答/我的问题/答案卡/引用依据/反馈」层级,保留真实检索、引用和反馈接口;浏览器已验证登录、真实答案展示和“有用”反馈状态 | +| 28 | SOP 好评/差评反馈 | `79:674` | `mobile-uni/src/pages/user/sop/index.vue` | [x] 已实现 | 已按 Figma 语义对齐为「有帮助/没解决」双按钮,保留真实反馈接口、提交后状态提示与按钮锁定;浏览器已验证真实搜索、点击反馈、结果提示和两个按钮 disabled。Figma 中的「收藏」当前无后端持久化接口,未添加假按钮 | +| 29 | SOP 后台待复核 | `80:655` | `frontend/src/views/knowledge/sop.vue` | [x] 已实现 | 已补真实答案反馈待复核队列、待复核/已复核筛选、状态展示与「标记已复核」动作;浏览器已验证刷新加载临时待复核样本、点击复核后从待办移除并清理测试数据 | +| 30 | SOP 知识库管理 | `81:2` | `frontend/src/views/knowledge/sop.vue` | [x] 已实现 | 已修正 | +| 31 | 异步资料处理队列 | `81:750` | `frontend/src/views/knowledge/processing.vue` | [x] 已实现 | 已修正;ZIP 支持已写本地但未发布 | +| 32 | 案例语音上传 | `79:706` | `mobile-uni/src/pages/user/cases/index.vue`、`frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | +| 33 | 案例 AI 整理结果 | `79:734` | `frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | +| 34 | 案例审核/筛选/发布 | `82:2` | `frontend/src/views/knowledge/cases.vue` | [x] 已实现 | 已修正 | +| 35 | 案例学习/视频详情 | `80:2` | `mobile-uni/src/pages/user/learning/index.vue`、`cases/index.vue` | 部分实现 | 当前只有案例上传/整理/入库和学习入口;后端没有稳定的案例详情、媒体 URL、学习点/主管点评字段,暂不造视频详情页 | +| 36 | 个人资料库首页 | `86:3` | — | 未实现 | 未修正,阶段二 | +| 37 | 个人资料列表/筛选搜索 | `86:58` | — | 未实现 | 未修正,阶段二 | +| 38 | 收藏资料 | `86:846` | — | 未实现 | 未修正,阶段二 | +| 39 | 文件解析进度/失败重试 | `86:884` | — | 未实现 | 未修正,阶段二 | +| 40 | 资料详情/来源/删除/导出 | `86:950` | — | 未实现 | 未修正,阶段二 | +| 41 | 个人/企业/混合知识范围 | `86:997` | — | 未实现 | 未修正,阶段二 | +| 42 | 个人知识带引用回答 | `87:2` | — | 未实现 | 未修正,阶段二 | +| 43 | 工作思路整理输入 | `87:770` | — | 未实现 | 未修正,阶段二 | +| 44 | 结论/行动项结果 | `87:815` | — | 未实现 | 未修正,阶段二 | +| 45 | 摘要/可编辑文字生成 | `87:865` | — | 未实现 | 未修正,阶段二 | +| 46 | 员工工作量化看板 | `89:9` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有训练时长、贡献度、测评分、等级、证据包、预警和派发;完整三期量化指标暂不补 | +| 47 | 主管团队量化看板 | `89:776` | `mobile-uni/src/pages/supervisor/team/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有团队成员、风险、训练记录和成长证据;完整团队经营指标需正式组织数据 | +| 48 | HR/运营绩效看板 | `90:2` | — | 未实现 | 未修正,三期 | +| 49 | 工作积分排行榜 | `90:91` | — | 未实现 | 未修正,三期 | +| 50 | 等级/晋升条件/进度 | `90:867` | `frontend/src/views/competency/growth.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已有初/中/高级成长路径和评级证据包;正式晋升申请/审核属于后续范围 | +| 51 | 破格晋升申请/审核 | `91:2` | — | 未实现 | 未修正,三期 | +| 52 | 荣誉墙/成长档案导出 | `91:765` | — | 未实现 | 未修正,三期 | +| 53 | 七维回复风格调节 | `93:6` | `mobile-uni/src/pages/user/practice/index.vue` | 按二期计划已实现首期形态;按 Figma 部分对齐 | 已有训练内“再严肃/更温柔/更细致/更专业”重生成;偏好持久化和七维设置属于后续范围 | +| 54 | 优化前后结果对比 | `93:824` | — | 未实现 | 未修正,三期 | +| 55 | 个人 AI 偏好设置 | `93:857` | — | 未实现 | 未修正,三期 | +| 56 | 管理端团队偏好分布 | `93:940` | — | 未实现 | 未修正,三期 | +| 57 | 开放问题榜首页 | `95:6` | — | 未实现 | 未修正,仅有 `knowledge_gap` 记录 | +| 58 | 问题详情/全员回答 | `95:782` | — | 未实现 | 未修正,阶段二 | +| 59 | 最佳答案/奖励结果 | `95:826` | — | 未实现 | 未修正,阶段二 | +| 60 | 问题审核/激励规则 | `95:858` | — | 未实现 | 未修正,阶段二 | +| 61 | AI 教练训练计划 | `97:5` | `mobile-uni/src/pages/supervisor/assign/index.vue` | 按二期计划已实现;按 Figma 部分对齐 | 已用主管派发、低分再练、每日三题和成长证据承载;独立 AI 教练编排属于后续范围 | +| 62 | 自动场景任务/自动复盘 | `97:778` | `mobile-uni/src/pages/user/practice/index.vue`、`supervisor/review/index.vue` | 按二期计划已实现基础闭环;按 Figma 部分对齐 | 已有训练、评分、标注、复盘和再练;自动编排/自动复盘报告不属于当前二期必做 | +| 63 | 积分/徽章/荣誉中心 | `97:823` | — | 未实现 | 未修正,三期 | +| 64 | 企业发布申请 | — | — | 未实现 | 未修正,阶段二 | +| 65 | 个人 AI 助理发布记录 | — | — | 未实现 | 未修正,阶段二 | +| 66 | 个人 AI 助理报告大纲 | — | — | 未实现 | 未修正,阶段二 | +| 67 | 个人 AI 助理 PPT 生成 | — | — | 未实现 | 未修正,阶段二 | +| 68 | 企业知识发布审核列表 | — | — | 未实现 | 未修正,阶段二 | +| 69 | 企业知识发布审核详情 | — | — | 未实现 | 未修正,阶段二 | +| 70 | 个人 AI 助理运营/紧急审计 | — | — | 未实现 | 未修正,阶段二 | + +## 已取消而不应补做 + +以下 Figma 清单中的“师徒关系”项目已经被业务会议否决,不应作为缺口继续开发:申请拜师、师傅池、固定师徒关系、互动计划、师傅认证、带徒福利等。当前替代方向是主管复盘闭环和后续开放问题榜。 + +## 当前优先级 + +1. **P0:主管角色路由**。本地页面存在,但线上主管账号仍可能进入员工首页。 +2. **P0:线上包追平当前源码**。员工首页、训练页和登录页存在明显视觉版本滞后。 +3. **P1:按本矩阵完成阶段一验收**。重点是员工训练、主管复盘、候选人面试、知识库和案例闭环。 +4. **P2:阶段二再做个人 AI 助理和开放问题榜**,先补 API、权限和数据模型,再做页面。 +5. **P3:量化、偏好、积分和荣誉**,等待稳定训练数据与人工校准后再实现。 diff --git a/docs/FIGMA_ONLINE_DELTA.md b/docs/FIGMA_ONLINE_DELTA.md new file mode 100644 index 00000000..a73534b2 --- /dev/null +++ b/docs/FIGMA_ONLINE_DELTA.md @@ -0,0 +1,34 @@ +# Figma 与线上 H5 差异矩阵 + +> 2026-07-13 基于线上 `https://peilian.njzhmj.top/h5/` 实测截图、现有 `mobile-uni` 实现与 Figma 文件 [银城员工端与个人 AI 助理设计](https://www.figma.com/design/30hFiKtieGYraOZvJPJcPd) 整理。 + +## 使用口径 + +- 线上版本是行为、接口、路由和真实数据状态的基线。 +- Figma 是视觉、信息层级和完整状态覆盖的目标基线。 +- 不以静态设计替代未实现的后端能力;所有新增状态必须对应现有接口或明确的建设中状态。 +- 本批先收敛登录页与员工训练主链路,主管端角色路由单独作为阻塞项处理。 + +## 差异矩阵 + +| 页面/流程 | 线上实测 | Figma 目标 | 当前判断 | 调整动作 | 验收口径 | +| --- | --- | --- | --- | --- | --- | +| 登录页 | 浅蓝灰背景、青绿色主按钮、卡片下方留白较大 | 银城红主色、紧凑表单、明确验证码行和状态反馈 | 视觉 token 未统一 | 主色统一为 `#E60012`;收紧卡片和纵向留白;保留手机号、验证码、发送中/失败/成功状态 | 390×844 无横向滚动;输入、验证码和登录按钮均可操作;错误文案不跳布局 | +| 员工今日首页 | 首屏突出问答入口,今日安排和地图在下方;线上包体仍是蓝/绿/橙多色旧视觉 | 师傅区 → 今日安排 → 我的地图 → 成长摘要;红白轻卡片体系 | 行为已可用,线上发布包落后于当前源码/Figma | 保留真实问答和任务接口;以当前源码的员工端 token 为准,逐步替换线上旧包 | “开始训练”进入独立训练页,不在首页插入训练面板 | +| 员工训练入口 | 首页任务按钮已有 `switchTab('/pages/user/practice/index')` | 训练中心首屏为场景模拟、开始练习、最近练过,每日三题后置 | 路由方向正确,首屏优先级需守住 | 保持独立训练页;场景卡和开始练习固定在首屏,最近练过紧随其后,每日三题后置 | 点击开始练习后进入 active 对话区;提交/结束/结果链路不回归 | +| 训练中 | 真实对练、录音/文件兜底、SOP 求助、结束评分均已存在 | 角色对话、教练提示、结果卡分层展示 | 行为完整,视觉层级需统一 | 只调整卡片层级、按钮状态和提示区域,不改 `/start`、`/turn`、`/finish` 契约 | 开始、提交回应、求助、结束评分、失败回滚均可验证 | +| 主管端 | 本次以主管手机号登录后仍落到员工端,未能进入主管首页 | 已有主管工作台、待复盘、复盘详情、能力与派发页面 | 角色/岗位路由是阻塞项 | 先排查线上岗位识别与路由分流,再进行主管视觉迁移 | 主管账号可稳定进入主管首页;普通员工仍被拒绝主管接口 | +| 个人资料库/问题榜 | 线上当前入口未能从员工首页验证 | Figma 已有资料库、引用回答、问题榜和激励状态 | 属于后续建设目标,不伪造上线 | 等对应 API/入口具备后再实现;当前保留建设中或既有真实入口 | 有真实接口和权限边界后再纳入线上验收 | + +## 本批实施边界 + +1. 登录页只改视觉 token、间距和状态表现,不改短信登录接口、认证存储和原路跳转。 +2. 员工训练页只强化入口和状态层级,不重写训练状态机,不新增后端字段。 +3. 主管端不通过前端硬编码角色解决;先以真实手机号、岗位快照和路由结果取证。 +4. Figma 的 `LXGW WenKai TC` 与线上系统字体存在实现差异。本批代码继续使用现有系统字体栈,避免未加载字体造成线上回退;如要全面采用 Figma 字体,另开字体资源与性能评估。 + +## 当前验收证据 + +- 线上登录页和员工首页截图:`/Users/yuanjiantsui/.codex/visualizations/2026/07/13/019f5984-0790-7c01-91f2-e93cca023ae0/` +- Figma 员工训练前、主管工作台、个人资料库、问题榜截图:同一目录下的 `figma-*.png` +- 设计资产总清单:`docs/FIGMA_DESIGN_INVENTORY.md` diff --git a/docs/README.md b/docs/README.md index a5ec4b50..8f918cd2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,8 @@ |---|---| | [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 | | [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 | +| [FIGMA_DESIGN_SYSTEM_GUIDE.md](FIGMA_DESIGN_SYSTEM_GUIDE.md) | 员工端通用视觉元素、业务元素、状态映射和 LLM 组页规则 | +| [FIGMA_ONLINE_DELTA.md](FIGMA_ONLINE_DELTA.md) | Figma 目标与线上 H5 实测差异矩阵、当前实施边界和验收口径 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 | | [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index a8fb59cb..57f7792c 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -42,6 +42,46 @@ export type PracticeScenarioEnabledRequest = { enabled: boolean; }; +export type PracticeScenarioSaveRequest = { + id?: string; + name: string; + position: string; + scenarioType: string; + projectType: string; + difficulty: number; + customerPersona: string; + openingText: string; + explicitNeed: string; + hiddenNeed: string; + irritants: string; + delights: string; + sopRefs: string; + successCriteria: string; + strategy: string; + goal: string; + enabled: boolean; +}; + +export type PracticeRubricDimension = { + code: string; + name: string; + weight: number; + description: string; +}; + +export type PracticeRubricAdmin = { + code: string; + scenarioCode: string; + scenarioName: string; + name: string; + version: string; + enabled: boolean; + description: string; + dimensions: PracticeRubricDimension[]; +}; + +export type PracticeRubricSaveRequest = Omit; + export type PracticePrepCardResponse = { keyPoints: string[]; redLines: string[]; @@ -174,6 +214,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario }); } +export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise> { + return request({ + url: '/api/train/practice/scenarios', + method: 'post', + data + }); +} + +export function listPracticeRubrics(): Promise> { + return request({ url: '/api/train/practice/rubrics/manage', method: 'get' }); +} + +export function savePracticeRubric(data: PracticeRubricSaveRequest): Promise> { + return request({ url: '/api/train/practice/rubrics/manage', method: 'post', data }); +} + +export function updatePracticeRubricEnabled(code: string, enabled: boolean): Promise> { + return request({ + url: `/api/train/practice/rubrics/${encodeURIComponent(code)}/enabled`, + method: 'patch', + data: { enabled } + }); +} + export function getPracticePrepCard(id: string): Promise> { return request({ url: `/api/train/practice/scenarios/${id}/prep-card`, diff --git a/frontend/src/api/aihr/sop.ts b/frontend/src/api/aihr/sop.ts index 58df7261..944f20eb 100644 --- a/frontend/src/api/aihr/sop.ts +++ b/frontend/src/api/aihr/sop.ts @@ -48,6 +48,25 @@ export type SopReviewResponse = { reviewedAt: string; }; +export type AnswerFeedbackItem = { + id: number; + queryText: string; + queryNorm: string; + fragmentId: number; + verdict: string; + category: string; + position: string; + source: string; + reviewStatus: string; + createTime: string; + updateTime: string; +}; + +export type AnswerFeedbackReviewResponse = { + id: number; + reviewStatus: string; +}; + export type SopUploadResponse = { docId: string; ossId: number; @@ -87,6 +106,23 @@ export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer }); } +export function listAnswerFeedback(status = '待复核', limit = 50): Promise> { + return request({ + url: '/api/knowledge/answer-feedback', + method: 'get', + params: { status, limit }, + headers: { repeatSubmit: false } + }); +} + +export function reviewAnswerFeedback(id: number): Promise> { + return request({ + url: `/api/knowledge/answer-feedback/${id}/review`, + method: 'post', + headers: { repeatSubmit: false } + }); +} + export type UploadEnqueueResponse = { itemId: number; batchId: string; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 49789bd3..3b9eec5b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -101,6 +101,18 @@ export const constantRoutes: RouteRecordRaw[] = [ name: 'TrainPractice', meta: { title: '三角色对练', icon: 'peoples' } }, + { + path: 'scenarios', + component: () => import('@/views/train/scenarios.vue'), + name: 'TrainScenarios', + meta: { title: '场景库', icon: 'list' } + }, + { + path: 'rubrics', + component: () => import('@/views/train/rubrics.vue'), + name: 'TrainRubrics', + meta: { title: 'Rubric 规则', icon: 'list' } + }, { path: 'reviews', component: () => import('@/views/train/reviews.vue'), @@ -157,6 +169,12 @@ export const constantRoutes: RouteRecordRaw[] = [ component: () => import('@/views/system/model/index.vue'), name: 'SystemModelConfig', meta: { title: '模型配置', icon: 'model' } + }, + { + path: 'prompts', + component: () => import('@/views/system/prompts.vue'), + name: 'SystemPromptTemplates', + meta: { title: '提示词模板', icon: 'edit' } } ] }, diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index 003586ad..43680040 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -283,12 +283,14 @@ import { } from '@/api/aihr/processing'; import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop'; -const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v'; +const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip'; const autoCategory = '__auto__'; -const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v)$/i; +const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i; const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i; +const archivePattern = /\.zip$/i; const maxFileSize = 100 * 1024 * 1024; const maxVideoSize = 500 * 1024 * 1024; +const maxArchiveSize = 500 * 1024 * 1024; const browserBatchWarnCount = 20; const browserBatchWarnSize = 200 * 1024 * 1024; @@ -484,14 +486,17 @@ async function handlePickedFiles(event: Event) { async function uploadFiles(files: File[]) { const readyFiles = files.filter( - (file) => supportedPattern.test(file.name) && file.size > 0 && file.size <= (videoPattern.test(file.name) ? maxVideoSize : maxFileSize) + (file) => + supportedPattern.test(file.name) && + file.size > 0 && + file.size <= (videoPattern.test(file.name) ? maxVideoSize : archivePattern.test(file.name) ? maxArchiveSize : maxFileSize) ); if (readyFiles.length === 0) { - ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)或视频(500MB内)文件'); + ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)、视频或 ZIP(500MB内)文件'); return; } if (readyFiles.length < files.length) { - ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频 500MB)`); + ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频/ZIP 500MB)`); } const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0); diff --git a/frontend/src/views/knowledge/sop.vue b/frontend/src/views/knowledge/sop.vue index 42b115c0..742dfc41 100644 --- a/frontend/src/views/knowledge/sop.vue +++ b/frontend/src/views/knowledge/sop.vue @@ -89,6 +89,50 @@ +
+
+
+ +
+

答案待复核

+

员工反馈为「没解决」的答案会进入这里,复核后从待办队列移除。

+
+
+
+ + + + + 刷新 +
+
+ + + + + + + + + + + + +
+
@@ -110,9 +154,18 @@ import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue'; import { ElMessage } from 'element-plus'; import type { UploadFile } from 'element-plus'; -import { computed, ref } from 'vue'; +import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; -import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop'; +import { + listAnswerFeedback, + reviewAnswerFeedback, + reviewKnowledgeSop, + searchKnowledgeSop, + uploadKnowledgeDoc, + type AnswerFeedbackItem, + type SopRecord, + type SopSearchResponse +} from '@/api/aihr/sop'; const router = useRouter(); @@ -212,6 +265,10 @@ const uploading = ref(false); const uploadedRecords = ref([]); const reviewSaving = ref(false); const reviewStatus = ref(''); +const pendingReviewStatus = ref('待复核'); +const pendingReviewLoading = ref(false); +const reviewingFeedbackId = ref(null); +const pendingReviews = ref([]); const activeSeed = computed(() => sopSeeds[selectedCategory.value]); const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer); @@ -318,6 +375,32 @@ async function markReview(usable: boolean) { } } +async function loadPendingReviews() { + pendingReviewLoading.value = true; + try { + const { data } = await listAnswerFeedback(pendingReviewStatus.value); + pendingReviews.value = data ?? []; + } catch { + pendingReviews.value = []; + ElMessage.error('答案待复核列表加载失败'); + } finally { + pendingReviewLoading.value = false; + } +} + +async function markFeedbackReviewed(row: AnswerFeedbackItem) { + reviewingFeedbackId.value = row.id; + try { + await reviewAnswerFeedback(row.id); + pendingReviews.value = pendingReviews.value.filter((item) => item.id !== row.id); + ElMessage.success('答案已标记为已复核'); + } catch { + ElMessage.error('答案复核失败'); + } finally { + reviewingFeedbackId.value = null; + } +} + async function handleDocSelected(uploadFile: UploadFile) { const raw = uploadFile.raw; if (!raw) return; @@ -359,6 +442,10 @@ async function generateTraining() { trainingGenerated.value = true; ElMessage.success('已基于引用片段生成训练题'); } + +onMounted(() => { + loadPendingReviews(); +}); diff --git a/frontend/src/views/train/practice.vue b/frontend/src/views/train/practice.vue index 10e2635d..817f112d 100644 --- a/frontend/src/views/train/practice.vue +++ b/frontend/src/views/train/practice.vue @@ -344,6 +344,18 @@ type ScenarioSeed = { id: string; name: string; position?: string; + scenarioType?: string; + projectType?: string; + difficulty?: number; + customerPersona?: string; + openingText?: string; + explicitNeed?: string; + hiddenNeed?: string; + irritants?: string; + delights?: string; + sopRefs?: string; + successCriteria?: string; + enabled?: boolean; trainee: string; customer: string; project: string; @@ -489,7 +501,6 @@ const defaultPromptForm = (): PromptTemplateForm => ({ description: '' }); const promptForm = ref(defaultPromptForm()); - const selectedScenario = computed( () => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0] ); @@ -563,6 +574,18 @@ const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({ id: item.id, name: item.name, position: item.position, + scenarioType: item.scenarioType, + projectType: item.projectType, + difficulty: item.difficulty, + customerPersona: item.customerPersona, + openingText: item.openingText, + explicitNeed: item.explicitNeed, + hiddenNeed: item.hiddenNeed, + irritants: item.irritants, + delights: item.delights, + sopRefs: item.sopRefs, + successCriteria: item.successCriteria, + enabled: item.enabled, trainee: '张三', customer: item.customerPersona, project: item.projectType || item.scenarioType, diff --git a/frontend/src/views/train/reviews.vue b/frontend/src/views/train/reviews.vue index ce9ece57..be491453 100644 --- a/frontend/src/views/train/reviews.vue +++ b/frontend/src/views/train/reviews.vue @@ -6,10 +6,37 @@

集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。

+ 导出试点 CSV 刷新
+
+ + + + + + + + +
+ +
+
+ 校准样本{{ metrics.calibrationCount }} / 20 +
+
+ 分档一致率{{ metrics.consistency }} +
+
+ SOP 可用率{{ metrics.sopUsable }} +
+
+ 严格门禁{{ metrics.ready ? '通过' : '待补样本' }} +
+
+
@@ -17,9 +44,6 @@ {{ visibleReviews.length }} 条
- - - ([]); const orgSnapshot = ref(null); const selectedProjectCode = ref(''); const selectedExtPartyId = ref(''); +const selectedPosition = ref('生活顾问'); +const exporting = ref(false); +const startDate = ref('2026-07-01'); +const endDate = ref('2026-07-31'); +const metrics = reactive({ calibrationCount: 0, consistency: '--', sopUsable: '--', ready: false }); const audioSources = ref>({}); const audioLoading = ref>({}); const audioErrors = ref>({}); @@ -228,6 +257,41 @@ const loadReviews = async () => { } }; +const loadMetrics = async () => { + try { + const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value }); + const csv = await blob.text(); + const value = (key: string) => csv.match(new RegExp(`(?:^|\\n)"?${key}"?,"?([^"\\n]*)`))?.[1] || ''; + metrics.calibrationCount = Number(value('calibration_sample_count')) || 0; + metrics.consistency = value('ai_human_band_consistency_rate') || '--'; + metrics.sopUsable = value('sop_answer_usable_rate') || '--'; + metrics.ready = value('pilot_metric_ready') === 'true'; + } catch { + metrics.calibrationCount = reviews.value.length; + metrics.consistency = '--'; + metrics.sopUsable = '--'; + metrics.ready = false; + } +}; + +const exportPilot = async () => { + exporting.value = true; + try { + const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `aihr-pilot-${startDate.value}-${endDate.value}.csv`; + link.click(); + URL.revokeObjectURL(url); + ElMessage.success('试点 CSV 已导出'); + } catch { + ElMessage.error('试点 CSV 导出失败'); + } finally { + exporting.value = false; + } +}; + const selectReview = async (row: ReviewRecord) => { releaseAudioSources(); const result = await getPracticeReview(row.id); @@ -290,10 +354,15 @@ watch(selectedExtPartyId, () => { loadReviews(); }); +watch([startDate, endDate], () => { + loadMetrics(); +}); + onMounted(() => { loadScenarios(); loadOrgSnapshot(); loadReviews(); + loadMetrics(); }); onBeforeUnmount(releaseAudioSources); @@ -332,6 +401,51 @@ onBeforeUnmount(releaseAudioSources); } } +.head-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-panel { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 14px; +} + +.metric-card { + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + padding: 16px; + + span { + display: block; + color: #667085; + font-size: 13px; + } + + strong { + display: block; + margin-top: 8px; + color: #16864b; + font-size: 24px; + } + + .is-pending { + color: #d97706; + font-size: 18px; + } +} + .review-layout { display: grid; grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr); @@ -445,8 +559,15 @@ onBeforeUnmount(releaseAudioSources); .review-layout, .list-filters, .score-grid, - .form-grid { + .form-grid, + .filter-panel, + .metric-grid { grid-template-columns: 1fr; } + + .head-actions { + align-items: stretch; + flex-direction: column; + } } diff --git a/frontend/src/views/train/rubrics.vue b/frontend/src/views/train/rubrics.vue new file mode 100644 index 00000000..49e7f8be --- /dev/null +++ b/frontend/src/views/train/rubrics.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/frontend/src/views/train/scenarios.vue b/frontend/src/views/train/scenarios.vue new file mode 100644 index 00000000..e0db950c --- /dev/null +++ b/frontend/src/views/train/scenarios.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/mobile-uni/src/components/SupervisorTabBar.vue b/mobile-uni/src/components/SupervisorTabBar.vue new file mode 100644 index 00000000..eed71626 --- /dev/null +++ b/mobile-uni/src/components/SupervisorTabBar.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index b4447641..6366a593 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -31,6 +31,12 @@ "navigationBarTitleText": "我" } }, + { + "path": "pages/user/history/index", + "style": { + "navigationBarTitleText": "训练详情" + } + }, { "path": "pages/user/cases/index", "style": { @@ -91,6 +97,12 @@ "navigationBarTitleText": "复盘详情" } }, + { + "path": "pages/supervisor/profile/index", + "style": { + "navigationBarTitleText": "我的主管工作台" + } + }, { "path": "pages/candidate/index/index", "style": { @@ -112,7 +124,7 @@ { "path": "pages/candidate/progress/index", "style": { - "navigationBarTitleText": "进度通知" + "navigationBarTitleText": "面试记录" } }, { @@ -131,7 +143,7 @@ }, "tabBar": { "color": "#7b8494", - "selectedColor": "#e70014", + "selectedColor": "#e60012", "backgroundColor": "#ffffff", "borderStyle": "white", "height": "66px", diff --git a/mobile-uni/src/pages/auth/login/index.vue b/mobile-uni/src/pages/auth/login/index.vue index 6473b8cb..bb6b516e 100644 --- a/mobile-uni/src/pages/auth/login/index.vue +++ b/mobile-uni/src/pages/auth/login/index.vue @@ -34,7 +34,7 @@ type="number" maxlength="11" :clearable="false" - primary-color="#e70014" + primary-color="#e60012" @input="onPhoneInput" placeholder="请输入手机号" placeholder-style="color: #b5b8c1" @@ -53,7 +53,7 @@ type="number" maxlength="6" :clearable="false" - primary-color="#e70014" + primary-color="#e60012" @input="onSmsCodeInput" placeholder="请输入验证码" placeholder-style="color: #b5b8c1" @@ -207,13 +207,13 @@ onShow(() => { min-height: 100vh; padding: 0; background: - radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 247, 247, 0.86) 38%, rgba(239, 239, 239, 0.72) 100%), - linear-gradient(180deg, #fafafa 0%, #f2f2f2 100%); + radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 248, 251, 0.9) 38%, rgba(239, 242, 247, 0.78) 100%), + linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); } .login-shell { min-height: 100vh; - padding: 114px 30px 52px; + padding: 72px 24px 36px; overflow: hidden; } @@ -243,14 +243,14 @@ onShow(() => { .login-head { position: relative; max-width: 720px; - margin: 0 auto 43px; + margin: 0 auto 32px; } .brand-lockup { display: flex; align-items: center; gap: 12px; - margin-bottom: 36px; + margin-bottom: 28px; } .login-logo { @@ -283,7 +283,7 @@ onShow(() => { } .login-eyebrow { - color: #1d9668; + color: #e60012; } .login-head .page-title { @@ -312,8 +312,8 @@ onShow(() => { margin: 0 auto; padding: 36px 20px 36px; border: 1px solid rgba(225, 227, 232, 0.95); - border-top: 4px solid #f20b1a; - border-radius: 12px; + border-top: 4px solid #e60012; + border-radius: 18px; background: rgba(255, 255, 255, 0.96); box-shadow: 0 12px 28px rgba(29, 31, 35, 0.12), 0 1px 0 rgba(255, 255, 255, 0.8) inset; } @@ -321,7 +321,7 @@ onShow(() => { .login-card::before { position: absolute; inset: 0; - border-radius: 12px; + border-radius: 18px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 26%); content: ''; pointer-events: none; @@ -369,8 +369,8 @@ onShow(() => { } .field-shell:focus-within { - border-color: #e70014; - box-shadow: 0 0 0 3px rgba(231, 0, 20, 0.08); + border-color: #e60012; + box-shadow: 0 0 0 3px rgba(230, 0, 18, 0.08); } .field-shell :deep(.uni-easyinput) { @@ -387,6 +387,9 @@ onShow(() => { .field-shell :deep(.uni-easyinput__content-input) { height: 28px; + appearance: none; + -webkit-appearance: none; + background: transparent !important; color: #25282d; font-size: 15px; line-height: 28px; @@ -433,10 +436,10 @@ onShow(() => { width: 100%; min-height: 56px; padding: 0 4px; - border: 1px solid #f20b1a; + border: 1px solid #e60012; border-radius: 8px; background: linear-gradient(180deg, #ffffff 0%, #fffdfd 100%); - color: #f20b1a; + color: #e60012; font-size: 12px; font-weight: 700; line-height: 1.3; @@ -453,14 +456,13 @@ onShow(() => { justify-content: center; align-self: stretch; padding: 0 20px; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 12%), - linear-gradient(180deg, #ff1124 0%, #f40619 100%); + border-radius: 14px; + background: #e60012; color: #ffffff; font-size: 22px; font-weight: 800; letter-spacing: 2px; - box-shadow: 0 8px 18px rgba(231, 0, 20, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.24); + box-shadow: 0 8px 18px rgba(230, 0, 18, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.24); } .login-card :deep(.uni-noticebar) { diff --git a/mobile-uni/src/pages/candidate/progress/index.vue b/mobile-uni/src/pages/candidate/progress/index.vue index 6ee68041..99d2f830 100644 --- a/mobile-uni/src/pages/candidate/progress/index.vue +++ b/mobile-uni/src/pages/candidate/progress/index.vue @@ -1,14 +1,11 @@ + + + diff --git a/mobile-uni/src/pages/supervisor/review/index.vue b/mobile-uni/src/pages/supervisor/review/index.vue index b1ccd33b..75a26c99 100644 --- a/mobile-uni/src/pages/supervisor/review/index.vue +++ b/mobile-uni/src/pages/supervisor/review/index.vue @@ -154,6 +154,7 @@ {{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }} + @@ -165,6 +166,7 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { resetPageScroll } from '@/services/navigation'; import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice'; +import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import { createAsyncGenerationGuard, isReviewEditable, @@ -411,8 +413,8 @@ onBeforeUnmount(() => { diff --git a/mobile-uni/src/pages/user/history/index.vue b/mobile-uni/src/pages/user/history/index.vue new file mode 100644 index 00000000..858c54af --- /dev/null +++ b/mobile-uni/src/pages/user/history/index.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 849e5613..452c7936 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -774,11 +774,12 @@ onShow(() => { .practice-head::after { position: absolute; - right: 28px; - bottom: 24px; - width: 56px; - height: 3px; - background: #e70014; + right: 20px; + bottom: 20px; + width: 42px; + height: 4px; + border-radius: 4px; + background: var(--employee-primary); content: ''; } @@ -1058,7 +1059,7 @@ onShow(() => { } .dialogue-card { - border-color: #f0dfe0; + border-color: #dbe7ff; box-shadow: var(--employee-card-shadow); } @@ -1120,19 +1121,19 @@ onShow(() => { } .emotion-fill { - background: #e45757; + background: #6f9cff; } .trust-fill { - background: #d51b2b; + background: #2f6bff; } .red-flag { padding: 9px 10px; - border-left: 3px solid #e70014; + border-left: 3px solid #f59e0b; border-radius: 4px; - background: #fff2f3; - color: #bd0010; + background: #fff7e6; + color: #a15c00; font-size: 13px; font-weight: 800; line-height: 1.45; @@ -1162,19 +1163,19 @@ onShow(() => { } .turn-row.trainee { - border-left-color: #e70014; - background: #fff6f6; + border-left-color: #2f6bff; + background: #eef4ff; } .turn-row.coach { - border-left-color: #d98282; - background: #fff5f5; + border-left-color: #8bb1ff; + background: #f4f7ff; } .reply-actions button.recording-active { - border-color: #e70014; - background: #fff5f5; - color: #e70014; + border-color: #2f6bff; + background: #eef4ff; + color: #2f6bff; } .turn-role { @@ -1198,9 +1199,9 @@ onShow(() => { .style-chip { width: auto; - border-color: #f0c8ca; + border-color: #c9d9ff; background: #ffffff; - color: #d51b2b; + color: #2f6bff; } .reply-card { @@ -1210,7 +1211,7 @@ onShow(() => { .practice-textarea { min-height: 124px; - border-color: #f0dfe0; + border-color: #dbe7ff; border-radius: 18px; background: #f9fbff; } @@ -1230,8 +1231,8 @@ onShow(() => { .help-ref { padding: 5px 8px; border-radius: 5px; - background: #fff1f2; - color: #d51b2b; + background: #eef4ff; + color: #2f6bff; font-size: 12px; font-weight: 800; } @@ -1243,7 +1244,7 @@ onShow(() => { } .result-score { - color: #e52323; + color: #2f6bff; font-size: 44px; font-weight: 900; line-height: 1; @@ -1261,55 +1262,56 @@ onShow(() => { flex-direction: column; gap: 6px; padding: 12px; - border-left: 3px solid #e52323; + border-left: 3px solid #2f6bff; border-radius: 6px; - background: #fff6f6; + background: #eef4ff; } .mentor-rewrite text:first-child { - color: #c71927; + color: #2f6bff; font-weight: 900; } -/* 练页入口态:对应高保真原型,真实对话状态仍沿用下方业务面板 */ +/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */ .practice-page { - padding: 0 18px 104px; - background: #fafafa; + padding: 0 16px 108px; + background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%); } .practice-head { - margin: 0 -18px; - padding: 36px 18px 26px; - background: #ffffff; - text-align: center; + margin: 0 -16px; + padding: 30px 20px 24px; + border-bottom: 1px solid var(--employee-line); + background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%); + text-align: left; } .practice-head::after, .practice-eyebrow, .practice-tip { - display: none; + display: block; } .practice-head .page-title { - color: #111111; - font-size: 38px; + color: var(--employee-text); + font-size: 30px; font-weight: 900; } .practice-head .page-subtitle { max-width: none; margin-top: 8px; - color: #858991; - font-size: 16px; + color: var(--employee-muted); + font-size: 14px; } .scenario-card { position: relative; overflow: hidden; - margin-top: 18px; + margin-top: 16px; padding: 22px 18px 18px; border: 0; - border-radius: 20px; + border-radius: var(--employee-card-radius); border-left: 0; background: #ffffff; box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06); @@ -1323,13 +1325,13 @@ onShow(() => { } .scenario-title { - color: #111111; + color: var(--employee-text); font-size: 30px; font-weight: 900; } .scenario-desc { - color: #858991; + color: var(--employee-muted); font-size: 17px; } @@ -1340,7 +1342,7 @@ onShow(() => { width: 42%; height: 108px; border-radius: 50%; - background: #fff1f2; + background: var(--employee-primary-soft); opacity: 0.9; } @@ -1352,7 +1354,7 @@ onShow(() => { height: 76px; border-radius: 12px; background: #ffffff; - box-shadow: 0 8px 16px rgba(231, 0, 20, 0.08); + box-shadow: 0 8px 16px rgba(47, 107, 255, 0.08); transform: rotate(7deg); } @@ -1363,7 +1365,7 @@ onShow(() => { width: 48px; height: 6px; border-radius: 6px; - background: #f5dfe1; + background: #dce8ff; content: ''; } @@ -1386,11 +1388,11 @@ onShow(() => { align-items: center; justify-content: center; border-radius: 14px; - background: #ef5b61; + background: #7ba4ff; color: #ffffff; font-size: 19px; letter-spacing: 3px; - box-shadow: 0 8px 14px rgba(231, 0, 20, 0.16); + box-shadow: 0 8px 14px rgba(47, 107, 255, 0.16); } .visual-bubble::after { @@ -1399,7 +1401,7 @@ onShow(() => { bottom: -7px; width: 14px; height: 14px; - background: #ef5b61; + background: #7ba4ff; content: ''; transform: rotate(35deg); } @@ -1421,27 +1423,47 @@ onShow(() => { justify-content: center; gap: 4px; padding: 8px; - border: 1px solid #f4d9da; + border: 1px solid #dce8ff; border-radius: 12px; - background: #fffafa; + background: #f8fbff; } .prep-mini .prep-label { - color: #e70014; + color: var(--employee-primary); font-size: 15px; font-weight: 800; } .prep-mini text:last-child { - color: #8c8f96; + color: var(--employee-muted); font-size: 11px; line-height: 1.3; } .scenario-card .primary-button { margin-top: 18px; - background: #e70014; - box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18); + background: linear-gradient(135deg, #2f6bff, #5a8cff); + box-shadow: 0 8px 16px rgba(47, 107, 255, 0.18); +} + +.login-panel { + border-left-color: var(--employee-primary); +} + +.assignment-row { + border-color: #dbe7ff; +} + +.assignment-row.highlighted { + border-color: #9bbaff; + background: #f4f7ff; +} + +.assignment-badge, +.assignment-badge.daily, +.scenario-badge { + background: var(--employee-primary-soft); + color: var(--employee-primary); } .recent-panel, @@ -1449,14 +1471,14 @@ onShow(() => { margin-top: 14px; padding: 18px; border: 0; - border-radius: 20px; + border-radius: var(--employee-card-radius); background: #ffffff; box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05); } .recent-link, .daily-action-label { - color: #858991; + color: var(--employee-muted); font-size: 14px; } @@ -1481,8 +1503,8 @@ onShow(() => { justify-content: center; margin-right: 12px; border-radius: 50%; - background: #fff1f2; - color: #e52323; + background: var(--employee-primary-soft); + color: var(--employee-primary); font-size: 24px; font-weight: 800; } @@ -1502,7 +1524,7 @@ onShow(() => { .recent-row .assignment-desc { margin-top: 4px; - color: #858991; + color: var(--employee-muted); } .recent-arrow { @@ -1517,7 +1539,7 @@ onShow(() => { .daily-subtitle { display: block; margin-top: 4px; - color: #858991; + color: var(--employee-muted); font-size: 14px; } @@ -1529,17 +1551,17 @@ onShow(() => { gap: 5px; margin-top: 14px; padding: 0 14px; - border: 1px dashed #f0c8ca; + border: 1px dashed #cfe0ff; border-radius: 14px; - background: #fffafa; - color: #858991; + background: #f8fbff; + color: var(--employee-muted); font-size: 13px; line-height: 1.45; text-align: center; } .daily-empty text:first-child { - color: #d51b2b; + color: var(--employee-primary); font-size: 15px; font-weight: 800; } @@ -1556,13 +1578,18 @@ onShow(() => { margin-top: 8px; } +.daily-row .small-button { + background: linear-gradient(135deg, #2f6bff, #5a8cff); + box-shadow: 0 6px 12px rgba(47, 107, 255, 0.16); +} + .capability-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin-top: 14px; padding: 16px 8px; - border-radius: 18px; + border-radius: var(--employee-card-radius); background: #ffffff; box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05); } @@ -1580,13 +1607,13 @@ onShow(() => { } .capability-strip text:first-child { - color: #1a1a1a; + color: var(--employee-text); font-size: 16px; font-weight: 800; } .capability-strip text:last-child { - color: #8a8d93; + color: var(--employee-muted); font-size: 11px; } diff --git a/mobile-uni/src/pages/user/profile/index.vue b/mobile-uni/src/pages/user/profile/index.vue index 1614d344..48d60d71 100644 --- a/mobile-uni/src/pages/user/profile/index.vue +++ b/mobile-uni/src/pages/user/profile/index.vue @@ -1,5 +1,5 @@