feat(aihr): align figma pages for release
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
|
||||
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
|
||||
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
|
||||
- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
|
||||
- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);问答卡两侧图片容器统一 `64×64`,横向内容垂直居中;员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
|
||||
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
|
||||
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
|
||||
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
|
||||
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
|
||||
- 用户侧 Alpha:三端已在 `mobile-uni/` 按真实页面栈重建,portless 本地入口为 `https://wygj-mobile-uni.localhost/h5/`;员工端路由为 `/h5/#/pages/user/today/index`、`/practice/index`、`/sop/index`、`/profile/index`,候选人端为 `/h5/#/pages/candidate/index/index`,主管端为 `/h5/#/pages/supervisor/index/index`。已接手机号短信登录、问师傅检索/反馈/总结卡、员工训练闭环、案例素材入库、候选人面试/资料、主管复盘/派专项;旧 `mobile/` 不再作为新增功能承载面。
|
||||
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。
|
||||
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。
|
||||
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
|
||||
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
|
||||
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
|
||||
@@ -45,6 +45,8 @@
|
||||
|---|---|
|
||||
| [文档目录](docs/README.md) | docs 区完整索引 |
|
||||
| [Figma 设计资产清单](docs/FIGMA_DESIGN_INVENTORY.md) | 79 个页面、71 张正式画板的节点索引、批次范围和一致性审计结果 |
|
||||
| [Figma 视觉系统使用规范](docs/FIGMA_DESIGN_SYSTEM_GUIDE.md) | 通用视觉元素、业务元素、状态映射和 LLM 组页规则 |
|
||||
| [Figma 与线上 H5 差异矩阵](docs/FIGMA_ONLINE_DELTA.md) | 线上真实页面与 Figma 目标的差异、调整顺序和验收口径 |
|
||||
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
|
||||
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
|
||||
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
|
||||
|
||||
+46
-5
@@ -16,6 +16,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
|
||||
import org.dromara.aihr.service.AihrOrgSyncService;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
@@ -44,6 +46,7 @@ import java.util.Map;
|
||||
public class AihrMobileController {
|
||||
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
private final AihrOrgSyncService orgSyncService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@SaIgnore
|
||||
@@ -52,11 +55,42 @@ public class AihrMobileController {
|
||||
return R.ok(mobileSeedService.home(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated APP user's mobile role from the latest org snapshot.
|
||||
* The login response intentionally stays compatible with the shared OAuth contract.
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public R<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")
|
||||
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String 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")
|
||||
public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
|
||||
@RequestParam(required = false) String extPartyId,
|
||||
@@ -172,7 +206,7 @@ public class AihrMobileController {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("role", turn.role());
|
||||
item.put("label", turn.label());
|
||||
item.put("text", turn.text());
|
||||
item.put("text", maskSensitiveText(turn.text()));
|
||||
item.put("audioUrl", turn.audioUrl());
|
||||
item.put("audioOssId", turn.audioOssId());
|
||||
return item;
|
||||
@@ -184,10 +218,17 @@ public class AihrMobileController {
|
||||
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
|
||||
return Map.of(
|
||||
"turnIndex", annotation.turnIndex(),
|
||||
"sourceText", annotation.sourceText(),
|
||||
"issue", annotation.issue(),
|
||||
"rewrite", annotation.rewrite(),
|
||||
"sopPoint", annotation.sopPoint()
|
||||
"sourceText", maskSensitiveText(annotation.sourceText()),
|
||||
"issue", maskSensitiveText(annotation.issue()),
|
||||
"rewrite", maskSensitiveText(annotation.rewrite()),
|
||||
"sopPoint", maskSensitiveText(annotation.sopPoint())
|
||||
);
|
||||
}
|
||||
|
||||
private static String maskSensitiveText(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
return value.replaceAll("(1\\d{2})\\d{4}(\\d{4})", "$1****$2");
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -12,6 +12,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||
@@ -86,6 +89,24 @@ public class AihrPracticeController {
|
||||
return R.ok(practiceSeedService.rubrics(scenarioId));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/rubrics/manage")
|
||||
public R<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)
|
||||
@GetMapping("/export")
|
||||
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
|
||||
+5
-1
@@ -41,6 +41,10 @@ public final class AihrInterviewDto {
|
||||
public record DimensionResponse(String label, Object value, String note) {
|
||||
}
|
||||
|
||||
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary) {
|
||||
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
|
||||
}
|
||||
|
||||
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary,
|
||||
List<QuestionRecordResponse> questions) {
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -78,6 +78,32 @@ public final class AihrPracticeDto {
|
||||
public record RubricDimensionResponse(String code, String name, Double weight, String description) {
|
||||
}
|
||||
|
||||
public record RubricAdminResponse(
|
||||
String code,
|
||||
String scenarioCode,
|
||||
String scenarioName,
|
||||
String name,
|
||||
String version,
|
||||
Boolean enabled,
|
||||
String description,
|
||||
List<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) {
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -10,6 +10,14 @@ public final class AihrMobileDto {
|
||||
private AihrMobileDto() {
|
||||
}
|
||||
|
||||
public record IdentityResponse(
|
||||
String role,
|
||||
String roleLabel,
|
||||
String positionName,
|
||||
String positionLevel
|
||||
) {
|
||||
}
|
||||
|
||||
public record HomeResponse(
|
||||
String role,
|
||||
String roleLabel,
|
||||
|
||||
+37
-5
@@ -11,10 +11,12 @@ import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.QuestionRecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
@@ -88,7 +90,8 @@ public class AihrInterviewService {
|
||||
}
|
||||
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
|
||||
String time = LocalDateTime.now().format(TIME_FORMAT);
|
||||
RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary());
|
||||
List<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);
|
||||
return new FinishResponse(
|
||||
score.total(),
|
||||
@@ -110,7 +113,7 @@ public class AihrInterviewService {
|
||||
ensureResultTable();
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
|
||||
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary, questions_json
|
||||
FROM aihr_interview_result
|
||||
WHERE tenant_id = '000000'
|
||||
""");
|
||||
@@ -134,7 +137,8 @@ public class AihrInterviewService {
|
||||
rs.getString("position_code"),
|
||||
rs.getInt("total_score"),
|
||||
rs.getString("result"),
|
||||
rs.getString("ai_summary")
|
||||
rs.getString("ai_summary"),
|
||||
parseQuestionRecords(rs.getString("questions_json"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,8 +147,8 @@ public class AihrInterviewService {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_interview_result
|
||||
(tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result,
|
||||
suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
|
||||
suggestion, ai_summary, evidence, source, answers_json, questions_json, dimensions_json, finished_time, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
candidate_id = VALUES(candidate_id),
|
||||
candidate_name = VALUES(candidate_name),
|
||||
@@ -156,6 +160,7 @@ public class AihrInterviewService {
|
||||
evidence = VALUES(evidence),
|
||||
source = VALUES(source),
|
||||
answers_json = VALUES(answers_json),
|
||||
questions_json = VALUES(questions_json),
|
||||
dimensions_json = VALUES(dimensions_json),
|
||||
finished_time = VALUES(finished_time),
|
||||
update_time = now()
|
||||
@@ -171,6 +176,7 @@ public class AihrInterviewService {
|
||||
score.evidence(),
|
||||
score.source(),
|
||||
json(session.answers()),
|
||||
json(questionRecords(session)),
|
||||
json(score.dimensions())
|
||||
);
|
||||
}
|
||||
@@ -198,6 +204,7 @@ public class AihrInterviewService {
|
||||
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
|
||||
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
|
||||
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
|
||||
`questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON',
|
||||
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
@@ -208,10 +215,35 @@ public class AihrInterviewService {
|
||||
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||
""");
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json");
|
||||
} catch (DataAccessException error) {
|
||||
if (!String.valueOf(error.getMessage()).contains("Duplicate column")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private List<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) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
|
||||
+4
@@ -77,6 +77,10 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.reviewDetail(id, supervisorExtPartyId);
|
||||
}
|
||||
|
||||
public ReviewDetailResponse practiceHistoryDetail(Long id, String extPartyId) {
|
||||
return practiceSeedService.historyDetail(id, extPartyId);
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id) {
|
||||
return markReviewed(id, null);
|
||||
}
|
||||
|
||||
+116
@@ -28,7 +28,10 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||
@@ -380,6 +383,79 @@ public class AihrPracticeSeedService {
|
||||
return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim()));
|
||||
}
|
||||
|
||||
public List<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) {
|
||||
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
|
||||
RoundSeed firstRound = scenario.rounds().get(0);
|
||||
@@ -999,6 +1075,11 @@ public class AihrPracticeSeedService {
|
||||
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail;
|
||||
}
|
||||
|
||||
public ReviewDetailResponse historyDetail(Long id, String extPartyId) {
|
||||
ReviewDetailResponse detail = reviewDetail(id);
|
||||
return detail == null || isBlank(extPartyId) || !extPartyId.equals(reviewExtPartyId(id)) ? null : detail;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean markReviewed(Long id) {
|
||||
return markReviewed(id, null);
|
||||
@@ -2853,6 +2934,41 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureRubricTables() {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` varchar(20) DEFAULT '000000',
|
||||
`rubric_code` varchar(100) NOT NULL,
|
||||
`scenario_code` varchar(80) NOT NULL,
|
||||
`rubric_name` varchar(100) NOT NULL,
|
||||
`version` varchar(30) DEFAULT 'v1',
|
||||
`enabled` tinyint DEFAULT 1,
|
||||
`description` varchar(500) DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` varchar(20) DEFAULT '000000',
|
||||
`rubric_code` varchar(100) NOT NULL,
|
||||
`dimension_code` varchar(50) NOT NULL,
|
||||
`dimension_name` varchar(50) NOT NULL,
|
||||
`weight` decimal(5,2) NOT NULL,
|
||||
`description` varchar(500) DEFAULT NULL,
|
||||
`sort_order` int DEFAULT 0,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
""");
|
||||
}
|
||||
|
||||
private void ensureCalibrationTable() {
|
||||
if (calibrationTableReady) {
|
||||
return;
|
||||
|
||||
+113
-14
@@ -19,12 +19,15 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* 批量上传队列:上传请求只做「暂存文件 + 入队 + 秒回」,解析/归类/向量化由本服务的后台 worker
|
||||
@@ -39,6 +42,9 @@ public class AihrUploadQueueService {
|
||||
private static final long MAX_FILE_BYTES = 100L * 1024 * 1024;
|
||||
/** 视频单独放宽:培训视频普遍大于文档;更大的走服务端目录导入。 */
|
||||
private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024;
|
||||
private static final long MAX_ARCHIVE_BYTES = 500L * 1024 * 1024;
|
||||
private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024;
|
||||
private static final int MAX_ARCHIVE_ENTRIES = 1000;
|
||||
private static final int MAX_ERROR_CHARS = 480;
|
||||
/** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */
|
||||
private static final int WORKER_PERMITS = 2;
|
||||
@@ -76,12 +82,16 @@ public class AihrUploadQueueService {
|
||||
throw new ServiceException("上传文件不能为空");
|
||||
}
|
||||
String fileName = sanitizeFileName(file.getOriginalFilename());
|
||||
if (!AihrSopSeedService.supportedFile(fileName)) {
|
||||
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频文件");
|
||||
boolean archive = isZip(fileName);
|
||||
if (!archive && !AihrSopSeedService.supportedFile(fileName)) {
|
||||
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件");
|
||||
}
|
||||
long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
|
||||
long maxBytes = archive ? MAX_ARCHIVE_BYTES
|
||||
: AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
|
||||
if (file.getSize() > maxBytes) {
|
||||
throw new ServiceException(AihrVideoService.videoFile(fileName)
|
||||
throw new ServiceException(archive
|
||||
? "ZIP不能超过 500MB"
|
||||
: AihrVideoService.videoFile(fileName)
|
||||
? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入"
|
||||
: "文件不能超过 100MB");
|
||||
}
|
||||
@@ -135,11 +145,11 @@ public class AihrUploadQueueService {
|
||||
throw new ServiceException("重试条目不存在");
|
||||
}
|
||||
ItemRow row = jdbcTemplate.query("""
|
||||
select id, file_name, category, staging_path, status
|
||||
select id, batch_id, file_name, category, staging_path, status
|
||||
from aihr_knowledge_upload_item
|
||||
where tenant_id = ? and id = ?
|
||||
""", rs -> rs.next()
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, TENANT_ID, id);
|
||||
if (row == null) {
|
||||
throw new ServiceException("重试条目不存在");
|
||||
@@ -190,17 +200,16 @@ public class AihrUploadQueueService {
|
||||
List<QueueCandidate> candidates = jdbcTemplate.query("""
|
||||
select i.id, i.file_name from aihr_knowledge_upload_item i
|
||||
where i.tenant_id = ? and i.status = 0
|
||||
and (? = 0 or i.file_name not regexp '\\\\.(mp4|mov|avi|mkv|webm|m4v)$')
|
||||
and not exists (
|
||||
select 1 from (select file_name from aihr_knowledge_upload_item where tenant_id = ? and status = 1) p
|
||||
where p.file_name = i.file_name
|
||||
)
|
||||
order by i.id asc
|
||||
limit 10
|
||||
""", (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)), TENANT_ID, TENANT_ID);
|
||||
""", (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)),
|
||||
TENANT_ID, videoBusy ? 1 : 0, TENANT_ID);
|
||||
for (QueueCandidate candidate : candidates) {
|
||||
if (videoBusy && AihrVideoService.videoFile(candidate.fileName())) {
|
||||
continue;
|
||||
}
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 1, update_time = now()
|
||||
@@ -229,11 +238,11 @@ public class AihrUploadQueueService {
|
||||
|
||||
private void processItem(Long id) {
|
||||
ItemRow row = jdbcTemplate.query("""
|
||||
select id, file_name, category, staging_path, status
|
||||
select id, batch_id, file_name, category, staging_path, status
|
||||
from aihr_knowledge_upload_item
|
||||
where tenant_id = ? and id = ?
|
||||
""", rs -> rs.next()
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, TENANT_ID, id);
|
||||
if (row == null) {
|
||||
return;
|
||||
@@ -244,6 +253,18 @@ public class AihrUploadQueueService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isZip(row.fileName())) {
|
||||
int extracted = expandArchive(row);
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 2, fragment_count = ?, update_time = now()
|
||||
where tenant_id = ? and id = ? and status = 1
|
||||
""", extracted, TENANT_ID, id);
|
||||
if (updated == 1) {
|
||||
deleteQuietly(staged);
|
||||
}
|
||||
return;
|
||||
}
|
||||
UploadResponse result = sopSeedService.processStagedDocument(row.fileName(), row.category(), staged);
|
||||
// 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因
|
||||
String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null;
|
||||
@@ -264,6 +285,82 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private int expandArchive(ItemRow archive) throws IOException {
|
||||
List<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() {
|
||||
try {
|
||||
@@ -362,7 +459,9 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
|
||||
private Path stagingPath(String fileName) {
|
||||
return stagingRoot.resolve(UUID.randomUUID() + "_" + fileName);
|
||||
String lower = fileName.toLowerCase();
|
||||
String extension = lower.contains(".") ? lower.substring(lower.lastIndexOf('.')) : "";
|
||||
return stagingRoot.resolve(UUID.randomUUID() + extension);
|
||||
}
|
||||
|
||||
/** 去掉路径分隔与控制字符,防止暂存路径逃逸出 stagingRoot。 */
|
||||
@@ -422,6 +521,6 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private record ItemRow(Long id, String fileName, String category, String stagingPath, int status) {
|
||||
private record ItemRow(Long id, String batchId, String fileName, String category, String stagingPath, int status) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
|
||||
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
|
||||
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
|
||||
`questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON',
|
||||
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 |
|
||||
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
|
||||
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed |
|
||||
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
|
||||
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
|
||||
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
|
||||
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
|
||||
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
|
||||
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
|
||||
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/profile`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项;普通员工返回“无主管权限”。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 |
|
||||
| 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 |
|
||||
|
||||
@@ -67,7 +68,7 @@ SOP 文档上传第三片已经落最小后端边界:
|
||||
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 |
|
||||
| 服务端目录导入 | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,保留给小批量/调试 |
|
||||
| 服务端导入任务 | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,页面轮询查看总数、成功数、失败数、当前文件和进度;运行中任务可取消;重试当前按同目录重新启动一轮 |
|
||||
| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;后台 worker 并发 2 复用同一解析/归类/向量化链路,同名文件串行防撞唯一键;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 保留给 SOP 页单文件即时预览 |
|
||||
| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;支持 ZIP ≤500MB,worker 限制最多 1000 个子文件、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;支持的子文件写回同一 batch 继续复用解析链路;完成/失败状态 CAS 写入,卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h 供重试;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览 |
|
||||
| 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无 asr 且无 vision 结果时按「待处理」落库不报错;视频同时只加工 1 个 |
|
||||
|
||||
模型能力第二片已经落最小后端边界:
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ AIHR_AI_SPEECH_ENABLED=true
|
||||
- `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material`
|
||||
- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot`
|
||||
- 本地组织 seed 带演示手机号,可用 `13900000103` 验证员工端自动识别物业管家岗位,用 `13900000202` 验证主管端项目范围
|
||||
- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时
|
||||
- SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。资料处理中心异步接口另支持 `.zip`(≤500MB),后台安全解压后逐文件入队;最多 1000 个子文件、解压总量 ≤2GB,不支持嵌套 ZIP
|
||||
- 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`)
|
||||
- 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。
|
||||
- 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 已有正式页面
|
||||
|
||||
- [x] 员工端:今日
|
||||
- [x] 员工端:今日(Figma `23:3`)
|
||||
- [x] 员工端:练(训练中心入口)
|
||||
- [-] 员工端:问(仅提问态,缺结果与反馈)
|
||||
- [-] 员工端:我(有成长摘要,缺完整画像/荣誉/偏好)
|
||||
@@ -146,7 +146,7 @@
|
||||
- 一期范围外的复杂多组织流程
|
||||
- 未完成授权对接前的北森、考勤、请假等伪数据页面
|
||||
|
||||
## 全文件设计一致性审计(2026-07-13)
|
||||
## 全文件设计一致性审计(2026-07-14)
|
||||
|
||||
- [x] 建立 Figma 首屏“00|设计索引与审计”(Figma `101:3`)
|
||||
- [x] 79 个页面、71 张正式画板均有内容,无重复页面名和空业务页
|
||||
@@ -156,4 +156,5 @@
|
||||
- [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50%
|
||||
- [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐
|
||||
- [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界
|
||||
- [x] 员工端「页面|今日」已完成首页高保真复核:问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出
|
||||
- [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序
|
||||
|
||||
@@ -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 根据名称判断组件用途和边界?
|
||||
|
||||
判断标准:如果一个元素离开当前业务仍然有意义,它才适合进入通用视觉库。
|
||||
@@ -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:量化、偏好、积分和荣誉**,等待稳定训练数据与人工校准后再实现。
|
||||
@@ -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`
|
||||
@@ -8,6 +8,8 @@
|
||||
|---|---|
|
||||
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 |
|
||||
| [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 |
|
||||
| [FIGMA_DESIGN_SYSTEM_GUIDE.md](FIGMA_DESIGN_SYSTEM_GUIDE.md) | 员工端通用视觉元素、业务元素、状态映射和 LLM 组页规则 |
|
||||
| [FIGMA_ONLINE_DELTA.md](FIGMA_ONLINE_DELTA.md) | Figma 目标与线上 H5 实测差异矩阵、当前实施边界和验收口径 |
|
||||
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
|
||||
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
|
||||
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 |
|
||||
|
||||
@@ -42,6 +42,46 @@ export type PracticeScenarioEnabledRequest = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticeScenarioSaveRequest = {
|
||||
id?: string;
|
||||
name: string;
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
explicitNeed: string;
|
||||
hiddenNeed: string;
|
||||
irritants: string;
|
||||
delights: string;
|
||||
sopRefs: string;
|
||||
successCriteria: string;
|
||||
strategy: string;
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticeRubricDimension = {
|
||||
code: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type PracticeRubricAdmin = {
|
||||
code: string;
|
||||
scenarioCode: string;
|
||||
scenarioName: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
dimensions: PracticeRubricDimension[];
|
||||
};
|
||||
|
||||
export type PracticeRubricSaveRequest = Omit<PracticeRubricAdmin, 'scenarioName'>;
|
||||
|
||||
export type PracticePrepCardResponse = {
|
||||
keyPoints: 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>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/prep-card`,
|
||||
|
||||
@@ -48,6 +48,25 @@ export type SopReviewResponse = {
|
||||
reviewedAt: string;
|
||||
};
|
||||
|
||||
export type AnswerFeedbackItem = {
|
||||
id: number;
|
||||
queryText: string;
|
||||
queryNorm: string;
|
||||
fragmentId: number;
|
||||
verdict: string;
|
||||
category: string;
|
||||
position: string;
|
||||
source: string;
|
||||
reviewStatus: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
};
|
||||
|
||||
export type AnswerFeedbackReviewResponse = {
|
||||
id: number;
|
||||
reviewStatus: string;
|
||||
};
|
||||
|
||||
export type SopUploadResponse = {
|
||||
docId: string;
|
||||
ossId: number;
|
||||
@@ -87,6 +106,23 @@ export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer
|
||||
});
|
||||
}
|
||||
|
||||
export function listAnswerFeedback(status = '待复核', limit = 50): Promise<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 = {
|
||||
itemId: number;
|
||||
batchId: string;
|
||||
|
||||
@@ -101,6 +101,18 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
name: 'TrainPractice',
|
||||
meta: { title: '三角色对练', icon: 'peoples' }
|
||||
},
|
||||
{
|
||||
path: 'scenarios',
|
||||
component: () => import('@/views/train/scenarios.vue'),
|
||||
name: 'TrainScenarios',
|
||||
meta: { title: '场景库', icon: 'list' }
|
||||
},
|
||||
{
|
||||
path: 'rubrics',
|
||||
component: () => import('@/views/train/rubrics.vue'),
|
||||
name: 'TrainRubrics',
|
||||
meta: { title: 'Rubric 规则', icon: 'list' }
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
component: () => import('@/views/train/reviews.vue'),
|
||||
@@ -157,6 +169,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/model/index.vue'),
|
||||
name: 'SystemModelConfig',
|
||||
meta: { title: '模型配置', icon: 'model' }
|
||||
},
|
||||
{
|
||||
path: 'prompts',
|
||||
component: () => import('@/views/system/prompts.vue'),
|
||||
name: 'SystemPromptTemplates',
|
||||
meta: { title: '提示词模板', icon: 'edit' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -283,12 +283,14 @@ import {
|
||||
} from '@/api/aihr/processing';
|
||||
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
|
||||
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v';
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
|
||||
const autoCategory = '__auto__';
|
||||
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v)$/i;
|
||||
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
|
||||
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
|
||||
const archivePattern = /\.zip$/i;
|
||||
const maxFileSize = 100 * 1024 * 1024;
|
||||
const maxVideoSize = 500 * 1024 * 1024;
|
||||
const maxArchiveSize = 500 * 1024 * 1024;
|
||||
const browserBatchWarnCount = 20;
|
||||
const browserBatchWarnSize = 200 * 1024 * 1024;
|
||||
|
||||
@@ -484,14 +486,17 @@ async function handlePickedFiles(event: Event) {
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const readyFiles = files.filter(
|
||||
(file) => supportedPattern.test(file.name) && file.size > 0 && file.size <= (videoPattern.test(file.name) ? maxVideoSize : maxFileSize)
|
||||
(file) =>
|
||||
supportedPattern.test(file.name) &&
|
||||
file.size > 0 &&
|
||||
file.size <= (videoPattern.test(file.name) ? maxVideoSize : archivePattern.test(file.name) ? maxArchiveSize : maxFileSize)
|
||||
);
|
||||
if (readyFiles.length === 0) {
|
||||
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)或视频(500MB内)文件');
|
||||
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)、视频或 ZIP(500MB内)文件');
|
||||
return;
|
||||
}
|
||||
if (readyFiles.length < files.length) {
|
||||
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频 500MB)`);
|
||||
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频/ZIP 500MB)`);
|
||||
}
|
||||
|
||||
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
@@ -89,6 +89,50 @@
|
||||
</article>
|
||||
</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">
|
||||
<div class="panel-title">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
@@ -110,9 +154,18 @@
|
||||
import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { UploadFile } from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
import {
|
||||
listAnswerFeedback,
|
||||
reviewAnswerFeedback,
|
||||
reviewKnowledgeSop,
|
||||
searchKnowledgeSop,
|
||||
uploadKnowledgeDoc,
|
||||
type AnswerFeedbackItem,
|
||||
type SopRecord,
|
||||
type SopSearchResponse
|
||||
} from '@/api/aihr/sop';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -212,6 +265,10 @@ const uploading = ref(false);
|
||||
const uploadedRecords = ref<SopRecord[]>([]);
|
||||
const reviewSaving = ref(false);
|
||||
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 answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -318,6 +375,32 @@ async function markReview(usable: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingReviews() {
|
||||
pendingReviewLoading.value = true;
|
||||
try {
|
||||
const { data } = await listAnswerFeedback(pendingReviewStatus.value);
|
||||
pendingReviews.value = data ?? [];
|
||||
} catch {
|
||||
pendingReviews.value = [];
|
||||
ElMessage.error('答案待复核列表加载失败');
|
||||
} finally {
|
||||
pendingReviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markFeedbackReviewed(row: AnswerFeedbackItem) {
|
||||
reviewingFeedbackId.value = row.id;
|
||||
try {
|
||||
await reviewAnswerFeedback(row.id);
|
||||
pendingReviews.value = pendingReviews.value.filter((item) => item.id !== row.id);
|
||||
ElMessage.success('答案已标记为已复核');
|
||||
} catch {
|
||||
ElMessage.error('答案复核失败');
|
||||
} finally {
|
||||
reviewingFeedbackId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocSelected(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw;
|
||||
if (!raw) return;
|
||||
@@ -359,6 +442,10 @@ async function generateTraining() {
|
||||
trainingGenerated.value = true;
|
||||
ElMessage.success('已基于引用片段生成训练题');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPendingReviews();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -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>
|
||||
@@ -344,6 +344,18 @@ type ScenarioSeed = {
|
||||
id: string;
|
||||
name: string;
|
||||
position?: string;
|
||||
scenarioType?: string;
|
||||
projectType?: string;
|
||||
difficulty?: number;
|
||||
customerPersona?: string;
|
||||
openingText?: string;
|
||||
explicitNeed?: string;
|
||||
hiddenNeed?: string;
|
||||
irritants?: string;
|
||||
delights?: string;
|
||||
sopRefs?: string;
|
||||
successCriteria?: string;
|
||||
enabled?: boolean;
|
||||
trainee: string;
|
||||
customer: string;
|
||||
project: string;
|
||||
@@ -489,7 +501,6 @@ const defaultPromptForm = (): PromptTemplateForm => ({
|
||||
description: ''
|
||||
});
|
||||
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
|
||||
|
||||
const selectedScenario = computed(
|
||||
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]
|
||||
);
|
||||
@@ -563,6 +574,18 @@ const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
scenarioType: item.scenarioType,
|
||||
projectType: item.projectType,
|
||||
difficulty: item.difficulty,
|
||||
customerPersona: item.customerPersona,
|
||||
openingText: item.openingText,
|
||||
explicitNeed: item.explicitNeed,
|
||||
hiddenNeed: item.hiddenNeed,
|
||||
irritants: item.irritants,
|
||||
delights: item.delights,
|
||||
sopRefs: item.sopRefs,
|
||||
successCriteria: item.successCriteria,
|
||||
enabled: item.enabled,
|
||||
trainee: '张三',
|
||||
customer: item.customerPersona,
|
||||
project: item.projectType || item.scenarioType,
|
||||
|
||||
@@ -6,10 +6,37 @@
|
||||
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<article class="panel list-panel">
|
||||
<div class="panel-title">
|
||||
@@ -17,9 +44,6 @@
|
||||
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="list-filters">
|
||||
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
|
||||
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
||||
</el-select>
|
||||
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
|
||||
<el-option
|
||||
v-for="person in orgPeople"
|
||||
@@ -122,7 +146,7 @@
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { calibratePracticeSession, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import {
|
||||
getPracticeAudioBlobUrl,
|
||||
getOrgSnapshot,
|
||||
@@ -142,6 +166,11 @@ const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||||
const selectedProjectCode = ref('');
|
||||
const selectedExtPartyId = ref('');
|
||||
const selectedPosition = ref('生活顾问');
|
||||
const exporting = ref(false);
|
||||
const startDate = ref('2026-07-01');
|
||||
const endDate = ref('2026-07-31');
|
||||
const metrics = reactive({ calibrationCount: 0, consistency: '--', sopUsable: '--', ready: false });
|
||||
const audioSources = ref<Record<string, string>>({});
|
||||
const audioLoading = ref<Record<string, boolean>>({});
|
||||
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) => {
|
||||
releaseAudioSources();
|
||||
const result = await getPracticeReview(row.id);
|
||||
@@ -290,10 +354,15 @@ watch(selectedExtPartyId, () => {
|
||||
loadReviews();
|
||||
});
|
||||
|
||||
watch([startDate, endDate], () => {
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadScenarios();
|
||||
loadOrgSnapshot();
|
||||
loadReviews();
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onBeforeUnmount(releaseAudioSources);
|
||||
@@ -332,6 +401,51 @@ onBeforeUnmount(releaseAudioSources);
|
||||
}
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #16864b;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.is-pending {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
|
||||
@@ -445,8 +559,15 @@ onBeforeUnmount(releaseAudioSources);
|
||||
.review-layout,
|
||||
.list-filters,
|
||||
.score-grid,
|
||||
.form-grid {
|
||||
.form-grid,
|
||||
.filter-panel,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 优秀 · 75–89 良好 · 60–74 待提升 · <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>
|
||||
@@ -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>
|
||||
@@ -31,6 +31,12 @@
|
||||
"navigationBarTitleText": "我"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/history/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "训练详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/cases/index",
|
||||
"style": {
|
||||
@@ -91,6 +97,12 @@
|
||||
"navigationBarTitleText": "复盘详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的主管工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/index/index",
|
||||
"style": {
|
||||
@@ -112,7 +124,7 @@
|
||||
{
|
||||
"path": "pages/candidate/progress/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "进度通知"
|
||||
"navigationBarTitleText": "面试记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,7 +143,7 @@
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#7b8494",
|
||||
"selectedColor": "#e70014",
|
||||
"selectedColor": "#e60012",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"height": "66px",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:clearable="false"
|
||||
primary-color="#e70014"
|
||||
primary-color="#e60012"
|
||||
@input="onPhoneInput"
|
||||
placeholder="请输入手机号"
|
||||
placeholder-style="color: #b5b8c1"
|
||||
@@ -53,7 +53,7 @@
|
||||
type="number"
|
||||
maxlength="6"
|
||||
:clearable="false"
|
||||
primary-color="#e70014"
|
||||
primary-color="#e60012"
|
||||
@input="onSmsCodeInput"
|
||||
placeholder="请输入验证码"
|
||||
placeholder-style="color: #b5b8c1"
|
||||
@@ -207,13 +207,13 @@ onShow(() => {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 247, 247, 0.86) 38%, rgba(239, 239, 239, 0.72) 100%),
|
||||
linear-gradient(180deg, #fafafa 0%, #f2f2f2 100%);
|
||||
radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.98), rgba(247, 248, 251, 0.9) 38%, rgba(239, 242, 247, 0.78) 100%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
padding: 114px 30px 52px;
|
||||
padding: 72px 24px 36px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -243,14 +243,14 @@ onShow(() => {
|
||||
.login-head {
|
||||
position: relative;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 43px;
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 36px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
@@ -283,7 +283,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.login-eyebrow {
|
||||
color: #1d9668;
|
||||
color: #e60012;
|
||||
}
|
||||
|
||||
.login-head .page-title {
|
||||
@@ -312,8 +312,8 @@ onShow(() => {
|
||||
margin: 0 auto;
|
||||
padding: 36px 20px 36px;
|
||||
border: 1px solid rgba(225, 227, 232, 0.95);
|
||||
border-top: 4px solid #f20b1a;
|
||||
border-radius: 12px;
|
||||
border-top: 4px solid #e60012;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 12px 28px rgba(29, 31, 35, 0.12), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
|
||||
}
|
||||
@@ -321,7 +321,7 @@ onShow(() => {
|
||||
.login-card::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 26%);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
@@ -369,8 +369,8 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.field-shell:focus-within {
|
||||
border-color: #e70014;
|
||||
box-shadow: 0 0 0 3px rgba(231, 0, 20, 0.08);
|
||||
border-color: #e60012;
|
||||
box-shadow: 0 0 0 3px rgba(230, 0, 18, 0.08);
|
||||
}
|
||||
|
||||
.field-shell :deep(.uni-easyinput) {
|
||||
@@ -387,6 +387,9 @@ onShow(() => {
|
||||
|
||||
.field-shell :deep(.uni-easyinput__content-input) {
|
||||
height: 28px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent !important;
|
||||
color: #25282d;
|
||||
font-size: 15px;
|
||||
line-height: 28px;
|
||||
@@ -433,10 +436,10 @@ onShow(() => {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #f20b1a;
|
||||
border: 1px solid #e60012;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fffdfd 100%);
|
||||
color: #f20b1a;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
@@ -453,14 +456,13 @@ onShow(() => {
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 12%),
|
||||
linear-gradient(180deg, #ff1124 0%, #f40619 100%);
|
||||
border-radius: 14px;
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
box-shadow: 0 8px 18px rgba(231, 0, 20, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
box-shadow: 0 8px 18px rgba(230, 0, 18, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.login-card :deep(.uni-noticebar) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<view class="page candidate-subpage progress-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
|
||||
<view class="subpage-heading">
|
||||
<text class="page-title">进度通知</text>
|
||||
<text class="page-subtitle">面试与资料审核状态</text>
|
||||
<view class="page candidate-record-page">
|
||||
<view class="record-header">
|
||||
<button class="back-button" aria-label="返回候选首页" @click="goBack">←</button>
|
||||
<view>
|
||||
<text class="page-title">面试记录</text>
|
||||
<text class="page-subtitle">{{ latestRecord ? `${latestRecord.time} · ${latestRecord.position}` : '面试与资料审核状态' }}</text>
|
||||
</view>
|
||||
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
@@ -16,66 +13,50 @@
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后查看本人面试和资料审核进度。</text>
|
||||
<text class="muted block">登录后查看本人面试记录。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="panel status-panel">
|
||||
<view class="status-head">
|
||||
<text class="status-badge">{{ flow.badge }}</text>
|
||||
<text class="status-progress">{{ flow.progressText }}</text>
|
||||
<template v-else-if="latestRecord">
|
||||
<view class="score-card">
|
||||
<view class="score-card-head">
|
||||
<view>
|
||||
<text class="score-value">{{ latestRecord.score }} 分</text>
|
||||
<text class="score-meta">AI 初评 · {{ latestRecord.result }}</text>
|
||||
</view>
|
||||
<text class="status-title">{{ flow.notice }}</text>
|
||||
<text class="status-desc">{{ flow.desc }}</text>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
|
||||
<text class="review-badge">{{ reviewStatusLabel }}</text>
|
||||
</view>
|
||||
<button class="primary-button next-button" :disabled="loading" @click="runCurrentAction">
|
||||
{{ flow.stage === 'review' || flow.stage === 'complete' ? '刷新进度' : flow.cta }}
|
||||
</button>
|
||||
<text class="score-summary">{{ latestRecord.summary }}</text>
|
||||
<text class="score-evidence">{{ questionRecords.length }} 道题 · 已保存真实回答</text>
|
||||
</view>
|
||||
|
||||
<view class="panel steps-panel">
|
||||
<text class="panel-title">办理流程</text>
|
||||
<view class="step-list">
|
||||
<view v-for="step in steps" :key="step.index" class="step-row" :class="step.status">
|
||||
<view class="step-rail">
|
||||
<text class="step-dot">{{ step.status === 'complete' ? '✓' : step.index }}</text>
|
||||
<view v-if="step.index < steps.length" class="step-line" />
|
||||
</view>
|
||||
<view class="step-copy">
|
||||
<text class="step-title">{{ step.title }}</text>
|
||||
<text class="step-desc">{{ step.desc }}</text>
|
||||
</view>
|
||||
<text class="step-status">{{ stepStatusLabel(step.status) }}</text>
|
||||
<view class="question-card">
|
||||
<text class="section-title">问答记录</text>
|
||||
<view v-for="item in questionRecords" :key="item.seq" class="question-record">
|
||||
<view class="question-title-row">
|
||||
<text class="question-index">{{ item.seq }}</text>
|
||||
<text class="question-title">{{ item.question }}</text>
|
||||
</view>
|
||||
<text class="answer-text">回答:{{ item.answer }}</text>
|
||||
<text class="answer-rating" :class="ratingTone(item.rating)">{{ item.rating }}</text>
|
||||
</view>
|
||||
<view v-if="!questionRecords.length" class="empty question-empty">暂无问答明细</view>
|
||||
</view>
|
||||
|
||||
<view class="panel evidence-panel">
|
||||
<text class="panel-title">办理依据</text>
|
||||
<view v-if="latestRecord" class="evidence-row">
|
||||
<text class="evidence-label">面试记录</text>
|
||||
<view class="evidence-copy">
|
||||
<text class="evidence-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
|
||||
<text class="evidence-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="latestMaterial" class="evidence-row">
|
||||
<text class="evidence-label">最新资料</text>
|
||||
<view class="evidence-copy">
|
||||
<view class="material-title-row">
|
||||
<text class="evidence-title">{{ latestMaterial.fileName }}</text>
|
||||
<text class="status-pill" :class="materialStatusTone(latestMaterial.status)">{{ latestMaterial.status }}</text>
|
||||
</view>
|
||||
<text class="evidence-desc">{{ latestMaterial.time }} · {{ formatCandidateFileSize(latestMaterial.fileSize) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
|
||||
<view class="review-status-card">
|
||||
<text class="review-status-title">人工复核状态</text>
|
||||
<text class="review-status-desc">{{ reviewStatusDescription }}</text>
|
||||
</view>
|
||||
|
||||
<button class="primary-button home-button" @click="goBack">返回候选人中心</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -85,84 +66,51 @@ import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate';
|
||||
import {
|
||||
buildCandidateFlow,
|
||||
buildCandidateSteps,
|
||||
candidateRoutes,
|
||||
formatCandidateFileSize,
|
||||
materialStatusTone
|
||||
} from '@/services/candidate-view';
|
||||
import { candidateRoutes } from '@/services/candidate-view';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const currentPath = '/pages/candidate/progress/index';
|
||||
const records = ref<InterviewRecord[]>([]);
|
||||
const materials = ref<CandidateMaterial[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
|
||||
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
|
||||
const steps = computed(() => buildCandidateSteps(flow.value));
|
||||
const latestRecord = computed(() => records.value[0] || null);
|
||||
const latestMaterial = computed(() => materials.value[0] || null);
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const clearData = () => {
|
||||
records.value = [];
|
||||
materials.value = [];
|
||||
};
|
||||
const questionRecords = computed(() => latestRecord.value?.questions || []);
|
||||
const reviewStatusLabel = computed(() => latestMaterial.value?.status === '已通过' ? '人工复核完成' : '人工复核中');
|
||||
const reviewStatusDescription = computed(() => latestMaterial.value?.status === '已通过'
|
||||
? 'HR 已完成 AI 评分与补充资料复核,后续通知会同步更新。'
|
||||
: 'HR 正在复核 AI 评分与补充资料,预计 1-2 个工作日更新。');
|
||||
|
||||
const loadPage = async () => {
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
loggedIn.value = isLoggedIn();
|
||||
if (!loggedIn.value) {
|
||||
clearData();
|
||||
records.value = [];
|
||||
materials.value = [];
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [nextMaterials, nextRecords] = await Promise.all([
|
||||
[materials.value, records.value] = await Promise.all([
|
||||
getCandidateMaterials(),
|
||||
getCandidateInterviewRecords()
|
||||
]);
|
||||
materials.value = nextMaterials;
|
||||
records.value = nextRecords;
|
||||
message.value = '';
|
||||
} catch (error) {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
} else {
|
||||
message.value = error instanceof Error ? error.message : '办理进度加载失败';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loggedIn.value = isLoggedIn();
|
||||
message.value = loggedIn.value ? (error instanceof Error ? error.message : '面试记录加载失败') : '登录已过期,请重新登录';
|
||||
}
|
||||
};
|
||||
|
||||
const runCurrentAction = () => {
|
||||
if (flow.value.stage === 'review' || flow.value.stage === 'complete') {
|
||||
void loadPage();
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: flow.value.route });
|
||||
};
|
||||
|
||||
const stepStatusLabel = (status: 'complete' | 'current' | 'pending') => {
|
||||
if (status === 'complete') return '已完成';
|
||||
if (status === 'current') return '当前';
|
||||
return '待办理';
|
||||
};
|
||||
const ratingTone = (rating: string) => rating === '优秀' ? 'excellent' : rating === '良好' ? 'good' : 'needs-work';
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const openInterview = () => uni.navigateTo({ url: candidateRoutes.interview });
|
||||
|
||||
const goBack = () => {
|
||||
if (getCurrentPages().length <= 1) {
|
||||
uni.redirectTo({ url: candidateRoutes.index });
|
||||
@@ -178,228 +126,189 @@ onShow(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.candidate-subpage {
|
||||
background: #f4f7fb;
|
||||
.candidate-record-page {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.candidate-subpage .panel,
|
||||
.candidate-subpage button {
|
||||
border-radius: 8px;
|
||||
.record-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 4px 16px;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.status-head,
|
||||
.evidence-row,
|
||||
.material-title-row {
|
||||
.back-button {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 76px;
|
||||
padding: 0 10px;
|
||||
.score-value {
|
||||
display: block;
|
||||
color: #e60012;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.score-meta,
|
||||
.score-evidence {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #858991;
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
border-left: 4px solid #e60012;
|
||||
}
|
||||
|
||||
.status-badge,
|
||||
.status-progress {
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: #172033;
|
||||
font-size: 21px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
.question-empty {
|
||||
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>
|
||||
|
||||
@@ -92,17 +92,38 @@
|
||||
</view>
|
||||
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</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>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment } from '@/types/api';
|
||||
import type { PracticeAlert, PracticeAssignment } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
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 {
|
||||
createAsyncGenerationGuard,
|
||||
inferSupervisorScenarioId,
|
||||
@@ -118,6 +139,7 @@ const targetName = ref('');
|
||||
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
|
||||
const assignmentReason = ref('');
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const assigning = ref(false);
|
||||
@@ -132,6 +154,15 @@ const selectedTargetLabel = computed(() => {
|
||||
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
|
||||
});
|
||||
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) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
@@ -159,6 +190,7 @@ const resetDraft = () => {
|
||||
const clearPrivateState = () => {
|
||||
resetDraft();
|
||||
assignments.value = [];
|
||||
alerts.value = [];
|
||||
historyLoading.value = false;
|
||||
historyRequests.invalidate();
|
||||
};
|
||||
@@ -191,10 +223,11 @@ const loadAssignments = async (force = false) => {
|
||||
const generation = historyRequests.begin();
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
const [snapshot, nextAlerts] = await Promise.all([getPracticeTeamSnapshot(), getPracticeAlerts()]);
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
accessDenied.value = false;
|
||||
assignments.value = snapshot.assignments;
|
||||
alerts.value = nextAlerts;
|
||||
}
|
||||
} catch (error) {
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
@@ -306,8 +339,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.assign-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
@@ -368,8 +401,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.scenario-chip.active {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
@@ -429,4 +462,80 @@ onBeforeUnmount(() => {
|
||||
.compact-empty {
|
||||
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>
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="today" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -138,6 +139,7 @@ import type {
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
actionablePracticeAlerts,
|
||||
buildSupervisorMetrics,
|
||||
@@ -259,8 +261,8 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.supervisor-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.page-head,
|
||||
@@ -295,27 +297,27 @@ onShow(() => {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid #dbe7ff;
|
||||
border-radius: 8px;
|
||||
background: #e2e8f0;
|
||||
background: #dbe7ff;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
min-height: 104px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.metric-item.primary {
|
||||
background: #fff5f5;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.metric-item.primary .metric-value {
|
||||
color: #e70014;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.metric-item.primary .metric-label {
|
||||
color: #bd0010;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.metric-value,
|
||||
@@ -361,15 +363,16 @@ onShow(() => {
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: transparent;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row:last-child,
|
||||
.alert-row:last-child,
|
||||
.preview-row:last-child {
|
||||
border-bottom: 0;
|
||||
border-bottom: 1px solid #e5edf7;
|
||||
}
|
||||
|
||||
.task-copy,
|
||||
@@ -432,7 +435,7 @@ onShow(() => {
|
||||
.preview-score {
|
||||
min-width: 42px;
|
||||
flex: 0 0 auto;
|
||||
color: #dc2626;
|
||||
color: var(--employee-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
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>
|
||||
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
|
||||
</view>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -165,6 +166,7 @@ import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/ap
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
createAsyncGenerationGuard,
|
||||
isReviewEditable,
|
||||
@@ -411,8 +413,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.review-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
@@ -514,8 +516,8 @@ onBeforeUnmount(() => {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -618,8 +620,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.scenario-chip.active {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.next-training-section {
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
</view>
|
||||
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
|
||||
</view>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +66,7 @@ import type { PracticeAlert, PracticeRecord } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
|
||||
|
||||
const currentPath = '/pages/supervisor/reviews/index';
|
||||
@@ -176,8 +178,8 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.reviews-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
@@ -221,14 +223,14 @@ onShow(() => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: transparent;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.review-row:last-child {
|
||||
border-bottom: 0;
|
||||
border-bottom: 1px solid #e5edf7;
|
||||
}
|
||||
|
||||
.review-copy {
|
||||
@@ -277,7 +279,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.review-score text:first-child {
|
||||
color: #dc2626;
|
||||
color: var(--employee-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,13 @@
|
||||
<text class="muted">按风险排序</text>
|
||||
</view>
|
||||
<view v-if="teamProfiles.length" class="team-list">
|
||||
<view v-for="member in teamProfiles" :key="member.key" class="team-row" :class="member.risk">
|
||||
<view
|
||||
v-for="member in teamProfiles"
|
||||
:key="member.key"
|
||||
class="team-row"
|
||||
:class="member.risk"
|
||||
@click="selectMember(member)"
|
||||
>
|
||||
<view class="team-main">
|
||||
<view class="team-title-row">
|
||||
<text class="team-name">{{ member.displayName }}</text>
|
||||
@@ -65,12 +71,49 @@
|
||||
<text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text>
|
||||
</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 v-else-if="!loading" class="empty">暂无团队画像数据</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>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +124,7 @@ import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/t
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
buildTeamProfiles,
|
||||
inferSupervisorScenarioId,
|
||||
@@ -96,6 +140,7 @@ const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const selectedMember = ref<TeamProfile | null>(null);
|
||||
|
||||
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const riskCounts = computed(() => teamProfiles.value.reduce(
|
||||
@@ -105,11 +150,18 @@ const riskCounts = computed(() => teamProfiles.value.reduce(
|
||||
},
|
||||
{ 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 = () => {
|
||||
records.value = [];
|
||||
assignments.value = [];
|
||||
members.value = [];
|
||||
selectedMember.value = null;
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
@@ -167,6 +219,10 @@ const assignMember = (member: TeamProfile) => {
|
||||
uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` });
|
||||
};
|
||||
|
||||
const selectMember = (member: TeamProfile) => {
|
||||
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(currentPath);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
@@ -201,8 +257,8 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.team-page {
|
||||
padding-bottom: 42px;
|
||||
background: #f4f7fb;
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
@@ -239,7 +295,7 @@ onShow(() => {
|
||||
display: grid;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid #dbe7ff;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -247,7 +303,7 @@ onShow(() => {
|
||||
|
||||
.risk-item {
|
||||
padding: 13px 8px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-right: 1px solid #dbe7ff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -281,7 +337,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.risk-item.low .risk-value {
|
||||
color: #15803d;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.team-list {
|
||||
@@ -294,8 +350,10 @@ onShow(() => {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 0 14px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #22c55e;
|
||||
border: 1px solid #e5edf7;
|
||||
border-left: 4px solid var(--employee-primary);
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.team-row:last-child {
|
||||
@@ -350,8 +408,8 @@ onShow(() => {
|
||||
.team-tag {
|
||||
padding: 4px 7px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -363,4 +421,110 @@ onShow(() => {
|
||||
padding: 0 10px;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -774,11 +774,12 @@ onShow(() => {
|
||||
|
||||
.practice-head::after {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
bottom: 24px;
|
||||
width: 56px;
|
||||
height: 3px;
|
||||
background: #e70014;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--employee-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -1058,7 +1059,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.dialogue-card {
|
||||
border-color: #f0dfe0;
|
||||
border-color: #dbe7ff;
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
}
|
||||
|
||||
@@ -1120,19 +1121,19 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.emotion-fill {
|
||||
background: #e45757;
|
||||
background: #6f9cff;
|
||||
}
|
||||
|
||||
.trust-fill {
|
||||
background: #d51b2b;
|
||||
background: #2f6bff;
|
||||
}
|
||||
|
||||
.red-flag {
|
||||
padding: 9px 10px;
|
||||
border-left: 3px solid #e70014;
|
||||
border-left: 3px solid #f59e0b;
|
||||
border-radius: 4px;
|
||||
background: #fff2f3;
|
||||
color: #bd0010;
|
||||
background: #fff7e6;
|
||||
color: #a15c00;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
@@ -1162,19 +1163,19 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.turn-row.trainee {
|
||||
border-left-color: #e70014;
|
||||
background: #fff6f6;
|
||||
border-left-color: #2f6bff;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.turn-row.coach {
|
||||
border-left-color: #d98282;
|
||||
background: #fff5f5;
|
||||
border-left-color: #8bb1ff;
|
||||
background: #f4f7ff;
|
||||
}
|
||||
|
||||
.reply-actions button.recording-active {
|
||||
border-color: #e70014;
|
||||
background: #fff5f5;
|
||||
color: #e70014;
|
||||
border-color: #2f6bff;
|
||||
background: #eef4ff;
|
||||
color: #2f6bff;
|
||||
}
|
||||
|
||||
.turn-role {
|
||||
@@ -1198,9 +1199,9 @@ onShow(() => {
|
||||
|
||||
.style-chip {
|
||||
width: auto;
|
||||
border-color: #f0c8ca;
|
||||
border-color: #c9d9ff;
|
||||
background: #ffffff;
|
||||
color: #d51b2b;
|
||||
color: #2f6bff;
|
||||
}
|
||||
|
||||
.reply-card {
|
||||
@@ -1210,7 +1211,7 @@ onShow(() => {
|
||||
|
||||
.practice-textarea {
|
||||
min-height: 124px;
|
||||
border-color: #f0dfe0;
|
||||
border-color: #dbe7ff;
|
||||
border-radius: 18px;
|
||||
background: #f9fbff;
|
||||
}
|
||||
@@ -1230,8 +1231,8 @@ onShow(() => {
|
||||
.help-ref {
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
background: #fff1f2;
|
||||
color: #d51b2b;
|
||||
background: #eef4ff;
|
||||
color: #2f6bff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1243,7 +1244,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.result-score {
|
||||
color: #e52323;
|
||||
color: #2f6bff;
|
||||
font-size: 44px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
@@ -1261,55 +1262,56 @@ onShow(() => {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid #e52323;
|
||||
border-left: 3px solid #2f6bff;
|
||||
border-radius: 6px;
|
||||
background: #fff6f6;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.mentor-rewrite text:first-child {
|
||||
color: #c71927;
|
||||
color: #2f6bff;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* 练页入口态:对应高保真原型,真实对话状态仍沿用下方业务面板 */
|
||||
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
|
||||
.practice-page {
|
||||
padding: 0 18px 104px;
|
||||
background: #fafafa;
|
||||
padding: 0 16px 108px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.practice-head {
|
||||
margin: 0 -18px;
|
||||
padding: 36px 18px 26px;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
margin: 0 -16px;
|
||||
padding: 30px 20px 24px;
|
||||
border-bottom: 1px solid var(--employee-line);
|
||||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.practice-head::after,
|
||||
.practice-eyebrow,
|
||||
.practice-tip {
|
||||
display: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.practice-head .page-title {
|
||||
color: #111111;
|
||||
font-size: 38px;
|
||||
color: var(--employee-text);
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.practice-head .page-subtitle {
|
||||
max-width: none;
|
||||
margin-top: 8px;
|
||||
color: #858991;
|
||||
font-size: 16px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 18px;
|
||||
margin-top: 16px;
|
||||
padding: 22px 18px 18px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--employee-card-radius);
|
||||
border-left: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
|
||||
@@ -1323,13 +1325,13 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
color: #111111;
|
||||
color: var(--employee-text);
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
color: #858991;
|
||||
color: var(--employee-muted);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
@@ -1340,7 +1342,7 @@ onShow(() => {
|
||||
width: 42%;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background: #fff1f2;
|
||||
background: var(--employee-primary-soft);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -1352,7 +1354,7 @@ onShow(() => {
|
||||
height: 76px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.08);
|
||||
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.08);
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
@@ -1363,7 +1365,7 @@ onShow(() => {
|
||||
width: 48px;
|
||||
height: 6px;
|
||||
border-radius: 6px;
|
||||
background: #f5dfe1;
|
||||
background: #dce8ff;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -1386,11 +1388,11 @@ onShow(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: #ef5b61;
|
||||
background: #7ba4ff;
|
||||
color: #ffffff;
|
||||
font-size: 19px;
|
||||
letter-spacing: 3px;
|
||||
box-shadow: 0 8px 14px rgba(231, 0, 20, 0.16);
|
||||
box-shadow: 0 8px 14px rgba(47, 107, 255, 0.16);
|
||||
}
|
||||
|
||||
.visual-bubble::after {
|
||||
@@ -1399,7 +1401,7 @@ onShow(() => {
|
||||
bottom: -7px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #ef5b61;
|
||||
background: #7ba4ff;
|
||||
content: '';
|
||||
transform: rotate(35deg);
|
||||
}
|
||||
@@ -1421,27 +1423,47 @@ onShow(() => {
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid #f4d9da;
|
||||
border: 1px solid #dce8ff;
|
||||
border-radius: 12px;
|
||||
background: #fffafa;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.prep-mini .prep-label {
|
||||
color: #e70014;
|
||||
color: var(--employee-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.prep-mini text:last-child {
|
||||
color: #8c8f96;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.scenario-card .primary-button {
|
||||
margin-top: 18px;
|
||||
background: #e70014;
|
||||
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.18);
|
||||
background: linear-gradient(135deg, #2f6bff, #5a8cff);
|
||||
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.18);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
border-left-color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.assignment-row {
|
||||
border-color: #dbe7ff;
|
||||
}
|
||||
|
||||
.assignment-row.highlighted {
|
||||
border-color: #9bbaff;
|
||||
background: #f4f7ff;
|
||||
}
|
||||
|
||||
.assignment-badge,
|
||||
.assignment-badge.daily,
|
||||
.scenario-badge {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.recent-panel,
|
||||
@@ -1449,14 +1471,14 @@ onShow(() => {
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
|
||||
}
|
||||
|
||||
.recent-link,
|
||||
.daily-action-label {
|
||||
color: #858991;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1481,8 +1503,8 @@ onShow(() => {
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff1f2;
|
||||
color: #e52323;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1502,7 +1524,7 @@ onShow(() => {
|
||||
|
||||
.recent-row .assignment-desc {
|
||||
margin-top: 4px;
|
||||
color: #858991;
|
||||
color: var(--employee-muted);
|
||||
}
|
||||
|
||||
.recent-arrow {
|
||||
@@ -1517,7 +1539,7 @@ onShow(() => {
|
||||
.daily-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #858991;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1529,17 +1551,17 @@ onShow(() => {
|
||||
gap: 5px;
|
||||
margin-top: 14px;
|
||||
padding: 0 14px;
|
||||
border: 1px dashed #f0c8ca;
|
||||
border: 1px dashed #cfe0ff;
|
||||
border-radius: 14px;
|
||||
background: #fffafa;
|
||||
color: #858991;
|
||||
background: #f8fbff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.daily-empty text:first-child {
|
||||
color: #d51b2b;
|
||||
color: var(--employee-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1556,13 +1578,18 @@ onShow(() => {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.daily-row .small-button {
|
||||
background: linear-gradient(135deg, #2f6bff, #5a8cff);
|
||||
box-shadow: 0 6px 12px rgba(47, 107, 255, 0.16);
|
||||
}
|
||||
|
||||
.capability-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 14px;
|
||||
padding: 16px 8px;
|
||||
border-radius: 18px;
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
|
||||
}
|
||||
@@ -1580,13 +1607,13 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.capability-strip text:first-child {
|
||||
color: #1a1a1a;
|
||||
color: var(--employee-text);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.capability-strip text:last-child {
|
||||
color: #8a8d93;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page profile-page">
|
||||
<view class="page profile-page employee-page-shell">
|
||||
<view class="profile-head">
|
||||
<text class="profile-eyebrow">银城物业 · 员工成长</text>
|
||||
<text class="page-title">我的成长</text>
|
||||
@@ -120,7 +120,12 @@
|
||||
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
|
||||
</view>
|
||||
<view v-if="history.length" class="history-list">
|
||||
<view v-for="item in history" :key="item.id || item.sessionId || item.time" class="history-row">
|
||||
<view
|
||||
v-for="item in history"
|
||||
:key="item.id || item.sessionId || item.time"
|
||||
class="history-row history-row-clickable"
|
||||
@click="openHistory(item.id)"
|
||||
>
|
||||
<view>
|
||||
<text class="history-title">{{ item.scene }}</text>
|
||||
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
|
||||
@@ -231,6 +236,14 @@ const changePosition = (position: UserPosition) => {
|
||||
uni.showToast({ title: '岗位已更新', icon: 'none' });
|
||||
};
|
||||
|
||||
const openHistory = (id?: number) => {
|
||||
if (!id) {
|
||||
message.value = '这条训练记录暂不支持查看详情';
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/user/history/index?id=${id}` });
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
resetPageScroll();
|
||||
void loadData();
|
||||
@@ -239,45 +252,52 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
padding: 0 18px 96px;
|
||||
background: #f7f7f8;
|
||||
padding: 0 16px 108px;
|
||||
}
|
||||
|
||||
.profile-page .panel {
|
||||
border-radius: var(--employee-card-radius);
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
}
|
||||
|
||||
.profile-head {
|
||||
position: relative;
|
||||
margin: 0 -18px;
|
||||
padding: 34px 28px 26px;
|
||||
background: #f0f1ee;
|
||||
margin: 0 -16px;
|
||||
padding: 30px 20px 26px;
|
||||
border-bottom: 1px solid rgba(47, 74, 117, 0.08);
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%);
|
||||
}
|
||||
|
||||
.profile-head::after {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
bottom: 24px;
|
||||
width: 56px;
|
||||
height: 3px;
|
||||
background: #e70014;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--employee-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.profile-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
color: #168243;
|
||||
margin-bottom: 10px;
|
||||
color: var(--employee-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.profile-head .page-title {
|
||||
color: #25282d;
|
||||
font-size: 32px;
|
||||
line-height: 1.16;
|
||||
color: var(--employee-text);
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.profile-head .page-subtitle {
|
||||
margin-top: 10px;
|
||||
color: #696c72;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -290,15 +310,14 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.account-card {
|
||||
margin-top: 22px;
|
||||
border-left: 4px solid #e70014;
|
||||
box-shadow: 0 8px 22px rgba(37, 40, 45, 0.08);
|
||||
margin-top: 16px;
|
||||
border-left: 4px solid var(--employee-primary);
|
||||
}
|
||||
|
||||
.account-phone {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #676a70;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -317,7 +336,7 @@ onShow(() => {
|
||||
.position-title {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #168243;
|
||||
color: var(--employee-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -334,24 +353,24 @@ onShow(() => {
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #dedfe2;
|
||||
border-radius: 6px;
|
||||
background: #fbfbfb;
|
||||
color: #5e6167;
|
||||
border: 1px solid #dce5f2;
|
||||
border-radius: var(--employee-chip-radius);
|
||||
background: #f8fbff;
|
||||
color: #526176;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.position-chip.active {
|
||||
border-color: #168243;
|
||||
background: #edf7f0;
|
||||
color: #168243;
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
border-color: #d9e7de;
|
||||
border-top: 3px solid #168243;
|
||||
border-color: var(--employee-line);
|
||||
border-top: 3px solid var(--employee-primary);
|
||||
}
|
||||
|
||||
.score-row {
|
||||
@@ -367,12 +386,12 @@ onShow(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #edf7f0;
|
||||
border-radius: 18px;
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.score-value {
|
||||
color: #168243;
|
||||
color: var(--employee-primary);
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
@@ -380,7 +399,7 @@ onShow(() => {
|
||||
|
||||
.score-label {
|
||||
margin-top: 8px;
|
||||
color: #5f6268;
|
||||
color: var(--employee-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -396,26 +415,27 @@ onShow(() => {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid var(--employee-line);
|
||||
border-radius: 14px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.metric text:first-child {
|
||||
color: #303338;
|
||||
color: var(--employee-text);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #73767c;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-panel {
|
||||
border-color: #eadfbd;
|
||||
border-left: 4px solid #d98200;
|
||||
background: #fffdf7;
|
||||
border-color: #f4e4c4;
|
||||
border-left: 4px solid var(--employee-warning);
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
@@ -431,19 +451,20 @@ onShow(() => {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f4e7ce;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.evidence-metric text:first-child {
|
||||
color: #a86100;
|
||||
color: #b56b14;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.evidence-metric text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #73767c;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -494,14 +515,14 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.growth-title {
|
||||
color: #34373c;
|
||||
color: var(--employee-text);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.growth-desc {
|
||||
margin-top: 5px;
|
||||
color: #73767c;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -513,7 +534,7 @@ onShow(() => {
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #168243;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -523,13 +544,13 @@ onShow(() => {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #dce8e0;
|
||||
background: #dce8fb;
|
||||
}
|
||||
|
||||
.growth-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #168243;
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.dimension-row,
|
||||
@@ -545,7 +566,7 @@ onShow(() => {
|
||||
.dimension-title,
|
||||
.history-title {
|
||||
display: block;
|
||||
color: #34373c;
|
||||
color: var(--employee-text);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -553,13 +574,13 @@ onShow(() => {
|
||||
.history-desc {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #73767c;
|
||||
color: var(--employee-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dimension-score {
|
||||
color: #168243;
|
||||
color: var(--employee-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -567,14 +588,25 @@ onShow(() => {
|
||||
.refresh-button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dedee1;
|
||||
border: 1px solid #dce5f2;
|
||||
border-radius: var(--employee-chip-radius);
|
||||
background: #ffffff;
|
||||
color: #66696f;
|
||||
color: #526176;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -585,14 +617,14 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.history-score text:first-child {
|
||||
color: #168243;
|
||||
color: var(--employee-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.history-score text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #73767c;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,8 +61,14 @@
|
||||
</view>
|
||||
|
||||
<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>
|
||||
</view>
|
||||
|
||||
<view v-if="result.keyPoints?.length" class="keypoints">
|
||||
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
|
||||
@@ -81,7 +87,7 @@
|
||||
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
|
||||
@click="feedback('up')"
|
||||
>
|
||||
有用
|
||||
有帮助
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
@@ -89,9 +95,12 @@
|
||||
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
|
||||
@click="feedback('down')"
|
||||
>
|
||||
需要复核
|
||||
没解决
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="feedbackVerdict" class="feedback-status">
|
||||
{{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result?.snippets?.length" class="panel sources-panel">
|
||||
@@ -115,7 +124,7 @@
|
||||
<view class="modal-card" @click.stop>
|
||||
<view class="modal-header">
|
||||
<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>
|
||||
<scroll-view scroll-y class="modal-body">
|
||||
<view v-if="summaryCard.steps?.length" class="info-section">
|
||||
@@ -378,24 +387,26 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.sop-page {
|
||||
padding: 0 18px 96px;
|
||||
padding: 0 16px 108px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.sop-head {
|
||||
position: relative;
|
||||
margin: 0 -18px;
|
||||
padding: 30px 28px 24px;
|
||||
margin: 0 -16px;
|
||||
padding: 30px 20px 24px;
|
||||
border-bottom: 1px solid var(--employee-line);
|
||||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||||
}
|
||||
|
||||
.sop-head::after {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
bottom: 24px;
|
||||
width: 56px;
|
||||
height: 3px;
|
||||
background: #e70014;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--employee-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -436,7 +447,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
margin-top: 22px;
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
border-top: 0;
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
@@ -523,13 +534,43 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.answer-panel {
|
||||
border-color: #d9e7de;
|
||||
border-left: 4px solid #168243;
|
||||
border-color: var(--employee-line);
|
||||
border-left: 4px solid var(--employee-primary);
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -568,15 +609,25 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.feedback-actions .active {
|
||||
border-color: #168243;
|
||||
background: #edf7f0;
|
||||
color: #168243;
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.feedback-actions .negative {
|
||||
border-color: #e70014;
|
||||
background: #fff1f2;
|
||||
color: #bd0010;
|
||||
border-color: var(--employee-danger);
|
||||
background: #fff1f0;
|
||||
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 {
|
||||
@@ -600,7 +651,8 @@ onShow(() => {
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e3e4e7;
|
||||
border-radius: 8px;
|
||||
border-color: var(--employee-line);
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04);
|
||||
}
|
||||
@@ -629,8 +681,8 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
border-color: #eadfbd;
|
||||
background: #fffdf7;
|
||||
border-color: #f4e4c4;
|
||||
background: #fffaf2;
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
}
|
||||
|
||||
@@ -719,7 +771,8 @@ onShow(() => {
|
||||
|
||||
.footer-btn {
|
||||
width: 100%;
|
||||
background: #168243;
|
||||
background: var(--employee-primary);
|
||||
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -915,55 +968,55 @@ onShow(() => {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* 视觉稿对齐:与练页统一的红白卡片体系 */
|
||||
/* 视觉稿对齐:与员工端练页统一的蓝白轻卡片体系 */
|
||||
.sop-page {
|
||||
padding: 0 18px 96px;
|
||||
background: #fafafa;
|
||||
padding: 0 16px 108px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
}
|
||||
|
||||
.sop-head {
|
||||
margin: 0 -18px;
|
||||
padding: 34px 18px 24px;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
margin: 0 -16px;
|
||||
padding: 30px 20px 24px;
|
||||
border-bottom: 1px solid var(--employee-line);
|
||||
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sop-head::after,
|
||||
.sop-eyebrow,
|
||||
.toolbar-tip {
|
||||
display: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sop-head .page-title {
|
||||
color: #111111;
|
||||
font-size: 38px;
|
||||
color: var(--employee-text);
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.sop-head .page-subtitle {
|
||||
max-width: none;
|
||||
margin-top: 10px;
|
||||
color: #858991;
|
||||
font-size: 16px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sop-context {
|
||||
display: inline-flex;
|
||||
margin-top: 14px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #f8d7d8;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
background: #fff7f7;
|
||||
color: #d51b2b;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.query-panel {
|
||||
margin-top: 18px;
|
||||
margin-top: 16px;
|
||||
padding: 22px 18px 18px;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.06);
|
||||
}
|
||||
@@ -979,18 +1032,18 @@ onShow(() => {
|
||||
|
||||
.query-panel .chip {
|
||||
min-height: 40px;
|
||||
border-color: #e52323;
|
||||
border-color: #cfe0ff;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #e52323;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.query-submit {
|
||||
min-height: 54px;
|
||||
margin-top: 8px;
|
||||
border-radius: 18px;
|
||||
background: #e52323;
|
||||
box-shadow: 0 8px 16px rgba(229, 35, 35, 0.18);
|
||||
background: linear-gradient(135deg, #2f6bff, #5a8cff);
|
||||
box-shadow: 0 8px 18px rgba(47, 107, 255, 0.2);
|
||||
}
|
||||
|
||||
.knowledge-tip-card {
|
||||
@@ -1001,8 +1054,10 @@ onShow(() => {
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--employee-line);
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.05);
|
||||
box-shadow: var(--employee-card-shadow);
|
||||
}
|
||||
|
||||
.knowledge-tip-icon {
|
||||
@@ -1012,8 +1067,8 @@ onShow(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: #fff0f1;
|
||||
color: #e52323;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -1050,7 +1105,7 @@ onShow(() => {
|
||||
padding: 28px 18px;
|
||||
flex-direction: column;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(24, 28, 36, 0.04);
|
||||
}
|
||||
@@ -1075,67 +1130,68 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.answer-panel {
|
||||
border-color: #f0dfe0;
|
||||
border-left-color: #e52323;
|
||||
border-color: var(--employee-line);
|
||||
border-left-color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.answer-panel .panel-title,
|
||||
.result-score {
|
||||
color: #e52323;
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.keypoint,
|
||||
.speech-bubble {
|
||||
border-left-color: #e52323;
|
||||
background: #fff5f5;
|
||||
border-left-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.cautions,
|
||||
.reminder-item {
|
||||
border-left-color: #e52323;
|
||||
background: #fff5f5;
|
||||
color: #b71925;
|
||||
border-left-color: var(--employee-warning);
|
||||
background: #fff8ec;
|
||||
color: #8b5a1d;
|
||||
}
|
||||
|
||||
.feedback-actions .active {
|
||||
border-color: #e52323;
|
||||
background: #fff0f1;
|
||||
color: #e52323;
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.feedback-actions .negative {
|
||||
border-color: #e52323;
|
||||
background: #fff0f1;
|
||||
color: #b71925;
|
||||
border-color: var(--employee-danger);
|
||||
background: #fff1f0;
|
||||
color: #c34d45;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
border-color: #f0dfe0;
|
||||
border-color: #f4e4c4;
|
||||
border-radius: var(--employee-card-radius);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.summary-panel .ghost-button {
|
||||
border-color: #e52323;
|
||||
background: #ffffff;
|
||||
color: #e52323;
|
||||
border-color: #cfe0ff;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
border-top-color: #e52323;
|
||||
border-top-color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.badge-num,
|
||||
.timeline-node {
|
||||
background: #e52323;
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.a-badge {
|
||||
background: #fff0f1;
|
||||
color: #e52323;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.reminder-text {
|
||||
color: #b71925;
|
||||
color: #8b5a1d;
|
||||
}
|
||||
|
||||
/* 原型对齐:问师傅主卡的标题与对话插画 */
|
||||
@@ -1155,14 +1211,14 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.query-title {
|
||||
color: #111111;
|
||||
color: var(--employee-text);
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.query-subtitle {
|
||||
color: #858991;
|
||||
color: var(--employee-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1174,7 +1230,7 @@ onShow(() => {
|
||||
width: 42%;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: #fff3f3;
|
||||
background: var(--employee-primary-soft);
|
||||
}
|
||||
|
||||
.query-bubble {
|
||||
@@ -1192,7 +1248,7 @@ onShow(() => {
|
||||
width: 92px;
|
||||
height: 56px;
|
||||
border-radius: 28px 28px 28px 10px;
|
||||
background: #f58d91;
|
||||
background: #7ba4ff;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -1203,7 +1259,7 @@ onShow(() => {
|
||||
left: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #f58d91;
|
||||
background: #7ba4ff;
|
||||
content: '';
|
||||
transform: rotate(28deg);
|
||||
}
|
||||
@@ -1215,8 +1271,8 @@ onShow(() => {
|
||||
height: 42px;
|
||||
border-radius: 22px 22px 8px 22px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 5px 14px rgba(229, 35, 35, 0.08);
|
||||
color: #f3b7b9;
|
||||
box-shadow: 0 5px 14px rgba(47, 107, 255, 0.1);
|
||||
color: #9bb8ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,9 @@
|
||||
</view>
|
||||
<text class="schedule-desc">{{ primaryScheduleDesc }}</text>
|
||||
</view>
|
||||
<button class="schedule-action" @click="openPrimarySchedule">开始</button>
|
||||
<button class="schedule-action" @click="openPrimarySchedule">
|
||||
{{ primaryAssignment?.source === 'daily' ? '去作答' : '开始练习' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="schedule-row" v-if="home?.review">
|
||||
|
||||
@@ -5,6 +5,7 @@ export const clientIdKey = 'aihr_mobile_client_id';
|
||||
export const phoneKey = 'aihr_mobile_phone';
|
||||
export const loginRedirectKey = 'aihr_mobile_login_redirect';
|
||||
export const positionKey = 'aihr_mobile_position';
|
||||
export const roleKey = 'aihr_mobile_role';
|
||||
export const phonePattern = /^1[3-9]\d{9}$/;
|
||||
|
||||
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const;
|
||||
@@ -106,6 +107,7 @@ export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
const previousPhone = uni.getStorageSync(phoneKey) || '';
|
||||
if (previousPhone && previousPhone !== phone) {
|
||||
uni.removeStorageSync(positionKey);
|
||||
uni.removeStorageSync(roleKey);
|
||||
}
|
||||
uni.setStorageSync(tokenKey, result.access_token);
|
||||
uni.setStorageSync(clientIdKey, result.client_id);
|
||||
@@ -117,6 +119,7 @@ export const clearAuth = () => {
|
||||
uni.removeStorageSync(clientIdKey);
|
||||
uni.removeStorageSync(phoneKey);
|
||||
uni.removeStorageSync(positionKey);
|
||||
uni.removeStorageSync(roleKey);
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { OrgSnapshotResponse } from '@/types/api';
|
||||
import type { MobileIdentity, OrgSnapshotResponse, RoleKey } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import {
|
||||
getAuth,
|
||||
hasSelectedPosition,
|
||||
normalizePosition,
|
||||
roleKey,
|
||||
saveSelectedPosition,
|
||||
type UserPosition
|
||||
} from './auth';
|
||||
@@ -17,6 +18,31 @@ export const userTabPages = new Set([
|
||||
|
||||
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) =>
|
||||
`/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) => {
|
||||
const role = await hydrateMobileRole();
|
||||
const roleRoute = routeForMobileRole(role, redirect);
|
||||
if (roleRoute !== redirect) return roleRoute;
|
||||
if (!isUserRoute(redirect) || hasSelectedPosition()) return redirect;
|
||||
const officialPosition = await hydratePositionFromOrg();
|
||||
return normalizePosition(officialPosition) ? redirect : positionSetupUrl(redirect);
|
||||
|
||||
@@ -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 = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<CompetencyProfile>({
|
||||
|
||||
@@ -12,8 +12,8 @@ page {
|
||||
--ink: #182230;
|
||||
--muted: #647083;
|
||||
--line: #dfe4ea;
|
||||
--brand: #e70014;
|
||||
--brand-pressed: #bd0010;
|
||||
--brand: #e60012;
|
||||
--brand-pressed: #b8000f;
|
||||
--knowledge: #315b8f;
|
||||
--success: #168243;
|
||||
--warning: #a86100;
|
||||
@@ -52,12 +52,24 @@ button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(230, 0, 18, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 22px 16px 96px;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
@@ -187,7 +199,11 @@ button::after {
|
||||
.primary-button[disabled],
|
||||
.secondary-button[disabled],
|
||||
.ghost-button[disabled] {
|
||||
opacity: 0.55;
|
||||
opacity: 1;
|
||||
border-color: #e1e4e8;
|
||||
background: #f1f2f4;
|
||||
box-shadow: none;
|
||||
color: #9aa0a8;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface LoginResult {
|
||||
|
||||
export type RoleKey = 'user' | 'supervisor' | 'candidate';
|
||||
|
||||
export interface MobileIdentity {
|
||||
role: RoleKey;
|
||||
roleLabel: string;
|
||||
positionName: string;
|
||||
positionLevel: string;
|
||||
}
|
||||
|
||||
export interface ToolItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
@@ -374,6 +381,14 @@ export interface InterviewRecord {
|
||||
score: number;
|
||||
result: string;
|
||||
summary: string;
|
||||
questions?: InterviewQuestionRecord[];
|
||||
}
|
||||
|
||||
export interface InterviewQuestionRecord {
|
||||
seq: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
rating: string;
|
||||
}
|
||||
|
||||
export interface InterviewFinishResponse {
|
||||
|
||||
@@ -46,7 +46,7 @@ test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
|
||||
test('员工端 tabBar 继续保留四个固定入口', async () => {
|
||||
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\/practice\/index/);
|
||||
assert.match(source, /pages\/user\/sop\/index/);
|
||||
@@ -84,6 +84,14 @@ test('员工端三页接入共享页面和卡片基座', async () => {
|
||||
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 () => {
|
||||
const source = await pageSource('../src/pages/user/today/index.vue');
|
||||
|
||||
@@ -107,6 +115,7 @@ test('员工首页高保真入口继续使用真实图标和业务跳转', async
|
||||
assert.match(source, /const openPrimarySchedule = \(\) =>/);
|
||||
assert.match(source, /getMobileHome\('user'\)/);
|
||||
assert.match(source, /getPracticeAssignments\(true\)/);
|
||||
assert.match(source, /src="\/static\/mentor-hifi\.png"/);
|
||||
});
|
||||
|
||||
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
|
||||
|
||||
@@ -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/profile/index.vue "贡献度"
|
||||
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/case.ts "/api/knowledge/case/upload"
|
||||
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/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 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"
|
||||
@@ -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 "提示词模板"
|
||||
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/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/doc/upload"
|
||||
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 "答案待复核"
|
||||
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/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"
|
||||
|
||||
Reference in New Issue
Block a user