feat(aihr): align figma pages for release

This commit is contained in:
2026-07-14 02:04:36 +08:00
parent 922fe7ff29
commit 87360a25d3
51 changed files with 3575 additions and 629 deletions
+1 -1
View File
@@ -57,7 +57,7 @@
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。 - 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。 - 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。 - 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 /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 代码级强制失效,不要移除该闸门。 - 移动端手机号登录:验证码发送走 `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`。 - 移动端员工训练闭环:员工端首页“开始训练”复用 `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`。
+3 -1
View File
@@ -12,7 +12,7 @@
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。 - 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。 - 管理端 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/` 不再作为新增功能承载面。 - 用户侧 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_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。 - 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
@@ -45,6 +45,8 @@
|---|---| |---|---|
| [文档目录](docs/README.md) | docs 区完整索引 | | [文档目录](docs/README.md) | docs 区完整索引 |
| [Figma 设计资产清单](docs/FIGMA_DESIGN_INVENTORY.md) | 79 个页面、71 张正式画板的节点索引、批次范围和一致性审计结果 | | [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) | 端口、账号、启动和基础验证 | | [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
@@ -16,6 +16,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse; 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.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.domain.model.LoginUser;
@@ -44,6 +46,7 @@ import java.util.Map;
public class AihrMobileController { public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService; private final AihrMobileSeedService mobileSeedService;
private final AihrOrgSyncService orgSyncService;
private final ISysOssService ossService; private final ISysOssService ossService;
@SaIgnore @SaIgnore
@@ -52,11 +55,42 @@ public class AihrMobileController {
return R.ok(mobileSeedService.home(role)); 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<IdentityResponse> 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") @GetMapping("/practice/history")
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) { public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId))); return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
} }
@GetMapping("/practice/history/{id}")
public R<Map<String, Object>> practiceHistoryDetail(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceHistoryDetail(id, ownMobileExtPartyId(null));
return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail));
}
@GetMapping("/practice/reviews") @GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode, public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String extPartyId, @RequestParam(required = false) String extPartyId,
@@ -172,7 +206,7 @@ public class AihrMobileController {
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
item.put("role", turn.role()); item.put("role", turn.role());
item.put("label", turn.label()); item.put("label", turn.label());
item.put("text", turn.text()); item.put("text", maskSensitiveText(turn.text()));
item.put("audioUrl", turn.audioUrl()); item.put("audioUrl", turn.audioUrl());
item.put("audioOssId", turn.audioOssId()); item.put("audioOssId", turn.audioOssId());
return item; return item;
@@ -184,10 +218,17 @@ public class AihrMobileController {
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) { private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
return Map.of( return Map.of(
"turnIndex", annotation.turnIndex(), "turnIndex", annotation.turnIndex(),
"sourceText", annotation.sourceText(), "sourceText", maskSensitiveText(annotation.sourceText()),
"issue", annotation.issue(), "issue", maskSensitiveText(annotation.issue()),
"rewrite", annotation.rewrite(), "rewrite", maskSensitiveText(annotation.rewrite()),
"sopPoint", annotation.sopPoint() "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");
}
} }
@@ -12,6 +12,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; 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.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -86,6 +89,24 @@ public class AihrPracticeController {
return R.ok(practiceSeedService.rubrics(scenarioId)); return R.ok(practiceSeedService.rubrics(scenarioId));
} }
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/rubrics/manage")
public R<List<RubricAdminResponse>> manageRubrics() {
return R.ok(practiceSeedService.manageRubrics());
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/rubrics/manage")
public R<RubricAdminResponse> 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<Void> 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) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/export") @GetMapping("/export")
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@@ -41,6 +41,10 @@ public final class AihrInterviewDto {
public record DimensionResponse(String label, Object value, String note) { 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<QuestionRecordResponse> questions) {
} }
} }
@@ -78,6 +78,32 @@ public final class AihrPracticeDto {
public record RubricDimensionResponse(String code, String name, Double weight, String description) { 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<RubricDimensionResponse> dimensions
) {
}
public record RubricSaveRequest(
String code,
String scenarioCode,
String name,
String version,
Boolean enabled,
String description,
List<RubricDimensionResponse> dimensions
) {
}
public record RubricEnabledRequest(Boolean enabled) {
}
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) { public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
} }
@@ -10,6 +10,14 @@ public final class AihrMobileDto {
private AihrMobileDto() { private AihrMobileDto() {
} }
public record IdentityResponse(
String role,
String roleLabel,
String positionName,
String positionLevel
) {
}
public record HomeResponse( public record HomeResponse(
String role, String role,
String roleLabel, String roleLabel,
@@ -11,10 +11,12 @@ import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse; 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.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -88,7 +90,8 @@ public class AihrInterviewService {
} }
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session)); ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
String time = LocalDateTime.now().format(TIME_FORMAT); 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<QuestionRecordResponse> 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); saveResult(sessionKey, session, score);
return new FinishResponse( return new FinishResponse(
score.total(), score.total(),
@@ -110,7 +113,7 @@ public class AihrInterviewService {
ensureResultTable(); ensureResultTable();
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100)); int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
StringBuilder sql = new StringBuilder(""" 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 FROM aihr_interview_result
WHERE tenant_id = '000000' WHERE tenant_id = '000000'
"""); """);
@@ -134,7 +137,8 @@ public class AihrInterviewService {
rs.getString("position_code"), rs.getString("position_code"),
rs.getInt("total_score"), rs.getInt("total_score"),
rs.getString("result"), 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(""" jdbcTemplate.update("""
INSERT INTO aihr_interview_result INSERT INTO aihr_interview_result
(tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, 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) suggestion, ai_summary, evidence, source, answers_json, questions_json, dimensions_json, finished_time, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now()) VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
candidate_id = VALUES(candidate_id), candidate_id = VALUES(candidate_id),
candidate_name = VALUES(candidate_name), candidate_name = VALUES(candidate_name),
@@ -156,6 +160,7 @@ public class AihrInterviewService {
evidence = VALUES(evidence), evidence = VALUES(evidence),
source = VALUES(source), source = VALUES(source),
answers_json = VALUES(answers_json), answers_json = VALUES(answers_json),
questions_json = VALUES(questions_json),
dimensions_json = VALUES(dimensions_json), dimensions_json = VALUES(dimensions_json),
finished_time = VALUES(finished_time), finished_time = VALUES(finished_time),
update_time = now() update_time = now()
@@ -171,6 +176,7 @@ public class AihrInterviewService {
score.evidence(), score.evidence(),
score.source(), score.source(),
json(session.answers()), json(session.answers()),
json(questionRecords(session)),
json(score.dimensions()) json(score.dimensions())
); );
} }
@@ -198,6 +204,7 @@ public class AihrInterviewService {
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
`source` varchar(40) DEFAULT NULL COMMENT '评分来源', `source` varchar(40) DEFAULT NULL COMMENT '评分来源',
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
`questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON',
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间', `finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_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`) KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果'; ) 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; tableReady = true;
} }
} }
private List<QuestionRecordResponse> 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<QuestionRecordResponse> 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) { private String json(Object value) {
try { try {
return objectMapper.writeValueAsString(value); return objectMapper.writeValueAsString(value);
@@ -77,6 +77,10 @@ public class AihrMobileSeedService {
return practiceSeedService.reviewDetail(id, supervisorExtPartyId); return practiceSeedService.reviewDetail(id, supervisorExtPartyId);
} }
public ReviewDetailResponse practiceHistoryDetail(Long id, String extPartyId) {
return practiceSeedService.historyDetail(id, extPartyId);
}
public boolean markReviewed(Long id) { public boolean markReviewed(Long id) {
return markReviewed(id, null); return markReviewed(id, null);
} }
@@ -28,7 +28,10 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest; import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse; import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse; 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.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -380,6 +383,79 @@ public class AihrPracticeSeedService {
return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim())); return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim()));
} }
public List<RubricAdminResponse> 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<RubricDimensionResponse> 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) { public StartResponse start(StartRequest request) {
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null); ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
RoundSeed firstRound = scenario.rounds().get(0); RoundSeed firstRound = scenario.rounds().get(0);
@@ -999,6 +1075,11 @@ public class AihrPracticeSeedService {
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail; 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) @Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id) { public boolean markReviewed(Long id) {
return markReviewed(id, null); 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() { private void ensureCalibrationTable() {
if (calibrationTableReady) { if (calibrationTableReady) {
return; return;
@@ -19,12 +19,15 @@ import java.nio.file.StandardCopyOption;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** /**
* 批量上传队列:上传请求只做「暂存文件 + 入队 + 秒回」,解析/归类/向量化由本服务的后台 worker * 批量上传队列:上传请求只做「暂存文件 + 入队 + 秒回」,解析/归类/向量化由本服务的后台 worker
@@ -39,6 +42,9 @@ public class AihrUploadQueueService {
private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; 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_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; private static final int MAX_ERROR_CHARS = 480;
/** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */ /** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */
private static final int WORKER_PERMITS = 2; private static final int WORKER_PERMITS = 2;
@@ -76,12 +82,16 @@ public class AihrUploadQueueService {
throw new ServiceException("上传文件不能为空"); throw new ServiceException("上传文件不能为空");
} }
String fileName = sanitizeFileName(file.getOriginalFilename()); String fileName = sanitizeFileName(file.getOriginalFilename());
if (!AihrSopSeedService.supportedFile(fileName)) { boolean archive = isZip(fileName);
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频文件"); 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) { if (file.getSize() > maxBytes) {
throw new ServiceException(AihrVideoService.videoFile(fileName) throw new ServiceException(archive
? "ZIP不能超过 500MB"
: AihrVideoService.videoFile(fileName)
? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入" ? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入"
: "文件不能超过 100MB"); : "文件不能超过 100MB");
} }
@@ -135,11 +145,11 @@ public class AihrUploadQueueService {
throw new ServiceException("重试条目不存在"); throw new ServiceException("重试条目不存在");
} }
ItemRow row = jdbcTemplate.query(""" 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 from aihr_knowledge_upload_item
where tenant_id = ? and id = ? where tenant_id = ? and id = ?
""", rs -> rs.next() """, 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); : null, TENANT_ID, id);
if (row == null) { if (row == null) {
throw new ServiceException("重试条目不存在"); throw new ServiceException("重试条目不存在");
@@ -190,17 +200,16 @@ public class AihrUploadQueueService {
List<QueueCandidate> candidates = jdbcTemplate.query(""" List<QueueCandidate> candidates = jdbcTemplate.query("""
select i.id, i.file_name from aihr_knowledge_upload_item i select i.id, i.file_name from aihr_knowledge_upload_item i
where i.tenant_id = ? and i.status = 0 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 ( and not exists (
select 1 from (select file_name from aihr_knowledge_upload_item where tenant_id = ? and status = 1) p 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 where p.file_name = i.file_name
) )
order by i.id asc order by i.id asc
limit 10 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) { for (QueueCandidate candidate : candidates) {
if (videoBusy && AihrVideoService.videoFile(candidate.fileName())) {
continue;
}
int updated = jdbcTemplate.update(""" int updated = jdbcTemplate.update("""
update aihr_knowledge_upload_item update aihr_knowledge_upload_item
set status = 1, update_time = now() set status = 1, update_time = now()
@@ -229,11 +238,11 @@ public class AihrUploadQueueService {
private void processItem(Long id) { private void processItem(Long id) {
ItemRow row = jdbcTemplate.query(""" 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 from aihr_knowledge_upload_item
where tenant_id = ? and id = ? where tenant_id = ? and id = ?
""", rs -> rs.next() """, 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); : null, TENANT_ID, id);
if (row == null) { if (row == null) {
return; return;
@@ -244,6 +253,18 @@ public class AihrUploadQueueService {
return; return;
} }
try { 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); UploadResponse result = sopSeedService.processStagedDocument(row.fileName(), row.category(), staged);
// 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因 // 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因
String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null; 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<ExtractedFile> 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() { private void sweepAndTrigger() {
try { try {
@@ -362,7 +459,9 @@ public class AihrUploadQueueService {
} }
private Path stagingPath(String fileName) { 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。 */ /** 去掉路径分隔与控制字符,防止暂存路径逃逸出 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) {
} }
} }
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
`source` varchar(40) DEFAULT NULL COMMENT '评分来源', `source` varchar(40) DEFAULT NULL COMMENT '评分来源',
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
`questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON',
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间', `finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+3 -2
View File
@@ -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,前端降级文本 | | 对练语音 | `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 本地结构化;不再用固定样例转写冒充成功 | | 案例沉淀 `/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 | | 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 名试点人员手机号 | | 组织人员同步 | `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 代码级强制失效) | | 移动端手机号登录 | `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 保演示 | | 用户侧三端首页 `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` 写入贡献度 | | 移动端员工训练与主管复盘闭环 | 员工复用 `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,不兜前端示例答案 | | 移动端候选人闭环 | 页面拆为 `/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`,生成资料处理页指标、分类、任务、链路和事件列表 | | 解析状态聚合 | `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` | 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/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 个 | | 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无 asr 且无 vision 结果时按「待处理」落库不报错;视频同时只加工 1 个 |
模型能力第二片已经落最小后端边界: 模型能力第二片已经落最小后端边界:
+1 -1
View File
@@ -121,7 +121,7 @@ AIHR_AI_SPEECH_ENABLED=true
- `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` - `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` - `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot`
- 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围 - 本地组织 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`) - 视频(`.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` 保留为同步调试接口。 - 服务端目录导入接口为 `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,不影响检索。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
+3 -2
View File
@@ -13,7 +13,7 @@
## 已有正式页面 ## 已有正式页面
- [x] 员工端:今日 - [x] 员工端:今日(Figma `23:3`)
- [x] 员工端:练(训练中心入口) - [x] 员工端:练(训练中心入口)
- [-] 员工端:问(仅提问态,缺结果与反馈) - [-] 员工端:问(仅提问态,缺结果与反馈)
- [-] 员工端:我(有成长摘要,缺完整画像/荣誉/偏好) - [-] 员工端:我(有成长摘要,缺完整画像/荣誉/偏好)
@@ -146,7 +146,7 @@
- 一期范围外的复杂多组织流程 - 一期范围外的复杂多组织流程
- 未完成授权对接前的北森、考勤、请假等伪数据页面 - 未完成授权对接前的北森、考勤、请假等伪数据页面
## 全文件设计一致性审计(2026-07-13) ## 全文件设计一致性审计(2026-07-14)
- [x] 建立 Figma 首屏“00|设计索引与审计”(Figma `101:3`) - [x] 建立 Figma 首屏“00|设计索引与审计”(Figma `101:3`)
- [x] 79 个页面、71 张正式画板均有内容,无重复页面名和空业务页 - [x] 79 个页面、71 张正式画板均有内容,无重复页面名和空业务页
@@ -156,4 +156,5 @@
- [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50% - [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50%
- [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐 - [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐
- [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界 - [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界
- [x] 员工端「页面|今日」已完成首页高保真复核:问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出
- [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序 - [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序
+183
View File
@@ -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 根据名称判断组件用途和边界?
判断标准:如果一个元素离开当前业务仍然有意义,它才适合进入通用视觉库。
+134
View File
@@ -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:量化、偏好、积分和荣誉**,等待稳定训练数据与人工校准后再实现。
+34
View File
@@ -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`
+2
View File
@@ -8,6 +8,8 @@
|---|---| |---|---|
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 | | [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 |
| [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 | | [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 演示流验收清单 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | | [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 |
+64
View File
@@ -42,6 +42,46 @@ export type PracticeScenarioEnabledRequest = {
enabled: boolean; 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<PracticeRubricAdmin, 'scenarioName'>;
export type PracticePrepCardResponse = { export type PracticePrepCardResponse = {
keyPoints: string[]; keyPoints: string[];
redLines: string[]; redLines: string[];
@@ -174,6 +214,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario
}); });
} }
export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> {
return request({
url: '/api/train/practice/scenarios',
method: 'post',
data
});
}
export function listPracticeRubrics(): Promise<ApiResult<PracticeRubricAdmin[]>> {
return request({ url: '/api/train/practice/rubrics/manage', method: 'get' });
}
export function savePracticeRubric(data: PracticeRubricSaveRequest): Promise<ApiResult<PracticeRubricAdmin>> {
return request({ url: '/api/train/practice/rubrics/manage', method: 'post', data });
}
export function updatePracticeRubricEnabled(code: string, enabled: boolean): Promise<ApiResult<null>> {
return request({
url: `/api/train/practice/rubrics/${encodeURIComponent(code)}/enabled`,
method: 'patch',
data: { enabled }
});
}
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> { export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
return request({ return request({
url: `/api/train/practice/scenarios/${id}/prep-card`, url: `/api/train/practice/scenarios/${id}/prep-card`,
+36
View File
@@ -48,6 +48,25 @@ export type SopReviewResponse = {
reviewedAt: string; 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 = { export type SopUploadResponse = {
docId: string; docId: string;
ossId: number; ossId: number;
@@ -87,6 +106,23 @@ export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer
}); });
} }
export function listAnswerFeedback(status = '待复核', limit = 50): Promise<ApiResult<AnswerFeedbackItem[]>> {
return request({
url: '/api/knowledge/answer-feedback',
method: 'get',
params: { status, limit },
headers: { repeatSubmit: false }
});
}
export function reviewAnswerFeedback(id: number): Promise<ApiResult<AnswerFeedbackReviewResponse>> {
return request({
url: `/api/knowledge/answer-feedback/${id}/review`,
method: 'post',
headers: { repeatSubmit: false }
});
}
export type UploadEnqueueResponse = { export type UploadEnqueueResponse = {
itemId: number; itemId: number;
batchId: string; batchId: string;
+18
View File
@@ -101,6 +101,18 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'TrainPractice', name: 'TrainPractice',
meta: { title: '三角色对练', icon: 'peoples' } 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', path: 'reviews',
component: () => import('@/views/train/reviews.vue'), component: () => import('@/views/train/reviews.vue'),
@@ -157,6 +169,12 @@ export const constantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/model/index.vue'), component: () => import('@/views/system/model/index.vue'),
name: 'SystemModelConfig', name: 'SystemModelConfig',
meta: { title: '模型配置', icon: 'model' } meta: { title: '模型配置', icon: 'model' }
},
{
path: 'prompts',
component: () => import('@/views/system/prompts.vue'),
name: 'SystemPromptTemplates',
meta: { title: '提示词模板', icon: 'edit' }
} }
] ]
}, },
+10 -5
View File
@@ -283,12 +283,14 @@ import {
} from '@/api/aihr/processing'; } from '@/api/aihr/processing';
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop'; 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 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 videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
const archivePattern = /\.zip$/i;
const maxFileSize = 100 * 1024 * 1024; const maxFileSize = 100 * 1024 * 1024;
const maxVideoSize = 500 * 1024 * 1024; const maxVideoSize = 500 * 1024 * 1024;
const maxArchiveSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20; const browserBatchWarnCount = 20;
const browserBatchWarnSize = 200 * 1024 * 1024; const browserBatchWarnSize = 200 * 1024 * 1024;
@@ -484,14 +486,17 @@ async function handlePickedFiles(event: Event) {
async function uploadFiles(files: File[]) { async function uploadFiles(files: File[]) {
const readyFiles = files.filter( 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) { 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; return;
} }
if (readyFiles.length < files.length) { 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); const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
+131 -2
View File
@@ -89,6 +89,50 @@
</article> </article>
</section> </section>
<section class="panel pending-review-panel">
<div class="panel-title with-actions">
<div class="panel-title-main">
<el-icon><DocumentChecked /></el-icon>
<div>
<h2>答案待复核</h2>
<p>员工反馈为「没解决」的答案会进入这里,复核后从待办队列移除。</p>
</div>
</div>
<div class="review-toolbar">
<el-select v-model="pendingReviewStatus" size="small" @change="loadPendingReviews">
<el-option label="待复核" value="待复核" />
<el-option label="已复核" value="已复核" />
</el-select>
<el-button size="small" :loading="pendingReviewLoading" @click="loadPendingReviews">刷新</el-button>
</div>
</div>
<el-table v-loading="pendingReviewLoading" :data="pendingReviews" size="small" class="record-table">
<el-table-column prop="queryText" label="员工问题" min-width="260" />
<el-table-column prop="verdict" label="反馈" width="90">
<template #default="{ row }">
<span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span>
</template>
</el-table-column>
<el-table-column prop="position" label="岗位" width="120" />
<el-table-column prop="createTime" label="提交时间" width="170" />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.reviewStatus === '待复核'"
link
type="danger"
:loading="reviewingFeedbackId === row.id"
@click="markFeedbackReviewed(row)"
>
标记已复核
</el-button>
<span v-else class="review-done">已复核</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!pendingReviewLoading && !pendingReviews.length" description="暂无待复核答案" />
</section>
<section class="panel record-panel"> <section class="panel record-panel">
<div class="panel-title"> <div class="panel-title">
<el-icon><Tickets /></el-icon> <el-icon><Tickets /></el-icon>
@@ -110,9 +154,18 @@
import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue'; import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import type { UploadFile } 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 { 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(); const router = useRouter();
@@ -212,6 +265,10 @@ const uploading = ref(false);
const uploadedRecords = ref<SopRecord[]>([]); const uploadedRecords = ref<SopRecord[]>([]);
const reviewSaving = ref(false); const reviewSaving = ref(false);
const reviewStatus = ref(''); const reviewStatus = ref('');
const pendingReviewStatus = ref('待复核');
const pendingReviewLoading = ref(false);
const reviewingFeedbackId = ref<number | null>(null);
const pendingReviews = ref<AnswerFeedbackItem[]>([]);
const activeSeed = computed(() => sopSeeds[selectedCategory.value]); const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer); 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) { async function handleDocSelected(uploadFile: UploadFile) {
const raw = uploadFile.raw; const raw = uploadFile.raw;
if (!raw) return; if (!raw) return;
@@ -359,6 +442,10 @@ async function generateTraining() {
trainingGenerated.value = true; trainingGenerated.value = true;
ElMessage.success('已基于引用片段生成训练题'); ElMessage.success('已基于引用片段生成训练题');
} }
onMounted(() => {
loadPendingReviews();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -425,6 +512,48 @@ async function generateTraining() {
} }
} }
.panel-title.with-actions {
justify-content: space-between;
}
.panel-title-main,
.review-toolbar {
display: flex;
align-items: center;
}
.panel-title-main {
gap: 8px;
p {
margin: 4px 0 0;
color: #657184;
font-size: 12px;
font-weight: 400;
}
}
.review-toolbar {
gap: 8px;
}
.pending-review-panel {
margin-top: 12px;
}
.review-verdict,
.review-done {
font-size: 12px;
}
.review-verdict.negative {
color: #c80f1f;
}
.review-done {
color: #15844a;
}
.category-list { .category-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
+242
View File
@@ -0,0 +1,242 @@
<template>
<div class="prompt-page">
<div class="page-heading">
<div>
<h1>提示词模板管理</h1>
<p>统一维护训练、评分、复盘和准备卡模板</p>
</div>
<el-button type="danger" :icon="Plus" @click="createPrompt">新增模板</el-button>
</div>
<div class="prompt-layout">
<section class="list-card">
<h2>模板列表</h2>
<button
v-for="item in templates"
:key="item.id"
class="template-item"
:class="{ active: item.id === selectedId }"
@click="selectPrompt(item)"
>
<strong>{{ item.name }}</strong
><span>{{ item.code }}</span
><em>{{ item.enabled ? '已启用' : '已停用' }}</em>
</button>
<el-empty v-if="!loading && !templates.length" description="暂无模板" />
</section>
<section v-if="form" class="editor-card">
<div class="editor-heading">
<div>
<h2>编辑:{{ form.name }}</h2>
<p>版本 {{ form.version }} · 分类 {{ form.category }}</p>
</div>
<el-tag :type="form.enabled ? 'success' : 'info'">{{ form.enabled ? '启用' : '停用' }}</el-tag>
</div>
<div class="field-grid">
<el-form-item label="模板代码"><el-input v-model="form.code" :disabled="form.id > 0" /></el-form-item>
<el-form-item label="模板名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="输出文体"><el-input v-model="form.outputFormat" /></el-form-item>
</div>
<el-form-item label="系统提示词"><el-input v-model="form.systemPrompt" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="提示词模板"><el-input v-model="form.template" type="textarea" :rows="5" /></el-form-item>
<el-form-item label="模板变量"><el-input v-model="form.variables" placeholder='["scenario","dialogue","score"]' /></el-form-item>
<div class="check-card"><strong>变量校验</strong><span>保存前校验变量格式;未知变量不会发布到生产调用链。</span></div>
<div class="editor-actions">
<el-button @click="save(false)">保存草稿</el-button><el-button type="danger" :loading="saving" @click="save(true)">保存并启用</el-button>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import {
createPromptTemplate,
listPromptTemplates,
updatePromptTemplate,
type PromptTemplateRequest,
type PromptTemplateResponse
} from '@/api/aihr/practice';
const loading = ref(false);
const saving = ref(false);
const templates = ref<PromptTemplateResponse[]>([]);
const selectedId = ref(0);
const form = ref<PromptTemplateRequest & { id: number }>();
const clone = (item: PromptTemplateResponse): PromptTemplateRequest & { id: number } => ({ ...item });
const selectPrompt = (item: PromptTemplateResponse) => {
selectedId.value = item.id;
form.value = reactive(clone(item));
};
const load = async () => {
loading.value = true;
try {
const result = await listPromptTemplates();
templates.value = result.data ?? [];
if (!selectedId.value && templates.value[0]) selectPrompt(templates.value[0]);
} finally {
loading.value = false;
}
};
const createPrompt = () => {
selectedId.value = 0;
form.value = reactive({
id: 0,
code: '',
name: '新提示词模板',
category: 'practice',
outputFormat: 'summary_card',
systemPrompt: '',
template: '',
variables: '',
version: 'v1',
enabled: false,
description: ''
});
};
const save = async (enabled: boolean) => {
if (!form.value?.code.trim() || !form.value.name.trim() || !form.value.template.trim()) {
ElMessage.warning('请填写模板代码、名称和模板内容');
return;
}
saving.value = true;
try {
const payload = { ...form.value, enabled };
const result = form.value.id ? await updatePromptTemplate(form.value.id, payload) : await createPromptTemplate(payload);
ElMessage.success(enabled ? '模板已保存并启用' : '模板草稿已保存');
await load();
if (result.data) selectPrompt(result.data);
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped>
.prompt-page {
min-height: calc(100vh - 84px);
padding: 28px 32px 40px;
background: #f4f6fa;
color: #1d2635;
}
.page-heading,
.editor-heading,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-heading {
margin-bottom: 22px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 28px;
font-weight: 600;
}
h2 {
font-size: 20px;
}
.page-heading p,
.editor-heading p {
margin-top: 8px;
color: #858e9e;
font-size: 13px;
}
.prompt-layout {
display: grid;
grid-template-columns: 410px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.list-card,
.editor-card {
min-height: 520px;
padding: 18px 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgb(33 43 54 / 4%);
}
.list-card h2 {
margin-bottom: 16px;
}
.template-item {
position: relative;
display: block;
width: 100%;
margin-bottom: 9px;
padding: 15px 14px;
border: 0;
border-radius: 8px;
background: #fafbfc;
color: #253044;
text-align: left;
cursor: pointer;
}
.template-item.active {
background: #fff0f1;
color: #e60012;
}
.template-item strong,
.template-item span {
display: block;
}
.template-item span {
margin-top: 7px;
color: #858e9e;
font-size: 12px;
}
.template-item em {
position: absolute;
top: 20px;
right: 14px;
color: #16a05d;
font-size: 12px;
font-style: normal;
}
.editor-card {
padding: 20px;
}
.editor-heading {
align-items: flex-start;
margin-bottom: 18px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.check-card {
display: flex;
flex-direction: column;
gap: 8px;
margin: 10px 0 14px;
padding: 16px;
border-radius: 8px;
background: #f7f9fb;
}
.check-card span {
color: #858e9e;
font-size: 12px;
}
.editor-actions {
gap: 10px;
}
.editor-actions .el-button:last-child {
flex: 1;
}
@media (max-width: 900px) {
.prompt-layout,
.field-grid {
grid-template-columns: 1fr;
}
}
</style>
+24 -1
View File
@@ -344,6 +344,18 @@ type ScenarioSeed = {
id: string; id: string;
name: string; name: string;
position?: 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; trainee: string;
customer: string; customer: string;
project: string; project: string;
@@ -489,7 +501,6 @@ const defaultPromptForm = (): PromptTemplateForm => ({
description: '' description: ''
}); });
const promptForm = ref<PromptTemplateForm>(defaultPromptForm()); const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
const selectedScenario = computed( const selectedScenario = computed(
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0] () => 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, id: item.id,
name: item.name, name: item.name,
position: item.position, 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: '张三', trainee: '张三',
customer: item.customerPersona, customer: item.customerPersona,
project: item.projectType || item.scenarioType, project: item.projectType || item.scenarioType,
+126 -5
View File
@@ -6,10 +6,37 @@
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p> <p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<el-button type="danger" :loading="exporting" @click="exportPilot">导出试点 CSV</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button> <el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
</div> </div>
</section> </section>
<section class="filter-panel panel">
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<el-select v-model="selectedPosition" placeholder="岗位">
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="selectedProjectCode" clearable filterable placeholder="组织范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
</section>
<section class="metric-grid">
<div class="metric-card">
<span>校准样本</span><strong>{{ metrics.calibrationCount }} / 20</strong>
</div>
<div class="metric-card">
<span>分档一致率</span><strong>{{ metrics.consistency }}</strong>
</div>
<div class="metric-card">
<span>SOP 可用率</span><strong>{{ metrics.sopUsable }}</strong>
</div>
<div class="metric-card">
<span>严格门禁</span><strong :class="metrics.ready ? 'is-ready' : 'is-pending'">{{ metrics.ready ? '通过' : '待补样本' }}</strong>
</div>
</section>
<section class="review-layout"> <section class="review-layout">
<article class="panel list-panel"> <article class="panel list-panel">
<div class="panel-title"> <div class="panel-title">
@@ -17,9 +44,6 @@
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag> <el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div> </div>
<div class="list-filters"> <div class="list-filters">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员"> <el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option <el-option
v-for="person in orgPeople" v-for="person in orgPeople"
@@ -122,7 +146,7 @@
import { Refresh } from '@element-plus/icons-vue'; import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { calibratePracticeSession, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice'; import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { import {
getPracticeAudioBlobUrl, getPracticeAudioBlobUrl,
getOrgSnapshot, getOrgSnapshot,
@@ -142,6 +166,11 @@ const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null); const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref(''); const selectedProjectCode = ref('');
const selectedExtPartyId = 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<Record<string, string>>({}); const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({}); const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({}); const audioErrors = ref<Record<string, string>>({});
@@ -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) => { const selectReview = async (row: ReviewRecord) => {
releaseAudioSources(); releaseAudioSources();
const result = await getPracticeReview(row.id); const result = await getPracticeReview(row.id);
@@ -290,10 +354,15 @@ watch(selectedExtPartyId, () => {
loadReviews(); loadReviews();
}); });
watch([startDate, endDate], () => {
loadMetrics();
});
onMounted(() => { onMounted(() => {
loadScenarios(); loadScenarios();
loadOrgSnapshot(); loadOrgSnapshot();
loadReviews(); loadReviews();
loadMetrics();
}); });
onBeforeUnmount(releaseAudioSources); 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 { .review-layout {
display: grid; display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr); grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
@@ -445,8 +559,15 @@ onBeforeUnmount(releaseAudioSources);
.review-layout, .review-layout,
.list-filters, .list-filters,
.score-grid, .score-grid,
.form-grid { .form-grid,
.filter-panel,
.metric-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.head-actions {
align-items: stretch;
flex-direction: column;
}
} }
</style> </style>
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="rubric-page">
<div class="page-heading">
<div>
<h1>Rubric 评分规则</h1>
<p>维护评分维度、权重、版本和生效状态</p>
</div>
<el-button type="danger" :icon="Plus" @click="createVersion">新建版本</el-button>
</div>
<div class="rubric-layout">
<section class="version-card">
<h2>版本列表</h2>
<button
v-for="item in rubrics"
:key="item.code"
class="version-item"
:class="{ active: item.code === selectedCode }"
@click="selectRubric(item)"
>
<strong>{{ item.name }} {{ item.version }}</strong>
<span>{{ item.enabled ? '当前生效' : '历史版本' }} · {{ item.dimensions.length }} 维度</span>
</button>
<el-empty v-if="!loading && rubrics.length === 0" description="暂无评分规则" />
</section>
<section v-if="form" class="editor-card">
<div class="editor-heading">
<div>
<h2>{{ form.name }} {{ form.version }}</h2>
<p>{{ selectedRubric?.scenarioName || form.scenarioCode }} · {{ form.description || '维护评分维度与权重' }}</p>
</div>
<el-tag :type="form.enabled ? 'success' : 'info'">{{ form.enabled ? '已启用' : '已停用' }}</el-tag>
</div>
<h3>评分维度与权重</h3>
<div v-for="dimension in form.dimensions" :key="dimension.code" class="dimension-row">
<div class="dimension-label">
<strong>{{ dimension.name }}</strong>
<span>{{ dimension.description || '请补充该维度的评分说明' }}</span>
</div>
<div class="dimension-control">
<span>{{ Math.round(dimension.weight * 100) }}% 权重</span>
<el-slider v-model="dimension.weight" :min="0" :max="1" :step="0.05" @change="normalizeWeights" />
</div>
</div>
<div class="band-card">
<strong>评分档位</strong>
<span>90–100 优秀&nbsp; · &nbsp;75–89 良好&nbsp; · &nbsp;60–74 待提升&nbsp; · &nbsp;&lt;60 不合格</span>
</div>
<div class="editor-actions">
<el-button @click="copyVersion">复制版本</el-button>
<el-button @click="selectedRubric && toggleEnabled(selectedRubric)">{{ form.enabled ? '停用版本' : '启用版本' }}</el-button>
<el-button type="danger" :loading="saving" @click="saveVersion">保存并发布</el-button>
</div>
</section>
<el-empty v-else-if="!loading" description="请选择一个评分规则" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import {
listPracticeRubrics,
savePracticeRubric,
updatePracticeRubricEnabled,
type PracticeRubricAdmin,
type PracticeRubricSaveRequest
} from '@/api/aihr/practice';
const loading = ref(false);
const saving = ref(false);
const rubrics = ref<PracticeRubricAdmin[]>([]);
const selectedCode = ref('');
const form = ref<PracticeRubricSaveRequest>();
const selectedRubric = computed(() => rubrics.value.find((item) => item.code === selectedCode.value));
const cloneForm = (item: PracticeRubricAdmin): PracticeRubricSaveRequest => ({
code: item.code,
scenarioCode: item.scenarioCode,
name: item.name,
version: item.version,
enabled: item.enabled,
description: item.description,
dimensions: item.dimensions.map((dimension) => ({ ...dimension }))
});
const loadRubrics = async () => {
loading.value = true;
try {
const result = await listPracticeRubrics();
rubrics.value = result.data ?? [];
if (!selectedCode.value && rubrics.value.length > 0) {
selectRubric(rubrics.value[0]);
} else if (selectedCode.value) {
const current = rubrics.value.find((item) => item.code === selectedCode.value);
if (current) selectRubric(current);
}
} catch {
rubrics.value = [];
} finally {
loading.value = false;
}
};
const selectRubric = (item: PracticeRubricAdmin) => {
selectedCode.value = item.code;
form.value = reactive(cloneForm(item));
};
const normalizeWeights = () => {
if (!form.value) return;
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
if (total > 0 && Math.abs(total - 1) > 0.001) {
form.value.dimensions.forEach((item) => {
item.weight = Number((item.weight / total).toFixed(2));
});
}
};
const createVersion = () => {
const source = rubrics.value[0];
if (!source) return;
const suffix = Date.now().toString().slice(-4);
form.value = reactive({
...cloneForm(source),
code: `${source.code}-${suffix}`,
version: `v${Number.parseInt(source.version.replace(/\D/g, ''), 10) + 1}`,
enabled: false
});
selectedCode.value = '';
};
const copyVersion = () => createVersion();
const saveVersion = async () => {
if (!form.value) return;
normalizeWeights();
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
if (Math.abs(total - 1) > 0.011) {
ElMessage.warning('各维度权重合计必须为 100%');
return;
}
saving.value = true;
try {
const result = await savePracticeRubric(form.value);
ElMessage.success('评分规则已保存并发布');
await loadRubrics();
if (result.data) selectRubric(result.data);
} finally {
saving.value = false;
}
};
const toggleEnabled = async (item: PracticeRubricAdmin) => {
await ElMessageBox.confirm(`确定${item.enabled ? '停用' : '启用'}“${item.name} ${item.version}”吗?`, '状态确认');
await updatePracticeRubricEnabled(item.code, !item.enabled);
item.enabled = !item.enabled;
if (item.code === selectedCode.value && form.value) form.value.enabled = item.enabled;
ElMessage.success(item.enabled ? '评分规则已启用' : '评分规则已停用');
};
onMounted(loadRubrics);
</script>
<style scoped>
.rubric-page {
min-height: calc(100vh - 84px);
padding: 28px 32px 40px;
background: #f4f6fa;
color: #1d2635;
}
.page-heading,
.editor-heading,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-heading {
margin-bottom: 22px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 28px;
font-weight: 600;
}
h2 {
font-size: 20px;
font-weight: 600;
}
h3 {
margin: 24px 0 18px;
font-size: 18px;
}
.page-heading p,
.editor-heading p {
margin-top: 8px;
color: #858e9e;
font-size: 13px;
}
.rubric-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.version-card,
.editor-card {
min-height: 520px;
padding: 18px 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgb(33 43 54 / 4%);
}
.version-card h2 {
margin-bottom: 16px;
}
.version-item {
display: block;
width: 100%;
margin-bottom: 9px;
padding: 16px 14px;
border: 0;
border-radius: 8px;
background: #fafbfc;
color: #253044;
text-align: left;
cursor: pointer;
}
.version-item.active {
background: #fff0f1;
color: #e60012;
}
.version-item strong,
.version-item span {
display: block;
}
.version-item span {
margin-top: 7px;
color: #858e9e;
font-size: 12px;
}
.editor-card {
padding: 20px;
}
.editor-heading {
align-items: flex-start;
}
.dimension-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: center;
margin: 18px 0;
}
.dimension-label strong,
.dimension-label span,
.dimension-control > span {
display: block;
}
.dimension-label span {
margin-top: 7px;
color: #858e9e;
font-size: 12px;
}
.dimension-control > span {
margin-bottom: 8px;
color: #e60012;
font-size: 12px;
text-align: right;
}
.band-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f7f9fb;
color: #758196;
font-size: 13px;
}
.band-card strong {
color: #253044;
font-size: 15px;
}
.editor-actions {
margin-top: 16px;
gap: 10px;
}
.editor-actions .el-button:last-child {
flex: 1;
}
@media (max-width: 900px) {
.rubric-layout {
grid-template-columns: 1fr;
}
.dimension-row {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div class="scenario-page">
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>生活顾问岗位 · 场景保存、Rubric 版本与启停维护</p>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" placeholder="岗位" clearable>
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.enabled" placeholder="状态" clearable>
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
<el-input v-model="filters.keyword" placeholder="场景名称 / 编码" clearable @keyup.enter="queryScenes" />
<el-button type="danger" @click="queryScenes">查询</el-button>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="difficulty" label="难度" width="92">
<template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
</el-table-column>
<el-table-column label="Rubric" min-width="180">
<template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
{{ row.enabled === false ? '停用' : '启用' }}
</span>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="150" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="toggleEnabled(row)">
{{ row.enabled === false ? '启用' : '停用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span>共 {{ filteredScenarios.length }} 个生活顾问场景 · 当前启用 {{ enabledCount }} 个</span>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
layout="prev, pager, next"
:total="filteredScenarios.length"
background
/>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close>
<el-form label-position="top" class="scenario-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="场景名称" required>
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="场景编码">
<el-input v-model="form.id" :disabled="Boolean(editingId)" placeholder="自动生成或填写唯一编码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="岗位"><el-input v-model="form.position" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item>
<el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item>
<el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
<el-switch v-model="form.enabled" active-text="启用场景" inactive-text="停用场景" />
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="danger" :loading="saving" @click="submit">保存场景</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
listPracticeScenarios,
savePracticeScenario,
updatePracticeScenarioEnabled,
type PracticeScenarioResponse,
type PracticeScenarioSaveRequest
} from '@/api/aihr/practice';
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
position: '生活顾问',
scenarioType: '',
enabled: undefined,
keyword: ''
});
const pagination = reactive({ page: 1, size: 10 });
const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
id: '',
name: '',
position: '生活顾问',
scenarioType: '投诉处理',
projectType: '住宅',
difficulty: 1,
customerPersona: '',
openingText: '',
explicitNeed: '',
hiddenNeed: '',
irritants: '',
delights: '',
sopRefs: '生活顾问SOP',
successCriteria: '',
strategy: '仅在漏关键步骤时提示',
goal: '',
enabled: true
});
const form = reactive(emptyForm());
const dialogTitle = computed(() => (editingId.value ? '编辑场景' : '新增场景'));
const filteredScenarios = computed(() =>
scenarios.value.filter((item) => {
const keyword = filters.keyword.trim().toLowerCase();
return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
);
})
);
const pagedScenarios = computed(() => {
const start = (pagination.page - 1) * pagination.size;
return filteredScenarios.value.slice(start, start + pagination.size);
});
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
const load = async () => {
loading.value = true;
try {
const { data } = await listPracticeScenarios();
scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
} finally {
loading.value = false;
}
};
const queryScenes = () => {
pagination.page = 1;
};
const difficultyLabel = (value: number) => ({ 1: '初级', 2: '中级', 3: '高级', 4: '困难', 5: '挑战' })[value] || '中级';
const fillForm = (item?: PracticeScenarioResponse) => {
Object.assign(
form,
emptyForm(),
item
? {
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,
strategy: item.strategy,
goal: item.goal,
enabled: item.enabled
}
: {}
);
};
const openCreate = () => {
editingId.value = '';
fillForm();
dialogVisible.value = true;
};
const openEdit = (item: PracticeScenarioResponse) => {
editingId.value = item.id;
fillForm(item);
dialogVisible.value = true;
};
const toggleEnabled = async (item: PracticeScenarioResponse) => {
const enabled = item.enabled === false;
await updatePracticeScenarioEnabled(item.id, { enabled });
item.enabled = enabled;
ElMessage.success(enabled ? '场景已启用' : '场景已停用');
};
const submit = async () => {
if (!form.name.trim() || !form.openingText.trim() || !form.goal.trim()) {
ElMessage.warning('请填写场景名称、开场话术和训练目标');
return;
}
saving.value = true;
try {
await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
dialogVisible.value = false;
await load();
ElMessage.success('场景已保存');
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.scenario-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #111;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
h1 {
margin: 0;
font-size: 24px;
line-height: 1.3;
}
p {
margin: 6px 0 0;
color: #858991;
font-size: 13px;
}
}
.filter-card,
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
}
.filter-card {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px;
gap: 12px;
margin-bottom: 12px;
}
.table-card {
:deep(.el-table) {
color: #111;
}
:deep(.el-table th.el-table__cell) {
color: #858991;
font-weight: 500;
background: #fafbfc;
}
}
.status-text {
font-size: 13px;
&.enabled {
color: #2a9b5f;
}
&.disabled {
color: #858991;
}
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
color: #858991;
font-size: 12px;
}
.scenario-form {
padding: 4px 8px;
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-textarea__inner) {
line-height: 1.6;
}
}
.full-control {
width: 100%;
}
@media (max-width: 1100px) {
.filter-card {
grid-template-columns: 1fr 1fr;
}
.filter-card .el-button {
width: 100%;
}
}
</style>
@@ -0,0 +1,80 @@
<template>
<view class="supervisor-tabbar">
<view
v-for="item in items"
:key="item.key"
class="supervisor-tab"
:class="{ active: active === item.key }"
@click="open(item.path)"
>
<uni-icons
:type="active === item.key ? item.activeIcon : item.icon"
size="22"
:color="active === item.key ? 'var(--supervisor-nav-active)' : 'var(--supervisor-nav-muted)'"
/>
<text>{{ item.label }}</text>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{ active: 'today' | 'team' | 'review' | 'profile' }>();
const items = [
{ key: 'today', label: '今日', icon: 'home', activeIcon: 'home-filled', path: '/pages/supervisor/index/index' },
{ key: 'team', label: '团队', icon: 'staff', activeIcon: 'staff-filled', path: '/pages/supervisor/team/index' },
{ key: 'review', label: '复盘', icon: 'list', activeIcon: 'list', path: '/pages/supervisor/reviews/index' },
{ key: 'profile', label: '我', icon: 'person', activeIcon: 'person-filled', path: '/pages/supervisor/profile/index' }
] as const;
const open = (path: string) => {
const pages = getCurrentPages();
const current = pages[pages.length - 1]?.route;
if (current === path.replace(/^\//, '')) return;
uni.redirectTo({ url: path });
};
</script>
<style scoped>
.supervisor-tabbar {
--supervisor-nav-active: #e60012;
--supervisor-nav-muted: #858991;
--supervisor-nav-radius: 18px;
position: fixed;
right: 20px;
bottom: 12px;
left: 20px;
z-index: 20;
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 10px 8px 9px;
border: 1px solid rgba(24, 34, 48, 0.06);
border-radius: var(--supervisor-nav-radius);
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 10px 28px rgba(24, 34, 48, 0.1);
}
.supervisor-tab {
display: flex;
min-height: 44px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--supervisor-nav-muted);
font-size: 12px;
line-height: 1;
}
.supervisor-tab.active {
color: var(--supervisor-nav-active);
font-weight: 800;
}
@media (min-width: 520px) {
.supervisor-tabbar {
right: calc((100vw - var(--mobile-shell-max-width)) / 2 + 20px);
left: calc((100vw - var(--mobile-shell-max-width)) / 2 + 20px);
}
}
</style>
+14 -2
View File
@@ -31,6 +31,12 @@
"navigationBarTitleText": "我" "navigationBarTitleText": "我"
} }
}, },
{
"path": "pages/user/history/index",
"style": {
"navigationBarTitleText": "训练详情"
}
},
{ {
"path": "pages/user/cases/index", "path": "pages/user/cases/index",
"style": { "style": {
@@ -91,6 +97,12 @@
"navigationBarTitleText": "复盘详情" "navigationBarTitleText": "复盘详情"
} }
}, },
{
"path": "pages/supervisor/profile/index",
"style": {
"navigationBarTitleText": "我的主管工作台"
}
},
{ {
"path": "pages/candidate/index/index", "path": "pages/candidate/index/index",
"style": { "style": {
@@ -112,7 +124,7 @@
{ {
"path": "pages/candidate/progress/index", "path": "pages/candidate/progress/index",
"style": { "style": {
"navigationBarTitleText": "进度通知" "navigationBarTitleText": "面试记录"
} }
}, },
{ {
@@ -131,7 +143,7 @@
}, },
"tabBar": { "tabBar": {
"color": "#7b8494", "color": "#7b8494",
"selectedColor": "#e70014", "selectedColor": "#e60012",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderStyle": "white", "borderStyle": "white",
"height": "66px", "height": "66px",
+21 -19
View File
@@ -34,7 +34,7 @@
type="number" type="number"
maxlength="11" maxlength="11"
:clearable="false" :clearable="false"
primary-color="#e70014" primary-color="#e60012"
@input="onPhoneInput" @input="onPhoneInput"
placeholder="请输入手机号" placeholder="请输入手机号"
placeholder-style="color: #b5b8c1" placeholder-style="color: #b5b8c1"
@@ -53,7 +53,7 @@
type="number" type="number"
maxlength="6" maxlength="6"
:clearable="false" :clearable="false"
primary-color="#e70014" primary-color="#e60012"
@input="onSmsCodeInput" @input="onSmsCodeInput"
placeholder="请输入验证码" placeholder="请输入验证码"
placeholder-style="color: #b5b8c1" placeholder-style="color: #b5b8c1"
@@ -207,13 +207,13 @@ onShow(() => {
min-height: 100vh; min-height: 100vh;
padding: 0; padding: 0;
background: 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%), 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, #fafafa 0%, #f2f2f2 100%); linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
} }
.login-shell { .login-shell {
min-height: 100vh; min-height: 100vh;
padding: 114px 30px 52px; padding: 72px 24px 36px;
overflow: hidden; overflow: hidden;
} }
@@ -243,14 +243,14 @@ onShow(() => {
.login-head { .login-head {
position: relative; position: relative;
max-width: 720px; max-width: 720px;
margin: 0 auto 43px; margin: 0 auto 32px;
} }
.brand-lockup { .brand-lockup {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 36px; margin-bottom: 28px;
} }
.login-logo { .login-logo {
@@ -283,7 +283,7 @@ onShow(() => {
} }
.login-eyebrow { .login-eyebrow {
color: #1d9668; color: #e60012;
} }
.login-head .page-title { .login-head .page-title {
@@ -312,8 +312,8 @@ onShow(() => {
margin: 0 auto; margin: 0 auto;
padding: 36px 20px 36px; padding: 36px 20px 36px;
border: 1px solid rgba(225, 227, 232, 0.95); border: 1px solid rgba(225, 227, 232, 0.95);
border-top: 4px solid #f20b1a; border-top: 4px solid #e60012;
border-radius: 12px; border-radius: 18px;
background: rgba(255, 255, 255, 0.96); 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; 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 { .login-card::before {
position: absolute; position: absolute;
inset: 0; 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%); background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 26%);
content: ''; content: '';
pointer-events: none; pointer-events: none;
@@ -369,8 +369,8 @@ onShow(() => {
} }
.field-shell:focus-within { .field-shell:focus-within {
border-color: #e70014; border-color: #e60012;
box-shadow: 0 0 0 3px rgba(231, 0, 20, 0.08); box-shadow: 0 0 0 3px rgba(230, 0, 18, 0.08);
} }
.field-shell :deep(.uni-easyinput) { .field-shell :deep(.uni-easyinput) {
@@ -387,6 +387,9 @@ onShow(() => {
.field-shell :deep(.uni-easyinput__content-input) { .field-shell :deep(.uni-easyinput__content-input) {
height: 28px; height: 28px;
appearance: none;
-webkit-appearance: none;
background: transparent !important;
color: #25282d; color: #25282d;
font-size: 15px; font-size: 15px;
line-height: 28px; line-height: 28px;
@@ -433,10 +436,10 @@ onShow(() => {
width: 100%; width: 100%;
min-height: 56px; min-height: 56px;
padding: 0 4px; padding: 0 4px;
border: 1px solid #f20b1a; border: 1px solid #e60012;
border-radius: 8px; border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #fffdfd 100%); background: linear-gradient(180deg, #ffffff 0%, #fffdfd 100%);
color: #f20b1a; color: #e60012;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
@@ -453,14 +456,13 @@ onShow(() => {
justify-content: center; justify-content: center;
align-self: stretch; align-self: stretch;
padding: 0 20px; padding: 0 20px;
border-radius: 8px; border-radius: 14px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 12%), background: #e60012;
linear-gradient(180deg, #ff1124 0%, #f40619 100%);
color: #ffffff; color: #ffffff;
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
letter-spacing: 2px; 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) { .login-card :deep(.uni-noticebar) {
+211 -302
View File
@@ -1,14 +1,11 @@
<template> <template>
<view class="page candidate-subpage progress-page"> <view class="page candidate-record-page">
<view class="subpage-header"> <view class="record-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button> <button class="back-button" aria-label="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading"> <view>
<text class="page-title">进度通知</text> <text class="page-title">面试记录</text>
<text class="page-subtitle">面试与资料审核状态</text> <text class="page-subtitle">{{ latestRecord ? `${latestRecord.time} · ${latestRecord.position}` : '面试与资料审核状态' }}</text>
</view> </view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view> </view>
<view v-if="message" class="status-message">{{ message }}</view> <view v-if="message" class="status-message">{{ message }}</view>
@@ -16,66 +13,50 @@
<view v-if="!loggedIn" class="panel login-panel"> <view v-if="!loggedIn" class="panel login-panel">
<view> <view>
<text class="panel-title">需要登录</text> <text class="panel-title">需要登录</text>
<text class="muted block">登录后查看本人面试和资料审核进度。</text> <text class="muted block">登录后查看本人面试记录。</text>
</view> </view>
<button class="primary-button login-button" @click="goLogin">去登录</button> <button class="primary-button login-button" @click="goLogin">去登录</button>
</view> </view>
<template v-else> <template v-else-if="latestRecord">
<view class="panel status-panel"> <view class="score-card">
<view class="status-head"> <view class="score-card-head">
<text class="status-badge">{{ flow.badge }}</text> <view>
<text class="status-progress">{{ flow.progressText }}</text> <text class="score-value">{{ latestRecord.score }} 分</text>
<text class="score-meta">AI 初评 · {{ latestRecord.result }}</text>
</view> </view>
<text class="status-title">{{ flow.notice }}</text> <text class="review-badge">{{ reviewStatusLabel }}</text>
<text class="status-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view> </view>
<button class="primary-button next-button" :disabled="loading" @click="runCurrentAction"> <text class="score-summary">{{ latestRecord.summary }}</text>
{{ flow.stage === 'review' || flow.stage === 'complete' ? '刷新进度' : flow.cta }} <text class="score-evidence">{{ questionRecords.length }} 道题 · 已保存真实回答</text>
</button>
</view> </view>
<view class="panel steps-panel"> <view class="question-card">
<text class="panel-title">办理流程</text> <text class="section-title">问答记录</text>
<view class="step-list"> <view v-for="item in questionRecords" :key="item.seq" class="question-record">
<view v-for="step in steps" :key="step.index" class="step-row" :class="step.status"> <view class="question-title-row">
<view class="step-rail"> <text class="question-index">{{ item.seq }}</text>
<text class="step-dot">{{ step.status === 'complete' ? '✓' : step.index }}</text> <text class="question-title">{{ item.question }}</text>
<view v-if="step.index < steps.length" class="step-line" />
</view>
<view class="step-copy">
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
<text class="step-status">{{ stepStatusLabel(step.status) }}</text>
</view> </view>
<text class="answer-text">回答:{{ item.answer }}</text>
<text class="answer-rating" :class="ratingTone(item.rating)">{{ item.rating }}</text>
</view> </view>
<view v-if="!questionRecords.length" class="empty question-empty">暂无问答明细</view>
</view> </view>
<view class="panel evidence-panel"> <view class="review-status-card">
<text class="panel-title">办理依据</text> <text class="review-status-title">人工复核状态</text>
<view v-if="latestRecord" class="evidence-row"> <text class="review-status-desc">{{ reviewStatusDescription }}</text>
<text class="evidence-label">面试记录</text>
<view class="evidence-copy">
<text class="evidence-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="evidence-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="evidence-row">
<text class="evidence-label">最新资料</text>
<view class="evidence-copy">
<view class="material-title-row">
<text class="evidence-title">{{ latestMaterial.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(latestMaterial.status)">{{ latestMaterial.status }}</text>
</view>
<text class="evidence-desc">{{ latestMaterial.time }} · {{ formatCandidateFileSize(latestMaterial.fileSize) }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view> </view>
<button class="primary-button home-button" @click="goBack">返回候选人中心</button>
</template> </template>
<view v-else class="panel empty-record-panel">
<text class="panel-title">暂无面试记录</text>
<text class="muted block">完成一次结构化面试后,这里会展示评分和问答明细。</text>
<button class="primary-button home-button" @click="openInterview">开始面试</button>
</view>
</view> </view>
</template> </template>
@@ -85,84 +66,51 @@ import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewRecord } from '@/types/api'; import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate'; import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate';
import { import { candidateRoutes } from '@/services/candidate-view';
buildCandidateFlow,
buildCandidateSteps,
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/progress/index'; const currentPath = '/pages/candidate/progress/index';
const records = ref<InterviewRecord[]>([]); const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]); const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const loading = ref(false);
const message = ref(''); const message = ref('');
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const steps = computed(() => buildCandidateSteps(flow.value));
const latestRecord = computed(() => records.value[0] || null); const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null); const latestMaterial = computed(() => materials.value[0] || null);
const questionRecords = computed(() => latestRecord.value?.questions || []);
const refreshAuthState = () => { const reviewStatusLabel = computed(() => latestMaterial.value?.status === '已通过' ? '人工复核完成' : '人工复核中');
loggedIn.value = isLoggedIn(); const reviewStatusDescription = computed(() => latestMaterial.value?.status === '已通过'
}; ? 'HR 已完成 AI 评分与补充资料复核,后续通知会同步更新。'
: 'HR 正在复核 AI 评分与补充资料,预计 1-2 个工作日更新。');
const clearData = () => {
records.value = [];
materials.value = [];
};
const loadPage = async () => { const loadPage = async () => {
refreshAuthState(); loggedIn.value = isLoggedIn();
message.value = '';
if (!loggedIn.value) { if (!loggedIn.value) {
clearData(); records.value = [];
materials.value = [];
return; return;
} }
if (loading.value) return;
loading.value = true;
try { try {
const [nextMaterials, nextRecords] = await Promise.all([ [materials.value, records.value] = await Promise.all([
getCandidateMaterials(), getCandidateMaterials(),
getCandidateInterviewRecords() getCandidateInterviewRecords()
]); ]);
materials.value = nextMaterials; message.value = '';
records.value = nextRecords;
} catch (error) { } catch (error) {
refreshAuthState(); loggedIn.value = isLoggedIn();
if (!loggedIn.value) { message.value = loggedIn.value ? (error instanceof Error ? error.message : '面试记录加载失败') : '登录已过期,请重新登录';
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '办理进度加载失败';
}
} finally {
loading.value = false;
} }
}; };
const runCurrentAction = () => { const ratingTone = (rating: string) => rating === '优秀' ? 'excellent' : rating === '良好' ? 'good' : 'needs-work';
if (flow.value.stage === 'review' || flow.value.stage === 'complete') {
void loadPage();
return;
}
uni.navigateTo({ url: flow.value.route });
};
const stepStatusLabel = (status: 'complete' | 'current' | 'pending') => {
if (status === 'complete') return '已完成';
if (status === 'current') return '当前';
return '待办理';
};
const goLogin = () => { const goLogin = () => {
rememberLoginRedirect(currentPath); rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' }); uni.redirectTo({ url: '/pages/auth/login/index' });
}; };
const openInterview = () => uni.navigateTo({ url: candidateRoutes.interview });
const goBack = () => { const goBack = () => {
if (getCurrentPages().length <= 1) { if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index }); uni.redirectTo({ url: candidateRoutes.index });
@@ -178,228 +126,189 @@ onShow(() => {
</script> </script>
<style scoped> <style scoped>
.candidate-subpage { .candidate-record-page {
background: #f4f7fb; background: #f9f9f9;
} }
.candidate-subpage .panel, .record-header {
.candidate-subpage button { display: flex;
border-radius: 8px; align-items: flex-start;
gap: 12px;
padding: 10px 4px 16px;
} }
.login-panel, .back-button {
.status-head, width: 28px;
.evidence-row, min-width: 28px;
.material-title-row { height: 28px;
padding: 0;
border: 0;
background: transparent;
color: #111;
font-size: 24px;
line-height: 28px;
}
.page-subtitle {
display: block;
margin-top: 5px;
color: #858991;
font-size: 13px;
}
.score-card,
.question-card,
.review-status-card {
border-radius: 18px;
background: #fff;
}
.score-card {
padding: 18px 16px;
}
.score-card-head,
.question-title-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.refresh-button { .score-value {
min-width: 76px; display: block;
padding: 0 10px; color: #e60012;
font-size: 28px;
font-weight: 800;
}
.score-meta,
.score-evidence {
display: block;
margin-top: 5px;
color: #858991;
font-size: 13px; font-size: 13px;
} }
.score-evidence {
margin-top: 16px;
font-size: 12px;
}
.score-summary {
display: block;
margin-top: 12px;
color: #858991;
font-size: 13px;
line-height: 1.5;
}
.review-badge {
padding: 6px 10px;
border-radius: 16px;
background: #fff4df;
color: #d98200;
font-size: 12px;
white-space: nowrap;
}
.question-card {
margin-top: 14px;
padding: 18px 16px;
}
.section-title {
color: #111;
font-size: 18px;
font-weight: 700;
}
.question-record {
padding: 18px 0 0;
}
.question-index {
display: inline-flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fff0f1;
color: #e60012;
font-size: 12px;
}
.question-title {
flex: 1;
color: #111;
font-size: 14px;
line-height: 1.45;
}
.answer-text {
display: block;
margin: 8px 0 0 34px;
color: #858991;
font-size: 12px;
line-height: 1.5;
}
.answer-rating {
display: block;
margin: 6px 0 0 34px;
font-size: 12px;
font-weight: 700;
}
.answer-rating.excellent { color: #16a34a; }
.answer-rating.good { color: #e60012; }
.answer-rating.needs-work { color: #d98200; }
.review-status-card {
margin-top: 14px;
padding: 16px;
background: #eef6ff;
}
.review-status-title {
display: block;
color: #111;
font-size: 15px;
font-weight: 700;
}
.review-status-desc {
display: block;
margin-top: 6px;
color: #858991;
font-size: 12px;
line-height: 1.5;
}
.home-button {
width: 100%;
margin-top: 14px;
}
.login-panel,
.empty-record-panel {
border-radius: 18px;
}
.block { .block {
display: block; display: block;
margin-top: 8px; margin-top: 8px;
font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
.login-button { .login-button {
min-width: 96px; min-width: 96px;
}
.status-panel {
border-left: 4px solid #e60012;
}
.status-badge,
.status-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.status-title {
display: block;
margin-top: 14px; margin-top: 14px;
color: #172033;
font-size: 21px;
font-weight: 800;
} }
.status-desc { .question-empty {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin-top: 16px; margin-top: 16px;
overflow: hidden;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.next-button {
width: 100%;
margin-top: 16px;
}
.step-list {
margin-top: 16px;
}
.step-row {
display: grid;
min-height: 76px;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 12px;
}
.step-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.step-row.complete .step-dot {
background: #dcfce7;
color: #166534;
}
.step-row.current .step-dot {
background: #fee2e2;
color: #b91c1c;
}
.step-line {
width: 2px;
flex: 1;
background: #e2e8f0;
}
.step-row.complete .step-line {
background: #22c55e;
}
.step-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.step-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.step-status {
color: #64748b;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.step-row.current .step-status {
color: #b91c1c;
}
.step-row.complete .step-status {
color: #15803d;
}
.evidence-row {
justify-content: flex-start;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.evidence-label {
flex: 0 0 64px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.evidence-copy {
min-width: 0;
flex: 1;
}
.evidence-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.evidence-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
} }
</style> </style>
@@ -92,17 +92,38 @@
</view> </view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view> <view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view> </view>
<view class="panel progress-panel">
<view class="section-head">
<text class="panel-title">派发进度与预警</text>
<text class="muted">{{ progressSummary }}</text>
</view>
<view class="progress-metrics">
<view><text>{{ progressCounts.pending }}</text><label>待训练</label></view>
<view><text>{{ progressCounts.completed }}</text><label>已完成</label></view>
<view><text>{{ progressCounts.retry }}</text><label>低分再练</label></view>
</view>
<view v-if="alerts.length" class="assignment-alert-list">
<view v-for="alert in alerts" :key="alert.type" class="assignment-alert" :class="alert.severity">
<text>{{ alert.title }}</text>
<text>{{ alert.desc }}</text>
</view>
</view>
<view v-else class="detail-empty">当前暂无团队预警</view>
</view>
</template> </template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment } from '@/types/api'; import type { PracticeAlert, PracticeAssignment } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeTeamSnapshot } from '@/services/practice'; import { createPracticeAssignment, getPracticeAlerts, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { import {
createAsyncGenerationGuard, createAsyncGenerationGuard,
inferSupervisorScenarioId, inferSupervisorScenarioId,
@@ -118,6 +139,7 @@ const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking'); const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const assignmentReason = ref(''); const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
const assigning = ref(false); const assigning = ref(false);
@@ -132,6 +154,15 @@ const selectedTargetLabel = computed(() => {
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target; return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
}); });
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10)); const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const progressCounts = computed(() => {
const items = recentAssignments.value;
return {
pending: items.filter((item) => item.status !== '已完成').length,
completed: items.filter((item) => item.status === '已完成').length,
retry: items.filter((item) => item.source === 'retry').length
};
});
const progressSummary = computed(() => `${progressCounts.value.pending} 项未完成`);
const queryText = (value: unknown) => { const queryText = (value: unknown) => {
if (typeof value !== 'string') return ''; if (typeof value !== 'string') return '';
@@ -159,6 +190,7 @@ const resetDraft = () => {
const clearPrivateState = () => { const clearPrivateState = () => {
resetDraft(); resetDraft();
assignments.value = []; assignments.value = [];
alerts.value = [];
historyLoading.value = false; historyLoading.value = false;
historyRequests.invalidate(); historyRequests.invalidate();
}; };
@@ -191,10 +223,11 @@ const loadAssignments = async (force = false) => {
const generation = historyRequests.begin(); const generation = historyRequests.begin();
historyLoading.value = true; historyLoading.value = true;
try { try {
const snapshot = await getPracticeTeamSnapshot(); const [snapshot, nextAlerts] = await Promise.all([getPracticeTeamSnapshot(), getPracticeAlerts()]);
if (historyRequests.isCurrent(generation)) { if (historyRequests.isCurrent(generation)) {
accessDenied.value = false; accessDenied.value = false;
assignments.value = snapshot.assignments; assignments.value = snapshot.assignments;
alerts.value = nextAlerts;
} }
} catch (error) { } catch (error) {
if (historyRequests.isCurrent(generation)) { if (historyRequests.isCurrent(generation)) {
@@ -306,8 +339,8 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.assign-page { .assign-page {
padding-bottom: 42px; padding-bottom: 112px;
background: #f4f7fb; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.login-panel, .login-panel,
@@ -368,8 +401,8 @@ onBeforeUnmount(() => {
} }
.scenario-chip.active { .scenario-chip.active {
background: #fee2e2; background: var(--employee-primary-soft);
color: #b91c1c; color: var(--employee-primary);
} }
.submit-button { .submit-button {
@@ -429,4 +462,80 @@ onBeforeUnmount(() => {
.compact-empty { .compact-empty {
padding: 18px 8px 4px; padding: 18px 8px 4px;
} }
.progress-panel {
margin-top: 14px;
}
.progress-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
padding: 12px 8px;
border-radius: 14px;
background: var(--employee-primary-soft);
text-align: center;
}
.progress-metrics view {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-metrics text {
color: var(--employee-primary);
font-size: 20px;
font-weight: 900;
}
.progress-metrics label {
color: var(--employee-muted);
font-size: 11px;
}
.assignment-alert-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.assignment-alert {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-left: 3px solid #f59e0b;
border-radius: 10px;
background: #fff7e6;
}
.assignment-alert.high {
border-left-color: #ef4444;
background: #fff1f2;
}
.assignment-alert text:first-child {
color: #a15c00;
font-size: 13px;
font-weight: 900;
}
.assignment-alert.high text:first-child {
color: #c2410c;
}
.assignment-alert text:last-child {
color: var(--employee-muted);
font-size: 12px;
line-height: 1.45;
}
.detail-empty {
margin-top: 12px;
color: var(--employee-muted);
font-size: 12px;
}
</style> </style>
+15 -12
View File
@@ -123,6 +123,7 @@
</view> </view>
</view> </view>
</template> </template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="today" />
</view> </view>
</template> </template>
@@ -138,6 +139,7 @@ import type {
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice'; import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { import {
actionablePracticeAlerts, actionablePracticeAlerts,
buildSupervisorMetrics, buildSupervisorMetrics,
@@ -259,8 +261,8 @@ onShow(() => {
<style scoped> <style scoped>
.supervisor-page { .supervisor-page {
padding-bottom: 42px; padding-bottom: 112px;
background: #f4f7fb; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.page-head, .page-head,
@@ -295,27 +297,27 @@ onShow(() => {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1px; gap: 1px;
overflow: hidden; overflow: hidden;
border: 1px solid #e2e8f0; border: 1px solid #dbe7ff;
border-radius: 8px; border-radius: 8px;
background: #e2e8f0; background: #dbe7ff;
} }
.metric-item { .metric-item {
min-height: 104px; min-height: 104px;
padding: 14px; padding: 14px;
background: #f8fafc; background: #f8fbff;
} }
.metric-item.primary { .metric-item.primary {
background: #fff5f5; background: #eef4ff;
} }
.metric-item.primary .metric-value { .metric-item.primary .metric-value {
color: #e70014; color: var(--employee-primary);
} }
.metric-item.primary .metric-label { .metric-item.primary .metric-label {
color: #bd0010; color: var(--employee-primary);
} }
.metric-value, .metric-value,
@@ -361,15 +363,16 @@ onShow(() => {
gap: 12px; gap: 12px;
padding: 12px 0; padding: 12px 0;
border-radius: 0; border-radius: 0;
border-bottom: 1px solid #e2e8f0; border: 1px solid #e5edf7;
background: transparent; border-radius: 14px;
background: #ffffff;
text-align: left; text-align: left;
} }
.task-row:last-child, .task-row:last-child,
.alert-row:last-child, .alert-row:last-child,
.preview-row:last-child { .preview-row:last-child {
border-bottom: 0; border-bottom: 1px solid #e5edf7;
} }
.task-copy, .task-copy,
@@ -432,7 +435,7 @@ onShow(() => {
.preview-score { .preview-score {
min-width: 42px; min-width: 42px;
flex: 0 0 auto; flex: 0 0 auto;
color: #dc2626; color: var(--employee-primary);
font-size: 22px; font-size: 22px;
font-weight: 900; font-weight: 900;
text-align: right; text-align: right;
@@ -0,0 +1,108 @@
<template>
<view class="page supervisor-profile-page">
<view class="page-head">
<view>
<text class="page-title">我的主管工作台</text>
<text class="page-subtitle">账号与主管身份</text>
</view>
</view>
<view class="panel account-card">
<text class="panel-title">当前账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
<text class="muted account-note">主管端团队数据仅对当前项目范围可见。</text>
</view>
<view class="panel identity-card">
<text class="panel-title">主管身份</text>
<view class="identity-row">
<text class="identity-label">角色</text>
<text class="identity-value">项目主管</text>
</view>
<view class="identity-row">
<text class="identity-label">可操作范围</text>
<text class="identity-value">团队画像、复盘、专项派发</text>
</view>
</view>
<button class="secondary-button logout-button" @click="logout">退出登录</button>
<SupervisorTabBar active="profile" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
const phone = ref('');
const refreshAuth = () => {
phone.value = getAuth().phone;
};
const logout = () => {
clearAuth();
rememberLoginRedirect('/pages/supervisor/profile/index');
uni.redirectTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
resetPageScroll();
if (!isLoggedIn()) {
rememberLoginRedirect('/pages/supervisor/profile/index');
uni.redirectTo({ url: '/pages/auth/login/index' });
return;
}
refreshAuth();
});
</script>
<style scoped>
.supervisor-profile-page {
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.account-card,
.identity-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.account-phone {
color: var(--employee-primary);
font-size: 24px;
font-weight: 900;
}
.account-note {
font-size: 13px;
line-height: 1.5;
}
.identity-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
border-top: 1px solid var(--employee-line);
}
.identity-label {
color: var(--employee-muted);
}
.identity-value {
color: var(--employee-text);
font-weight: 700;
text-align: right;
}
.logout-button {
width: 100%;
margin-top: 16px;
}
</style>
@@ -154,6 +154,7 @@
<view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view> <view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button> <button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view> </view>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view> </view>
</template> </template>
@@ -165,6 +166,7 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice'; import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { import {
createAsyncGenerationGuard, createAsyncGenerationGuard,
isReviewEditable, isReviewEditable,
@@ -411,8 +413,8 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.review-page { .review-page {
padding-bottom: 42px; padding-bottom: 112px;
background: #f4f7fb; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.subpage-header { .subpage-header {
@@ -514,8 +516,8 @@ onBeforeUnmount(() => {
flex: 0 0 auto; flex: 0 0 auto;
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; border-radius: 8px;
background: #fee2e2; background: var(--employee-primary-soft);
color: #b91c1c; color: var(--employee-primary);
font-size: 15px; font-size: 15px;
font-weight: 900; font-weight: 900;
} }
@@ -618,8 +620,8 @@ onBeforeUnmount(() => {
} }
.scenario-chip.active { .scenario-chip.active {
background: #fee2e2; background: var(--employee-primary-soft);
color: #b91c1c; color: var(--employee-primary);
} }
.next-training-section { .next-training-section {
@@ -55,6 +55,7 @@
</view> </view>
<view v-else-if="!loading" class="empty">暂无待复盘记录</view> <view v-else-if="!loading" class="empty">暂无待复盘记录</view>
</view> </view>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view> </view>
</template> </template>
@@ -65,6 +66,7 @@ import type { PracticeAlert, PracticeRecord } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice'; import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view'; import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/reviews/index'; const currentPath = '/pages/supervisor/reviews/index';
@@ -176,8 +178,8 @@ onShow(() => {
<style scoped> <style scoped>
.reviews-page { .reviews-page {
padding-bottom: 42px; padding-bottom: 112px;
background: #f4f7fb; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.subpage-header { .subpage-header {
@@ -221,14 +223,14 @@ onShow(() => {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 13px 0; padding: 13px 0;
border-radius: 0; border: 1px solid #e5edf7;
border-bottom: 1px solid #e2e8f0; border-radius: 14px;
background: transparent; background: #ffffff;
text-align: left; text-align: left;
} }
.review-row:last-child { .review-row:last-child {
border-bottom: 0; border-bottom: 1px solid #e5edf7;
} }
.review-copy { .review-copy {
@@ -277,7 +279,7 @@ onShow(() => {
} }
.review-score text:first-child { .review-score text:first-child {
color: #dc2626; color: var(--employee-primary);
font-size: 24px; font-size: 24px;
font-weight: 900; font-weight: 900;
} }
+175 -11
View File
@@ -52,7 +52,13 @@
<text class="muted">按风险排序</text> <text class="muted">按风险排序</text>
</view> </view>
<view v-if="teamProfiles.length" class="team-list"> <view v-if="teamProfiles.length" class="team-list">
<view v-for="member in teamProfiles" :key="member.key" class="team-row" :class="member.risk"> <view
v-for="member in teamProfiles"
:key="member.key"
class="team-row"
:class="member.risk"
@click="selectMember(member)"
>
<view class="team-main"> <view class="team-main">
<view class="team-title-row"> <view class="team-title-row">
<text class="team-name">{{ member.displayName }}</text> <text class="team-name">{{ member.displayName }}</text>
@@ -65,12 +71,49 @@
<text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text> <text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text>
</view> </view>
</view> </view>
<button class="secondary-button team-action" @click="assignMember(member)">派专项</button> <button class="secondary-button team-action" @click.stop="assignMember(member)">派专项</button>
</view> </view>
</view> </view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view> <view v-else-if="!loading" class="empty">暂无团队画像数据</view>
</view> </view>
<view v-if="selectedMember" class="panel member-detail-panel">
<view class="section-head">
<text class="panel-title">{{ selectedMember.displayName }} · 能力详情</text>
<button class="text-button" @click="selectedMember = null">收起</button>
</view>
<view class="member-summary">
<view>
<text class="member-score">{{ selectedMember.latestScoreText }}</text>
<text class="member-summary-label">最近训练评分</text>
</view>
<view>
<text class="member-score">{{ memberRecords.length }}</text>
<text class="member-summary-label">训练次数</text>
</view>
<view>
<text class="member-score">{{ selectedMember.pendingReview }}</text>
<text class="member-summary-label">待复盘</text>
</view>
</view>
<text class="subsection-title">训练趋势</text>
<view v-if="memberRecords.length" class="trend-list">
<view v-for="item in memberRecords" :key="item.id || item.time" class="trend-row">
<text class="trend-scene">{{ item.scene }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ width: `${Math.max(0, Math.min(100, item.score))}%` }" /></view>
<text class="trend-score">{{ item.score }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无训练评分,先派发一次专项训练</view>
<text class="subsection-title">成长证据</text>
<view class="evidence-list">
<text>已完成 {{ memberRecords.filter((item) => item.status === '已完成').length }} 次训练</text>
<text>当前 {{ selectedMember.pendingAssignment }} 项专项待训练</text>
<text>{{ selectedMember.reason }}</text>
</view>
</view>
</template> </template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
</view> </view>
</template> </template>
@@ -81,6 +124,7 @@ import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/t
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice'; import { getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { import {
buildTeamProfiles, buildTeamProfiles,
inferSupervisorScenarioId, inferSupervisorScenarioId,
@@ -96,6 +140,7 @@ const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
const loading = ref(false); const loading = ref(false);
const message = ref(''); const message = ref('');
const selectedMember = ref<TeamProfile | null>(null);
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value)); const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
const riskCounts = computed(() => teamProfiles.value.reduce( const riskCounts = computed(() => teamProfiles.value.reduce(
@@ -105,11 +150,18 @@ const riskCounts = computed(() => teamProfiles.value.reduce(
}, },
{ high: 0, medium: 0, low: 0 } { high: 0, medium: 0, low: 0 }
)); ));
const memberRecords = computed(() => {
if (!selectedMember.value) return [];
return records.value
.filter((item) => item.extPartyId === selectedMember.value?.extPartyId)
.slice(0, 6);
});
const clearData = () => { const clearData = () => {
records.value = []; records.value = [];
assignments.value = []; assignments.value = [];
members.value = []; members.value = [];
selectedMember.value = null;
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -167,6 +219,10 @@ const assignMember = (member: TeamProfile) => {
uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` }); uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` });
}; };
const selectMember = (member: TeamProfile) => {
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
};
const goLogin = () => { const goLogin = () => {
rememberLoginRedirect(currentPath); rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' }); uni.redirectTo({ url: '/pages/auth/login/index' });
@@ -201,8 +257,8 @@ onShow(() => {
<style scoped> <style scoped>
.team-page { .team-page {
padding-bottom: 42px; padding-bottom: 112px;
background: #f4f7fb; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.subpage-header { .subpage-header {
@@ -239,7 +295,7 @@ onShow(() => {
display: grid; display: grid;
margin-top: 16px; margin-top: 16px;
overflow: hidden; overflow: hidden;
border: 1px solid #e2e8f0; border: 1px solid #dbe7ff;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -247,7 +303,7 @@ onShow(() => {
.risk-item { .risk-item {
padding: 13px 8px; padding: 13px 8px;
border-right: 1px solid #e2e8f0; border-right: 1px solid #dbe7ff;
text-align: center; text-align: center;
} }
@@ -281,7 +337,7 @@ onShow(() => {
} }
.risk-item.low .risk-value { .risk-item.low .risk-value {
color: #15803d; color: var(--employee-primary);
} }
.team-list { .team-list {
@@ -294,8 +350,10 @@ onShow(() => {
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 14px 0 14px 12px; padding: 14px 0 14px 12px;
border-bottom: 1px solid #e2e8f0; border: 1px solid #e5edf7;
border-left: 4px solid #22c55e; border-left: 4px solid var(--employee-primary);
border-radius: 14px;
background: #ffffff;
} }
.team-row:last-child { .team-row:last-child {
@@ -350,8 +408,8 @@ onShow(() => {
.team-tag { .team-tag {
padding: 4px 7px; padding: 4px 7px;
border-radius: 8px; border-radius: 8px;
background: #f1f5f9; background: var(--employee-primary-soft);
color: #64748b; color: var(--employee-primary);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
} }
@@ -363,4 +421,110 @@ onShow(() => {
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
} }
.member-detail-panel {
margin-top: 14px;
}
.text-button {
min-height: 32px;
padding: 0;
background: transparent;
color: var(--employee-primary);
font-size: 13px;
font-weight: 800;
}
.member-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
padding: 12px 8px;
border-radius: 14px;
background: var(--employee-primary-soft);
text-align: center;
}
.member-score,
.member-summary-label,
.trend-scene,
.trend-score,
.subsection-title,
.evidence-list text {
display: block;
}
.member-score {
color: var(--employee-primary);
font-size: 20px;
font-weight: 900;
}
.member-summary-label {
margin-top: 4px;
color: var(--employee-muted);
font-size: 11px;
}
.subsection-title {
margin-top: 16px;
color: var(--employee-text);
font-size: 14px;
font-weight: 900;
}
.trend-list {
margin-top: 8px;
}
.trend-row {
display: grid;
grid-template-columns: 92px 1fr 30px;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.trend-scene,
.trend-score {
overflow: hidden;
color: var(--employee-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.trend-score {
color: var(--employee-primary);
font-weight: 900;
text-align: right;
}
.trend-track {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #e7eefc;
}
.trend-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #6f9cff, #2f6bff);
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.evidence-list text,
.detail-empty {
color: var(--employee-muted);
font-size: 12px;
line-height: 1.5;
}
</style> </style>
+343
View File
@@ -0,0 +1,343 @@
<template>
<view class="page history-detail-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回我的页面" title="返回我的页面" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">训练详情</text>
<text class="page-subtitle">{{ detail ? `${detail.scene} · ${detail.time}` : '训练记录' }}</text>
</view>
<button class="ghost-button refresh-button" :disabled="loading" @click="loadDetail">
{{ loading ? '加载中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看训练详情。</text>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else-if="detail">
<view class="panel summary-panel">
<view class="summary-head">
<view>
<text class="detail-title">{{ detail.scene }}</text>
<text class="detail-meta">{{ detail.status }} · {{ detail.score }}分</text>
</view>
<text class="score-pill">{{ detail.score }}</text>
</view>
<text class="detail-desc">{{ detail.summary || detail.aiComment || '暂无训练总结' }}</text>
</view>
<view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text>
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view>
<text class="score-label">{{ item.label }}</text>
<text class="score-note">{{ item.note }}</text>
</view>
<text class="score-value">{{ item.value }}</text>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
<view v-if="hasAudio(turn)" class="audio-block">
<audio v-if="audioSrc(turn, index)" class="audio-player" controls :src="audioSrc(turn, index)" />
<button v-else class="secondary-button" :disabled="audioLoading[index]" @click="loadAudio(turn, index)">
{{ audioLoading[index] ? '加载中' : '加载录音' }}
</button>
<text v-if="audioErrors[index]" class="audio-error">{{ audioErrors[index] }}</text>
</view>
</view>
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">复盘建议</text>
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
<text class="annotation-sop">依据:{{ item.sopPoint }}</text>
</view>
<view v-if="detail.mentorRewrite" class="mentor-block">
<text class="subsection-title">推荐话术</text>
<text class="mentor-text">{{ detail.mentorRewrite }}</text>
</view>
</view>
</template>
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
<text class="empty">{{ message || '训练记录不存在或不属于当前账号' }}</text>
<button class="secondary-button" @click="goBack">返回我的页面</button>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { ref } from 'vue';
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getPracticeHistoryDetail } from '@/services/practice';
import { resetPageScroll } from '@/services/navigation';
const detail = ref<ReviewDetail | null>(null);
const reviewId = ref(0);
const loggedIn = ref(isLoggedIn());
const loading = ref(false);
const message = ref('');
const audioSources = ref<Record<number, string>>({});
const audioLoading = ref<Record<number, boolean>>({});
const audioErrors = ref<Record<number, string>>({});
const audioSrc = (turn: ReviewDialogue, index: number) => audioSources.value[index] || turn.audioUrl || '';
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
const releaseAudio = () => {
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
audioSources.value = {};
};
const loadAudio = async (turn: ReviewDialogue, index: number) => {
if (!turn.audioOssId || audioLoading.value[index]) return;
audioLoading.value = { ...audioLoading.value, [index]: true };
audioErrors.value = { ...audioErrors.value, [index]: '' };
try {
audioSources.value = {
...audioSources.value,
[index]: await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`)
};
} catch {
audioErrors.value = { ...audioErrors.value, [index]: '录音加载失败,请稍后重试' };
} finally {
audioLoading.value = { ...audioLoading.value, [index]: false };
}
};
const loadDetail = async () => {
loggedIn.value = isLoggedIn();
if (!loggedIn.value) return;
if (!reviewId.value) {
message.value = '缺少有效的训练记录ID';
return;
}
loading.value = true;
message.value = '';
releaseAudio();
try {
detail.value = await getPracticeHistoryDetail(reviewId.value);
} catch (error) {
detail.value = null;
message.value = error instanceof Error ? error.message : '训练详情加载失败';
} finally {
loading.value = false;
}
};
const goBack = () => uni.navigateBack({ delta: 1 });
const goLogin = () => {
rememberLoginRedirect(`/pages/user/history/index?id=${reviewId.value}`);
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onLoad((query) => {
reviewId.value = Number(query?.id || 0);
resetPageScroll();
void loadDetail();
});
onUnload(releaseAudio);
</script>
<style scoped>
.history-detail-page {
padding: 0 18px 96px;
}
.subpage-header,
.summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.subpage-header {
padding: 20px 0 16px;
}
.subpage-back-button {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
border: 1px solid #e1e2e5;
border-radius: 8px;
background: #fff;
color: #25282d;
font-size: 20px;
line-height: 34px;
}
.subpage-heading {
flex: 1;
min-width: 0;
}
.page-title,
.detail-title,
.panel-title,
.score-label,
.dialogue-label,
.annotation-issue,
.subsection-title {
display: block;
color: #25282d;
font-weight: 900;
}
.page-title {
font-size: 22px;
}
.page-subtitle,
.detail-meta,
.detail-desc,
.score-note,
.dialogue-text,
.annotation-rewrite,
.annotation-sop,
.mentor-text,
.muted,
.audio-error {
display: block;
color: #73767c;
font-size: 13px;
line-height: 1.5;
}
.page-subtitle,
.detail-meta {
margin-top: 4px;
}
.panel {
margin-bottom: 12px;
padding: 16px;
border: 1px solid #e1e2e5;
border-radius: 8px;
background: #fff;
}
.summary-panel {
border-top: 3px solid #e60012;
}
.score-pill {
color: #e60012;
font-size: 24px;
font-weight: 900;
}
.detail-desc {
margin-top: 14px;
}
.score-row,
.dialogue-row,
.annotation-row {
padding: 12px 0;
border-top: 1px solid #ececee;
}
.score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.score-note,
.dialogue-text,
.annotation-rewrite,
.annotation-sop {
margin-top: 4px;
}
.score-value {
color: #e60012;
font-size: 20px;
font-weight: 900;
}
.dialogue-row:first-of-type,
.annotation-row:first-of-type {
margin-top: 8px;
}
.dialogue-text,
.mentor-text {
color: #34373c;
}
.audio-block {
margin-top: 10px;
}
.audio-player {
width: 100%;
}
.audio-error {
margin-top: 6px;
color: #c43b3b;
}
.mentor-block {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #ececee;
}
.mentor-text {
margin-top: 6px;
}
.refresh-button,
.secondary-button,
.primary-button {
min-height: 36px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
}
.refresh-button,
.secondary-button {
border: 1px solid #dedee1;
background: #fff;
color: #66696f;
}
.primary-button {
border: 0;
background: #e60012;
color: #fff;
}
.login-panel,
.empty-panel {
text-align: center;
}
.login-button,
.empty-panel button {
margin-top: 14px;
}
</style>
+95 -68
View File
@@ -774,11 +774,12 @@ onShow(() => {
.practice-head::after { .practice-head::after {
position: absolute; position: absolute;
right: 28px; right: 20px;
bottom: 24px; bottom: 20px;
width: 56px; width: 42px;
height: 3px; height: 4px;
background: #e70014; border-radius: 4px;
background: var(--employee-primary);
content: ''; content: '';
} }
@@ -1058,7 +1059,7 @@ onShow(() => {
} }
.dialogue-card { .dialogue-card {
border-color: #f0dfe0; border-color: #dbe7ff;
box-shadow: var(--employee-card-shadow); box-shadow: var(--employee-card-shadow);
} }
@@ -1120,19 +1121,19 @@ onShow(() => {
} }
.emotion-fill { .emotion-fill {
background: #e45757; background: #6f9cff;
} }
.trust-fill { .trust-fill {
background: #d51b2b; background: #2f6bff;
} }
.red-flag { .red-flag {
padding: 9px 10px; padding: 9px 10px;
border-left: 3px solid #e70014; border-left: 3px solid #f59e0b;
border-radius: 4px; border-radius: 4px;
background: #fff2f3; background: #fff7e6;
color: #bd0010; color: #a15c00;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
line-height: 1.45; line-height: 1.45;
@@ -1162,19 +1163,19 @@ onShow(() => {
} }
.turn-row.trainee { .turn-row.trainee {
border-left-color: #e70014; border-left-color: #2f6bff;
background: #fff6f6; background: #eef4ff;
} }
.turn-row.coach { .turn-row.coach {
border-left-color: #d98282; border-left-color: #8bb1ff;
background: #fff5f5; background: #f4f7ff;
} }
.reply-actions button.recording-active { .reply-actions button.recording-active {
border-color: #e70014; border-color: #2f6bff;
background: #fff5f5; background: #eef4ff;
color: #e70014; color: #2f6bff;
} }
.turn-role { .turn-role {
@@ -1198,9 +1199,9 @@ onShow(() => {
.style-chip { .style-chip {
width: auto; width: auto;
border-color: #f0c8ca; border-color: #c9d9ff;
background: #ffffff; background: #ffffff;
color: #d51b2b; color: #2f6bff;
} }
.reply-card { .reply-card {
@@ -1210,7 +1211,7 @@ onShow(() => {
.practice-textarea { .practice-textarea {
min-height: 124px; min-height: 124px;
border-color: #f0dfe0; border-color: #dbe7ff;
border-radius: 18px; border-radius: 18px;
background: #f9fbff; background: #f9fbff;
} }
@@ -1230,8 +1231,8 @@ onShow(() => {
.help-ref { .help-ref {
padding: 5px 8px; padding: 5px 8px;
border-radius: 5px; border-radius: 5px;
background: #fff1f2; background: #eef4ff;
color: #d51b2b; color: #2f6bff;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
} }
@@ -1243,7 +1244,7 @@ onShow(() => {
} }
.result-score { .result-score {
color: #e52323; color: #2f6bff;
font-size: 44px; font-size: 44px;
font-weight: 900; font-weight: 900;
line-height: 1; line-height: 1;
@@ -1261,55 +1262,56 @@ onShow(() => {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding: 12px; padding: 12px;
border-left: 3px solid #e52323; border-left: 3px solid #2f6bff;
border-radius: 6px; border-radius: 6px;
background: #fff6f6; background: #eef4ff;
} }
.mentor-rewrite text:first-child { .mentor-rewrite text:first-child {
color: #c71927; color: #2f6bff;
font-weight: 900; font-weight: 900;
} }
/* 练页入口态:对应高保真原型,真实对话状态仍沿用下方业务面板 */ /* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
.practice-page { .practice-page {
padding: 0 18px 104px; padding: 0 16px 108px;
background: #fafafa; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.practice-head { .practice-head {
margin: 0 -18px; margin: 0 -16px;
padding: 36px 18px 26px; padding: 30px 20px 24px;
background: #ffffff; border-bottom: 1px solid var(--employee-line);
text-align: center; background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
text-align: left;
} }
.practice-head::after, .practice-head::after,
.practice-eyebrow, .practice-eyebrow,
.practice-tip { .practice-tip {
display: none; display: block;
} }
.practice-head .page-title { .practice-head .page-title {
color: #111111; color: var(--employee-text);
font-size: 38px; font-size: 30px;
font-weight: 900; font-weight: 900;
} }
.practice-head .page-subtitle { .practice-head .page-subtitle {
max-width: none; max-width: none;
margin-top: 8px; margin-top: 8px;
color: #858991; color: var(--employee-muted);
font-size: 16px; font-size: 14px;
} }
.scenario-card { .scenario-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin-top: 18px; margin-top: 16px;
padding: 22px 18px 18px; padding: 22px 18px 18px;
border: 0; border: 0;
border-radius: 20px; border-radius: var(--employee-card-radius);
border-left: 0; border-left: 0;
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06); box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
@@ -1323,13 +1325,13 @@ onShow(() => {
} }
.scenario-title { .scenario-title {
color: #111111; color: var(--employee-text);
font-size: 30px; font-size: 30px;
font-weight: 900; font-weight: 900;
} }
.scenario-desc { .scenario-desc {
color: #858991; color: var(--employee-muted);
font-size: 17px; font-size: 17px;
} }
@@ -1340,7 +1342,7 @@ onShow(() => {
width: 42%; width: 42%;
height: 108px; height: 108px;
border-radius: 50%; border-radius: 50%;
background: #fff1f2; background: var(--employee-primary-soft);
opacity: 0.9; opacity: 0.9;
} }
@@ -1352,7 +1354,7 @@ onShow(() => {
height: 76px; height: 76px;
border-radius: 12px; border-radius: 12px;
background: #ffffff; 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); transform: rotate(7deg);
} }
@@ -1363,7 +1365,7 @@ onShow(() => {
width: 48px; width: 48px;
height: 6px; height: 6px;
border-radius: 6px; border-radius: 6px;
background: #f5dfe1; background: #dce8ff;
content: ''; content: '';
} }
@@ -1386,11 +1388,11 @@ onShow(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 14px; border-radius: 14px;
background: #ef5b61; background: #7ba4ff;
color: #ffffff; color: #ffffff;
font-size: 19px; font-size: 19px;
letter-spacing: 3px; 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 { .visual-bubble::after {
@@ -1399,7 +1401,7 @@ onShow(() => {
bottom: -7px; bottom: -7px;
width: 14px; width: 14px;
height: 14px; height: 14px;
background: #ef5b61; background: #7ba4ff;
content: ''; content: '';
transform: rotate(35deg); transform: rotate(35deg);
} }
@@ -1421,27 +1423,47 @@ onShow(() => {
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
padding: 8px; padding: 8px;
border: 1px solid #f4d9da; border: 1px solid #dce8ff;
border-radius: 12px; border-radius: 12px;
background: #fffafa; background: #f8fbff;
} }
.prep-mini .prep-label { .prep-mini .prep-label {
color: #e70014; color: var(--employee-primary);
font-size: 15px; font-size: 15px;
font-weight: 800; font-weight: 800;
} }
.prep-mini text:last-child { .prep-mini text:last-child {
color: #8c8f96; color: var(--employee-muted);
font-size: 11px; font-size: 11px;
line-height: 1.3; line-height: 1.3;
} }
.scenario-card .primary-button { .scenario-card .primary-button {
margin-top: 18px; margin-top: 18px;
background: #e70014; background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18); 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, .recent-panel,
@@ -1449,14 +1471,14 @@ onShow(() => {
margin-top: 14px; margin-top: 14px;
padding: 18px; padding: 18px;
border: 0; border: 0;
border-radius: 20px; border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05); box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
} }
.recent-link, .recent-link,
.daily-action-label { .daily-action-label {
color: #858991; color: var(--employee-muted);
font-size: 14px; font-size: 14px;
} }
@@ -1481,8 +1503,8 @@ onShow(() => {
justify-content: center; justify-content: center;
margin-right: 12px; margin-right: 12px;
border-radius: 50%; border-radius: 50%;
background: #fff1f2; background: var(--employee-primary-soft);
color: #e52323; color: var(--employee-primary);
font-size: 24px; font-size: 24px;
font-weight: 800; font-weight: 800;
} }
@@ -1502,7 +1524,7 @@ onShow(() => {
.recent-row .assignment-desc { .recent-row .assignment-desc {
margin-top: 4px; margin-top: 4px;
color: #858991; color: var(--employee-muted);
} }
.recent-arrow { .recent-arrow {
@@ -1517,7 +1539,7 @@ onShow(() => {
.daily-subtitle { .daily-subtitle {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
color: #858991; color: var(--employee-muted);
font-size: 14px; font-size: 14px;
} }
@@ -1529,17 +1551,17 @@ onShow(() => {
gap: 5px; gap: 5px;
margin-top: 14px; margin-top: 14px;
padding: 0 14px; padding: 0 14px;
border: 1px dashed #f0c8ca; border: 1px dashed #cfe0ff;
border-radius: 14px; border-radius: 14px;
background: #fffafa; background: #f8fbff;
color: #858991; color: var(--employee-muted);
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.45;
text-align: center; text-align: center;
} }
.daily-empty text:first-child { .daily-empty text:first-child {
color: #d51b2b; color: var(--employee-primary);
font-size: 15px; font-size: 15px;
font-weight: 800; font-weight: 800;
} }
@@ -1556,13 +1578,18 @@ onShow(() => {
margin-top: 8px; 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 { .capability-strip {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0; gap: 0;
margin-top: 14px; margin-top: 14px;
padding: 16px 8px; padding: 16px 8px;
border-radius: 18px; border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05); box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
} }
@@ -1580,13 +1607,13 @@ onShow(() => {
} }
.capability-strip text:first-child { .capability-strip text:first-child {
color: #1a1a1a; color: var(--employee-text);
font-size: 16px; font-size: 16px;
font-weight: 800; font-weight: 800;
} }
.capability-strip text:last-child { .capability-strip text:last-child {
color: #8a8d93; color: var(--employee-muted);
font-size: 11px; font-size: 11px;
} }
</style> </style>
+92 -60
View File
@@ -1,5 +1,5 @@
<template> <template>
<view class="page profile-page"> <view class="page profile-page employee-page-shell">
<view class="profile-head"> <view class="profile-head">
<text class="profile-eyebrow">银城物业 · 员工成长</text> <text class="profile-eyebrow">银城物业 · 员工成长</text>
<text class="page-title">我的成长</text> <text class="page-title">我的成长</text>
@@ -120,7 +120,12 @@
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button> <button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
</view> </view>
<view v-if="history.length" class="history-list"> <view v-if="history.length" class="history-list">
<view v-for="item in history" :key="item.id || item.sessionId || item.time" class="history-row"> <view
v-for="item in history"
:key="item.id || item.sessionId || item.time"
class="history-row history-row-clickable"
@click="openHistory(item.id)"
>
<view> <view>
<text class="history-title">{{ item.scene }}</text> <text class="history-title">{{ item.scene }}</text>
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text> <text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
@@ -231,6 +236,14 @@ const changePosition = (position: UserPosition) => {
uni.showToast({ title: '岗位已更新', icon: 'none' }); uni.showToast({ title: '岗位已更新', icon: 'none' });
}; };
const openHistory = (id?: number) => {
if (!id) {
message.value = '这条训练记录暂不支持查看详情';
return;
}
uni.navigateTo({ url: `/pages/user/history/index?id=${id}` });
};
onShow(() => { onShow(() => {
resetPageScroll(); resetPageScroll();
void loadData(); void loadData();
@@ -239,45 +252,52 @@ onShow(() => {
<style scoped> <style scoped>
.profile-page { .profile-page {
padding: 0 18px 96px; padding: 0 16px 108px;
background: #f7f7f8; }
.profile-page .panel {
border-radius: var(--employee-card-radius);
box-shadow: var(--employee-card-shadow);
} }
.profile-head { .profile-head {
position: relative; position: relative;
margin: 0 -18px; margin: 0 -16px;
padding: 34px 28px 26px; padding: 30px 20px 26px;
background: #f0f1ee; border-bottom: 1px solid rgba(47, 74, 117, 0.08);
background: linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%);
} }
.profile-head::after { .profile-head::after {
position: absolute; position: absolute;
right: 28px; right: 20px;
bottom: 24px; bottom: 20px;
width: 56px; width: 42px;
height: 3px; height: 4px;
background: #e70014; border-radius: 4px;
background: var(--employee-primary);
content: ''; content: '';
} }
.profile-eyebrow { .profile-eyebrow {
display: block; display: block;
margin-bottom: 12px; margin-bottom: 10px;
color: #168243; color: var(--employee-primary);
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
line-height: 1; letter-spacing: 0.04em;
line-height: 1.2;
} }
.profile-head .page-title { .profile-head .page-title {
color: #25282d; color: var(--employee-text);
font-size: 32px; font-size: 28px;
line-height: 1.16; line-height: 1.2;
} }
.profile-head .page-subtitle { .profile-head .page-subtitle {
margin-top: 10px; margin-top: 10px;
color: #696c72; color: var(--employee-muted);
font-size: 14px; font-size: 14px;
} }
@@ -290,15 +310,14 @@ onShow(() => {
} }
.account-card { .account-card {
margin-top: 22px; margin-top: 16px;
border-left: 4px solid #e70014; border-left: 4px solid var(--employee-primary);
box-shadow: 0 8px 22px rgba(37, 40, 45, 0.08);
} }
.account-phone { .account-phone {
display: block; display: block;
margin-top: 8px; margin-top: 8px;
color: #676a70; color: var(--employee-muted);
font-size: 14px; font-size: 14px;
} }
@@ -317,7 +336,7 @@ onShow(() => {
.position-title { .position-title {
display: block; display: block;
margin-top: 8px; margin-top: 8px;
color: #168243; color: var(--employee-primary);
font-size: 16px; font-size: 16px;
font-weight: 900; font-weight: 900;
} }
@@ -334,24 +353,24 @@ onShow(() => {
min-height: 38px; min-height: 38px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid #dedfe2; border: 1px solid #dce5f2;
border-radius: 6px; border-radius: var(--employee-chip-radius);
background: #fbfbfb; background: #f8fbff;
color: #5e6167; color: #526176;
font-size: 13px; font-size: 13px;
font-weight: 900; font-weight: 900;
white-space: nowrap; white-space: nowrap;
} }
.position-chip.active { .position-chip.active {
border-color: #168243; border-color: var(--employee-primary);
background: #edf7f0; background: var(--employee-primary-soft);
color: #168243; color: var(--employee-primary);
} }
.profile-summary { .profile-summary {
border-color: #d9e7de; border-color: var(--employee-line);
border-top: 3px solid #168243; border-top: 3px solid var(--employee-primary);
} }
.score-row { .score-row {
@@ -367,12 +386,12 @@ onShow(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 8px; border-radius: 18px;
background: #edf7f0; background: var(--employee-primary-soft);
} }
.score-value { .score-value {
color: #168243; color: var(--employee-primary);
font-size: 42px; font-size: 42px;
font-weight: 900; font-weight: 900;
line-height: 1; line-height: 1;
@@ -380,7 +399,7 @@ onShow(() => {
.score-label { .score-label {
margin-top: 8px; margin-top: 8px;
color: #5f6268; color: var(--employee-muted);
font-size: 13px; font-size: 13px;
} }
@@ -396,26 +415,27 @@ onShow(() => {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 8px 10px; padding: 8px 10px;
border-radius: 6px; border: 1px solid var(--employee-line);
background: #f4f4f5; border-radius: 14px;
background: #fbfdff;
} }
.metric text:first-child { .metric text:first-child {
color: #303338; color: var(--employee-text);
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.metric text:last-child { .metric text:last-child {
margin-top: 4px; margin-top: 4px;
color: #73767c; color: var(--employee-muted);
font-size: 12px; font-size: 12px;
} }
.evidence-panel { .evidence-panel {
border-color: #eadfbd; border-color: #f4e4c4;
border-left: 4px solid #d98200; border-left: 4px solid var(--employee-warning);
background: #fffdf7; background: #fffaf2;
} }
.evidence-grid { .evidence-grid {
@@ -431,19 +451,20 @@ onShow(() => {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 8px 10px; padding: 8px 10px;
border-radius: 6px; border: 1px solid #f4e7ce;
border-radius: 14px;
background: #ffffff; background: #ffffff;
} }
.evidence-metric text:first-child { .evidence-metric text:first-child {
color: #a86100; color: #b56b14;
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.evidence-metric text:last-child { .evidence-metric text:last-child {
margin-top: 4px; margin-top: 4px;
color: #73767c; color: var(--employee-muted);
font-size: 12px; font-size: 12px;
} }
@@ -494,14 +515,14 @@ onShow(() => {
} }
.growth-title { .growth-title {
color: #34373c; color: var(--employee-text);
font-size: 14px; font-size: 14px;
font-weight: 900; font-weight: 900;
} }
.growth-desc { .growth-desc {
margin-top: 5px; margin-top: 5px;
color: #73767c; color: var(--employee-muted);
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
@@ -513,7 +534,7 @@ onShow(() => {
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
color: #168243; color: var(--employee-primary);
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
} }
@@ -523,13 +544,13 @@ onShow(() => {
height: 6px; height: 6px;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: 4px;
background: #dce8e0; background: #dce8fb;
} }
.growth-fill { .growth-fill {
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: #168243; background: var(--employee-primary);
} }
.dimension-row, .dimension-row,
@@ -545,7 +566,7 @@ onShow(() => {
.dimension-title, .dimension-title,
.history-title { .history-title {
display: block; display: block;
color: #34373c; color: var(--employee-text);
font-weight: 900; font-weight: 900;
} }
@@ -553,13 +574,13 @@ onShow(() => {
.history-desc { .history-desc {
display: block; display: block;
margin-top: 5px; margin-top: 5px;
color: #73767c; color: var(--employee-muted);
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
} }
.dimension-score { .dimension-score {
color: #168243; color: var(--employee-primary);
font-size: 20px; font-size: 20px;
font-weight: 900; font-weight: 900;
} }
@@ -567,14 +588,25 @@ onShow(() => {
.refresh-button { .refresh-button {
min-height: 36px; min-height: 36px;
padding: 0 12px; padding: 0 12px;
border: 1px solid #dedee1; border: 1px solid #dce5f2;
border-radius: var(--employee-chip-radius);
background: #ffffff; background: #ffffff;
color: #66696f; color: #526176;
font-size: 13px; font-size: 13px;
} }
.history-panel { .history-panel {
border-color: #e1e2e5; border-color: var(--employee-line);
}
.history-row-clickable {
min-height: 68px;
padding: 14px 4px;
cursor: pointer;
}
.history-row-clickable:active {
opacity: 0.72;
} }
.history-score { .history-score {
@@ -585,14 +617,14 @@ onShow(() => {
} }
.history-score text:first-child { .history-score text:first-child {
color: #168243; color: var(--employee-primary);
font-size: 20px; font-size: 20px;
font-weight: 900; font-weight: 900;
} }
.history-score text:last-child { .history-score text:last-child {
margin-top: 4px; margin-top: 4px;
color: #73767c; color: var(--employee-muted);
font-size: 12px; font-size: 12px;
} }
</style> </style>
+138 -82
View File
@@ -61,8 +61,14 @@
</view> </view>
<view v-if="result" class="panel answer-panel employee-card"> <view v-if="result" class="panel answer-panel employee-card">
<text class="panel-title">直给答案</text> <text class="panel-title">师傅回答</text>
<view class="question-card">
<text class="question-label">我的问题</text>
<text class="question-text">{{ query }}</text>
</view>
<view class="answer-copy-card">
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text> <text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
</view>
<view v-if="result.keyPoints?.length" class="keypoints"> <view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint"> <text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
@@ -81,7 +87,7 @@
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)" :disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('up')" @click="feedback('up')"
> >
有用 有帮助
</button> </button>
<button <button
class="secondary-button" class="secondary-button"
@@ -89,9 +95,12 @@
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)" :disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')" @click="feedback('down')"
> >
需要复核 没解决
</button> </button>
</view> </view>
<text v-if="feedbackVerdict" class="feedback-status">
{{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }}
</text>
</view> </view>
<view v-if="result?.snippets?.length" class="panel sources-panel"> <view v-if="result?.snippets?.length" class="panel sources-panel">
@@ -115,7 +124,7 @@
<view class="modal-card" @click.stop> <view class="modal-card" @click.stop>
<view class="modal-header"> <view class="modal-header">
<text class="modal-title">{{ summaryCard.title }}</text> <text class="modal-title">{{ summaryCard.title }}</text>
<button class="modal-close" @click="closeSummary">×</button> <button class="modal-close" aria-label="关闭总结卡" title="关闭总结卡" @click="closeSummary">×</button>
</view> </view>
<scroll-view scroll-y class="modal-body"> <scroll-view scroll-y class="modal-body">
<view v-if="summaryCard.steps?.length" class="info-section"> <view v-if="summaryCard.steps?.length" class="info-section">
@@ -378,24 +387,26 @@ onShow(() => {
<style scoped> <style scoped>
.sop-page { .sop-page {
padding: 0 18px 96px; padding: 0 16px 108px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%); background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.sop-head { .sop-head {
position: relative; position: relative;
margin: 0 -18px; margin: 0 -16px;
padding: 30px 28px 24px; padding: 30px 20px 24px;
border-bottom: 1px solid var(--employee-line);
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%); background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
} }
.sop-head::after { .sop-head::after {
position: absolute; position: absolute;
right: 28px; right: 20px;
bottom: 24px; bottom: 20px;
width: 56px; width: 42px;
height: 3px; height: 4px;
background: #e70014; border-radius: 4px;
background: var(--employee-primary);
content: ''; content: '';
} }
@@ -436,7 +447,7 @@ onShow(() => {
} }
.query-panel { .query-panel {
margin-top: 22px; margin-top: 16px;
padding: 20px; padding: 20px;
border-top: 0; border-top: 0;
box-shadow: var(--employee-card-shadow); box-shadow: var(--employee-card-shadow);
@@ -523,13 +534,43 @@ onShow(() => {
} }
.answer-panel { .answer-panel {
border-color: #d9e7de; border-color: var(--employee-line);
border-left: 4px solid #168243; border-left: 4px solid var(--employee-primary);
box-shadow: var(--employee-card-shadow); box-shadow: var(--employee-card-shadow);
} }
.answer-panel .panel-title { .answer-panel .panel-title {
color: #168243; color: var(--employee-primary);
}
.question-card,
.answer-copy-card {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding: 14px 16px;
border-radius: 14px;
}
.question-card {
background: #fff1f1;
}
.question-label {
color: #e34b4b;
font-size: 13px;
font-weight: 700;
}
.question-text {
color: #25282d;
font-size: 16px;
line-height: 1.55;
}
.answer-copy-card {
background: #fff;
} }
.keypoints { .keypoints {
@@ -568,15 +609,25 @@ onShow(() => {
} }
.feedback-actions .active { .feedback-actions .active {
border-color: #168243; border-color: var(--employee-primary);
background: #edf7f0; background: var(--employee-primary-soft);
color: #168243; color: var(--employee-primary);
} }
.feedback-actions .negative { .feedback-actions .negative {
border-color: #e70014; border-color: var(--employee-danger);
background: #fff1f2; background: #fff1f0;
color: #bd0010; color: #c34d45;
}
.feedback-status {
display: block;
padding: 10px 12px;
border-radius: 12px;
background: var(--employee-primary-soft);
color: var(--employee-primary);
font-size: 13px;
line-height: 1.5;
} }
.sources-panel { .sources-panel {
@@ -600,7 +651,8 @@ onShow(() => {
gap: 6px; gap: 6px;
padding: 14px; padding: 14px;
border: 1px solid #e3e4e7; border: 1px solid #e3e4e7;
border-radius: 8px; border-color: var(--employee-line);
border-radius: 18px;
background: #ffffff; background: #ffffff;
box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04); box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04);
} }
@@ -629,8 +681,8 @@ onShow(() => {
} }
.summary-panel { .summary-panel {
border-color: #eadfbd; border-color: #f4e4c4;
background: #fffdf7; background: #fffaf2;
box-shadow: var(--employee-card-shadow); box-shadow: var(--employee-card-shadow);
} }
@@ -719,7 +771,8 @@ onShow(() => {
.footer-btn { .footer-btn {
width: 100%; width: 100%;
background: #168243; background: var(--employee-primary);
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.18);
color: #ffffff; color: #ffffff;
} }
@@ -915,55 +968,55 @@ onShow(() => {
line-height: 1.45; line-height: 1.45;
} }
/* 视觉稿对齐:与练页统一的红白卡片体系 */ /* 视觉稿对齐:与员工端练页统一的蓝白轻卡片体系 */
.sop-page { .sop-page {
padding: 0 18px 96px; padding: 0 16px 108px;
background: #fafafa; background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
.sop-head { .sop-head {
margin: 0 -18px; margin: 0 -16px;
padding: 34px 18px 24px; padding: 30px 20px 24px;
background: #ffffff; border-bottom: 1px solid var(--employee-line);
text-align: center; background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
text-align: left;
} }
.sop-head::after,
.sop-eyebrow, .sop-eyebrow,
.toolbar-tip { .toolbar-tip {
display: none; display: block;
} }
.sop-head .page-title { .sop-head .page-title {
color: #111111; color: var(--employee-text);
font-size: 38px; font-size: 30px;
font-weight: 900; font-weight: 900;
} }
.sop-head .page-subtitle { .sop-head .page-subtitle {
max-width: none; max-width: none;
margin-top: 10px; margin-top: 10px;
color: #858991; color: var(--employee-muted);
font-size: 16px; font-size: 14px;
} }
.sop-context { .sop-context {
display: inline-flex; display: inline-flex;
margin-top: 14px; margin-top: 14px;
padding: 7px 14px; padding: 7px 14px;
border: 1px solid #f8d7d8; border: 1px solid #cfe0ff;
border-radius: 999px; border-radius: 999px;
background: #fff7f7; background: var(--employee-primary-soft);
color: #d51b2b; color: var(--employee-primary);
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
} }
.query-panel { .query-panel {
margin-top: 18px; margin-top: 16px;
padding: 22px 18px 18px; padding: 22px 18px 18px;
border: 0; border: 0;
border-radius: 20px; border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06); box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
} }
@@ -979,18 +1032,18 @@ onShow(() => {
.query-panel .chip { .query-panel .chip {
min-height: 40px; min-height: 40px;
border-color: #e52323; border-color: #cfe0ff;
border-radius: 999px; border-radius: 999px;
background: #ffffff; background: #ffffff;
color: #e52323; color: var(--employee-primary);
} }
.query-submit { .query-submit {
min-height: 54px; min-height: 54px;
margin-top: 8px; margin-top: 8px;
border-radius: 18px; border-radius: 18px;
background: #e52323; background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 8px 16px rgba(229, 35, 35, 0.18); box-shadow: 0 8px 18px rgba(47, 107, 255, 0.2);
} }
.knowledge-tip-card { .knowledge-tip-card {
@@ -1001,8 +1054,10 @@ onShow(() => {
padding: 16px; padding: 16px;
border: 0; border: 0;
border-radius: 18px; border-radius: 18px;
border: 1px solid var(--employee-line);
border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05); box-shadow: var(--employee-card-shadow);
} }
.knowledge-tip-icon { .knowledge-tip-icon {
@@ -1012,8 +1067,8 @@ onShow(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 14px; border-radius: 14px;
background: #fff0f1; background: var(--employee-primary-soft);
color: #e52323; color: var(--employee-primary);
font-size: 28px; font-size: 28px;
font-weight: 900; font-weight: 900;
} }
@@ -1050,7 +1105,7 @@ onShow(() => {
padding: 28px 18px; padding: 28px 18px;
flex-direction: column; flex-direction: column;
border: 0; border: 0;
border-radius: 20px; border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04); box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
} }
@@ -1075,67 +1130,68 @@ onShow(() => {
} }
.answer-panel { .answer-panel {
border-color: #f0dfe0; border-color: var(--employee-line);
border-left-color: #e52323; border-left-color: var(--employee-primary);
} }
.answer-panel .panel-title, .answer-panel .panel-title,
.result-score { .result-score {
color: #e52323; color: var(--employee-primary);
} }
.keypoint, .keypoint,
.speech-bubble { .speech-bubble {
border-left-color: #e52323; border-left-color: var(--employee-primary);
background: #fff5f5; background: var(--employee-primary-soft);
} }
.cautions, .cautions,
.reminder-item { .reminder-item {
border-left-color: #e52323; border-left-color: var(--employee-warning);
background: #fff5f5; background: #fff8ec;
color: #b71925; color: #8b5a1d;
} }
.feedback-actions .active { .feedback-actions .active {
border-color: #e52323; border-color: var(--employee-primary);
background: #fff0f1; background: var(--employee-primary-soft);
color: #e52323; color: var(--employee-primary);
} }
.feedback-actions .negative { .feedback-actions .negative {
border-color: #e52323; border-color: var(--employee-danger);
background: #fff0f1; background: #fff1f0;
color: #b71925; color: #c34d45;
} }
.summary-panel { .summary-panel {
border-color: #f0dfe0; border-color: #f4e4c4;
border-radius: var(--employee-card-radius);
background: #ffffff; background: #ffffff;
} }
.summary-panel .ghost-button { .summary-panel .ghost-button {
border-color: #e52323; border-color: #cfe0ff;
background: #ffffff; background: var(--employee-primary-soft);
color: #e52323; color: var(--employee-primary);
} }
.modal-card { .modal-card {
border-top-color: #e52323; border-top-color: var(--employee-primary);
} }
.badge-num, .badge-num,
.timeline-node { .timeline-node {
background: #e52323; background: var(--employee-primary);
} }
.a-badge { .a-badge {
background: #fff0f1; background: var(--employee-primary-soft);
color: #e52323; color: var(--employee-primary);
} }
.reminder-text { .reminder-text {
color: #b71925; color: #8b5a1d;
} }
/* 原型对齐:问师傅主卡的标题与对话插画 */ /* 原型对齐:问师傅主卡的标题与对话插画 */
@@ -1155,14 +1211,14 @@ onShow(() => {
} }
.query-title { .query-title {
color: #111111; color: var(--employee-text);
font-size: 30px; font-size: 30px;
font-weight: 900; font-weight: 900;
line-height: 1.1; line-height: 1.1;
} }
.query-subtitle { .query-subtitle {
color: #858991; color: var(--employee-muted);
font-size: 15px; font-size: 15px;
line-height: 1.45; line-height: 1.45;
} }
@@ -1174,7 +1230,7 @@ onShow(() => {
width: 42%; width: 42%;
height: 96px; height: 96px;
border-radius: 50%; border-radius: 50%;
background: #fff3f3; background: var(--employee-primary-soft);
} }
.query-bubble { .query-bubble {
@@ -1192,7 +1248,7 @@ onShow(() => {
width: 92px; width: 92px;
height: 56px; height: 56px;
border-radius: 28px 28px 28px 10px; border-radius: 28px 28px 28px 10px;
background: #f58d91; background: #7ba4ff;
color: #ffffff; color: #ffffff;
font-size: 18px; font-size: 18px;
} }
@@ -1203,7 +1259,7 @@ onShow(() => {
left: 18px; left: 18px;
width: 16px; width: 16px;
height: 16px; height: 16px;
background: #f58d91; background: #7ba4ff;
content: ''; content: '';
transform: rotate(28deg); transform: rotate(28deg);
} }
@@ -1215,8 +1271,8 @@ onShow(() => {
height: 42px; height: 42px;
border-radius: 22px 22px 8px 22px; border-radius: 22px 22px 8px 22px;
background: #ffffff; background: #ffffff;
box-shadow: 0 5px 14px rgba(229, 35, 35, 0.08); box-shadow: 0 5px 14px rgba(47, 107, 255, 0.1);
color: #f3b7b9; color: #9bb8ff;
font-size: 13px; font-size: 13px;
} }
+3 -1
View File
@@ -106,7 +106,9 @@
</view> </view>
<text class="schedule-desc">{{ primaryScheduleDesc }}</text> <text class="schedule-desc">{{ primaryScheduleDesc }}</text>
</view> </view>
<button class="schedule-action" @click="openPrimarySchedule">开始</button> <button class="schedule-action" @click="openPrimarySchedule">
{{ primaryAssignment?.source === 'daily' ? '去作答' : '开始练习' }}
</button>
</view> </view>
<view class="schedule-row" v-if="home?.review"> <view class="schedule-row" v-if="home?.review">
+3
View File
@@ -5,6 +5,7 @@ export const clientIdKey = 'aihr_mobile_client_id';
export const phoneKey = 'aihr_mobile_phone'; export const phoneKey = 'aihr_mobile_phone';
export const loginRedirectKey = 'aihr_mobile_login_redirect'; export const loginRedirectKey = 'aihr_mobile_login_redirect';
export const positionKey = 'aihr_mobile_position'; export const positionKey = 'aihr_mobile_position';
export const roleKey = 'aihr_mobile_role';
export const phonePattern = /^1[3-9]\d{9}$/; export const phonePattern = /^1[3-9]\d{9}$/;
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const; export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const;
@@ -106,6 +107,7 @@ export const saveAuth = (result: LoginResult, phone: string) => {
const previousPhone = uni.getStorageSync(phoneKey) || ''; const previousPhone = uni.getStorageSync(phoneKey) || '';
if (previousPhone && previousPhone !== phone) { if (previousPhone && previousPhone !== phone) {
uni.removeStorageSync(positionKey); uni.removeStorageSync(positionKey);
uni.removeStorageSync(roleKey);
} }
uni.setStorageSync(tokenKey, result.access_token); uni.setStorageSync(tokenKey, result.access_token);
uni.setStorageSync(clientIdKey, result.client_id); uni.setStorageSync(clientIdKey, result.client_id);
@@ -117,6 +119,7 @@ export const clearAuth = () => {
uni.removeStorageSync(clientIdKey); uni.removeStorageSync(clientIdKey);
uni.removeStorageSync(phoneKey); uni.removeStorageSync(phoneKey);
uni.removeStorageSync(positionKey); uni.removeStorageSync(positionKey);
uni.removeStorageSync(roleKey);
uni.removeStorageSync(loginRedirectKey); uni.removeStorageSync(loginRedirectKey);
}; };
+30 -1
View File
@@ -1,9 +1,10 @@
import type { OrgSnapshotResponse } from '@/types/api'; import type { MobileIdentity, OrgSnapshotResponse, RoleKey } from '@/types/api';
import { apiRequest } from './api'; import { apiRequest } from './api';
import { import {
getAuth, getAuth,
hasSelectedPosition, hasSelectedPosition,
normalizePosition, normalizePosition,
roleKey,
saveSelectedPosition, saveSelectedPosition,
type UserPosition type UserPosition
} from './auth'; } from './auth';
@@ -17,6 +18,31 @@ export const userTabPages = new Set([
export const isUserRoute = (url: string) => url.startsWith('/pages/user/'); export const isUserRoute = (url: string) => url.startsWith('/pages/user/');
export const isSupervisorRoute = (url: string) => url.startsWith('/pages/supervisor/');
export const routeForMobileRole = (role: RoleKey, requested: string) => {
if (role === 'supervisor' && (isUserRoute(requested) || isSupervisorRoute(requested))) {
return '/pages/supervisor/index/index';
}
return requested;
};
export const hydrateMobileRole = async (): Promise<RoleKey> => {
try {
const identity = await apiRequest<MobileIdentity>({
url: '/api/aihr/mobile/me',
method: 'GET',
timeout: 15000,
clearAuthOnUnauthorized: false
});
const role = identity.role === 'supervisor' ? 'supervisor' : 'user';
uni.setStorageSync(roleKey, role);
return role;
} catch {
return (uni.getStorageSync(roleKey) === 'supervisor' ? 'supervisor' : 'user') as RoleKey;
}
};
export const positionSetupUrl = (redirect: string) => export const positionSetupUrl = (redirect: string) =>
`/pages/auth/position/index?redirect=${encodeURIComponent(redirect || '/pages/user/today/index')}`; `/pages/auth/position/index?redirect=${encodeURIComponent(redirect || '/pages/user/today/index')}`;
@@ -46,6 +72,9 @@ export const hydratePositionFromOrg = async (phone = getAuth().phone): Promise<U
}; };
export const nextUserRouteAfterLogin = async (redirect: string) => { export const nextUserRouteAfterLogin = async (redirect: string) => {
const role = await hydrateMobileRole();
const roleRoute = routeForMobileRole(role, redirect);
if (roleRoute !== redirect) return roleRoute;
if (!isUserRoute(redirect) || hasSelectedPosition()) return redirect; if (!isUserRoute(redirect) || hasSelectedPosition()) return redirect;
const officialPosition = await hydratePositionFromOrg(); const officialPosition = await hydratePositionFromOrg();
return normalizePosition(officialPosition) ? redirect : positionSetupUrl(redirect); return normalizePosition(officialPosition) ? redirect : positionSetupUrl(redirect);
+7
View File
@@ -81,6 +81,13 @@ export const getPracticeHistory = () => {
}); });
}; };
export const getPracticeHistoryDetail = (id: number) =>
apiRequest<ReviewDetail>({
url: `/api/aihr/mobile/practice/history/${id}`,
method: 'GET',
timeout: 15000
});
export const getCompetencyProfile = () => { export const getCompetencyProfile = () => {
const extPartyId = encodeURIComponent(currentExtPartyId()); const extPartyId = encodeURIComponent(currentExtPartyId());
return apiRequest<CompetencyProfile>({ return apiRequest<CompetencyProfile>({
+19 -3
View File
@@ -12,8 +12,8 @@ page {
--ink: #182230; --ink: #182230;
--muted: #647083; --muted: #647083;
--line: #dfe4ea; --line: #dfe4ea;
--brand: #e70014; --brand: #e60012;
--brand-pressed: #bd0010; --brand-pressed: #b8000f;
--knowledge: #315b8f; --knowledge: #315b8f;
--success: #168243; --success: #168243;
--warning: #a86100; --warning: #a86100;
@@ -52,12 +52,24 @@ button::after {
border: 0; border: 0;
} }
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(230, 0, 18, 0.35);
outline-offset: 2px;
}
.page { .page {
min-height: 100vh; min-height: 100vh;
padding: 22px 16px 96px; padding: 22px 16px 96px;
} }
.employee-page-shell { .employee-page-shell {
--employee-bg: #f3f5f7;
--employee-bg-soft: #fafafa;
--employee-primary: #e60012;
--employee-primary-soft: #fff0f1;
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%); background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
} }
@@ -187,7 +199,11 @@ button::after {
.primary-button[disabled], .primary-button[disabled],
.secondary-button[disabled], .secondary-button[disabled],
.ghost-button[disabled] { .ghost-button[disabled] {
opacity: 0.55; opacity: 1;
border-color: #e1e4e8;
background: #f1f2f4;
box-shadow: none;
color: #9aa0a8;
} }
.input { .input {
+15
View File
@@ -11,6 +11,13 @@ export interface LoginResult {
export type RoleKey = 'user' | 'supervisor' | 'candidate'; export type RoleKey = 'user' | 'supervisor' | 'candidate';
export interface MobileIdentity {
role: RoleKey;
roleLabel: string;
positionName: string;
positionLevel: string;
}
export interface ToolItem { export interface ToolItem {
title: string; title: string;
desc: string; desc: string;
@@ -374,6 +381,14 @@ export interface InterviewRecord {
score: number; score: number;
result: string; result: string;
summary: string; summary: string;
questions?: InterviewQuestionRecord[];
}
export interface InterviewQuestionRecord {
seq: number;
question: string;
answer: string;
rating: string;
} }
export interface InterviewFinishResponse { export interface InterviewFinishResponse {
+10 -1
View File
@@ -46,7 +46,7 @@ test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
test('员工端 tabBar 继续保留四个固定入口', async () => { test('员工端 tabBar 继续保留四个固定入口', async () => {
const source = await pageSource('../src/pages.json'); const source = await pageSource('../src/pages.json');
assert.match(source, /"selectedColor"\s*:\s*"#e70014"/); assert.match(source, /"selectedColor"\s*:\s*"#e60012"/);
assert.match(source, /pages\/user\/today\/index/); assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/); assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/); assert.match(source, /pages\/user\/sop\/index/);
@@ -84,6 +84,14 @@ test('员工端三页接入共享页面和卡片基座', async () => {
assert.match(practiceSource, /scenario-card employee-card/); assert.match(practiceSource, /scenario-card employee-card/);
}); });
test('登录后按组织身份切换主管首页', async () => {
const source = await pageSource('../src/services/position.ts');
assert.match(source, /\/api\/aihr\/mobile\/me/);
assert.match(source, /role === 'supervisor'/);
assert.match(source, /pages\/supervisor\/index\/index/);
});
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => { test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
const source = await pageSource('../src/pages/user/today/index.vue'); const source = await pageSource('../src/pages/user/today/index.vue');
@@ -107,6 +115,7 @@ test('员工首页高保真入口继续使用真实图标和业务跳转', async
assert.match(source, /const openPrimarySchedule = \(\) =>/); assert.match(source, /const openPrimarySchedule = \(\) =>/);
assert.match(source, /getMobileHome\('user'\)/); assert.match(source, /getMobileHome\('user'\)/);
assert.match(source, /getPracticeAssignments\(true\)/); assert.match(source, /getPracticeAssignments\(true\)/);
assert.match(source, /src="\/static\/mentor-hifi\.png"/);
}); });
test('问师傅页保留搜索框、答案区和总结卡入口', async () => { test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
+8
View File
@@ -301,6 +301,8 @@ contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/profile/promotion
contains mobile-uni/src/pages/user/practice/index.vue "训练完成,主管端待复盘已更新" contains mobile-uni/src/pages/user/practice/index.vue "训练完成,主管端待复盘已更新"
contains mobile-uni/src/pages/user/profile/index.vue "贡献度" contains mobile-uni/src/pages/user/profile/index.vue "贡献度"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback" contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback"
contains mobile-uni/src/pages/user/sop/index.vue "有帮助"
contains mobile-uni/src/pages/user/sop/index.vue "没解决"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card" contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload" contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start" contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
@@ -319,6 +321,8 @@ contains frontend/src/views/knowledge/cases.vue "真实语音"
contains frontend/src/views/knowledge/sop.vue "训练题已生成" contains frontend/src/views/knowledge/sop.vue "训练题已生成"
contains frontend/src/views/system/model/index.vue "调用优先级" contains frontend/src/views/system/model/index.vue "调用优先级"
contains frontend/src/views/system/model/index.vue "提示词模板" contains frontend/src/views/system/model/index.vue "提示词模板"
contains frontend/src/views/system/prompts.vue "保存并启用"
contains frontend/src/router/index.ts "/system/prompts"
contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start" contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/api/recruit/interview" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "/api/recruit/interview"
contains frontend/src/api/aihr/practice.ts "/api/train/practice/start" contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"
@@ -334,6 +338,8 @@ contains frontend/src/views/train/practice.vue "训练前预习卡"
contains frontend/src/views/train/practice.vue "Rubric 预览" contains frontend/src/views/train/practice.vue "Rubric 预览"
contains frontend/src/views/train/practice.vue "提示词模板" contains frontend/src/views/train/practice.vue "提示词模板"
contains frontend/src/views/train/practice.vue "导出试点CSV" contains frontend/src/views/train/practice.vue "导出试点CSV"
contains frontend/src/views/train/reviews.vue "导出试点 CSV"
contains frontend/src/views/train/reviews.vue "人工校准总分"
contains frontend/src/api/aihr/practice.ts "/api/train/practice/export" contains frontend/src/api/aihr/practice.ts "/api/train/practice/export"
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled' contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled"
@@ -361,7 +367,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search" contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/doc/upload" contains frontend/src/api/aihr/sop.ts "/api/knowledge/doc/upload"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/reviews" contains frontend/src/api/aihr/sop.ts "/api/knowledge/reviews"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/answer-feedback"
contains frontend/src/views/knowledge/sop.vue "SOP 问答评审已记录" contains frontend/src/views/knowledge/sop.vue "SOP 问答评审已记录"
contains frontend/src/views/knowledge/sop.vue "答案待复核"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/api/knowledge" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/api/knowledge"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/doc/upload" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/doc/upload"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"