feat(aihr): complete phase two pilot readiness

This commit is contained in:
2026-07-10 19:13:31 +08:00
parent 0219de6fa8
commit 13230e6b9a
48 changed files with 7040 additions and 1647 deletions
+1
View File
@@ -61,6 +61,7 @@
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。
- 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。
- 当前业务页已跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 已接真 LLM(`AihrPracticeLlmService`,评分 temperature=0 结构化输出),seed 剧本是剧情锚点与兜底,改对练逻辑时必须保留"未配置/失败回退 seed"的降级链,不要让演示依赖外部 API。
- 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。
+3 -2
View File
@@ -15,6 +15,7 @@
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/api/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
@@ -45,7 +46,7 @@
| [文档目录](docs/README.md) | docs 区完整索引 |
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、接口接入顺序、移动端训练记录、候选资料上传/审核和 SOP 上传解析接口 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
@@ -58,4 +59,4 @@
## 下一步
现役演示验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。2026-07-07 立项会后按 [银城员工端APP分阶段实施总纲](docs/银城员工端APP分阶段实施总纲.md) 三阶段推进:阶段一即 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)(时间锚点 2026-07-10 Alpha → 半年会发布 → 9 月收费季前试点可用);大喇叭信息平台、知识学习平台、北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
现役验收入口是 [DEMO_ACCEPTANCE.md](docs/DEMO_ACCEPTANCE.md)。阶段一的工程闭环已经就绪,当前唯一主线是补齐正式组织身份数据,完成 1–2 个项目、至少 20 名员工的真实训练、校准和 SOP 业务评审后重跑 M5 严格门禁;详见 [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md)。大喇叭信息平台、知识学习平台和北森对接属阶段二/三,不进当前主线。后续迭代同时参考 [H5_REDESIGN.md](docs/H5_REDESIGN.md)、[BACKLOG.md](docs/BACKLOG.md) 和 [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md),不把归档排期当当前待办。
@@ -10,6 +10,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -63,6 +64,11 @@ public class AihrMobileController {
return R.ok(mobileSeedService.practiceReviews(supervisorScopeExtPartyId(), projectCode, extPartyId, limit));
}
@GetMapping("/practice/team")
public R<PracticeTeamResponse> practiceTeam() {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
}
@GetMapping("/practice/reviews/{id}")
public R<Map<String, Object>> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id, supervisorScopeExtPartyId());
@@ -101,7 +107,7 @@ public class AihrMobileController {
@PostMapping("/practice/assignments")
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername()));
return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/assignments/{id}/answer")
@@ -117,15 +123,13 @@ public class AihrMobileController {
private static String scopedAssignmentExtPartyId(String requested) {
if (requested == null || requested.isBlank()) {
// ponytail: supervisor H5 keeps team-scope listing until mobile roles exist.
return requested;
}
return ownMobileExtPartyId(requested);
}
private static String supervisorScopeExtPartyId() {
// ponytail: supervisor H5 keeps team-scope reviews/alerts until mobile roles exist.
return "";
private String supervisorScopeExtPartyId() {
return mobileSeedService.requireSupervisorIdentity(currentAppUsername());
}
private static String currentAppUsername() {
@@ -26,6 +26,7 @@ import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.HttpHeaders;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
/**
@@ -86,12 +88,15 @@ public class AihrPracticeController {
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/export")
public void export(HttpServletResponse response) throws IOException {
byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv()).getBytes(StandardCharsets.UTF_8);
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
HttpServletResponse response) throws IOException {
byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv(startDate, endDate)).getBytes(StandardCharsets.UTF_8);
response.reset();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("text/csv;charset=UTF-8");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=aihr-practice-pilot-" + startDate + "-" + endDate + ".csv");
response.setContentLength(body.length);
response.getOutputStream().write(body);
}
@@ -208,6 +208,16 @@ public final class AihrPracticeDto {
) {
}
public record TeamMemberResponse(String extPartyId, String name) {
}
public record PracticeTeamResponse(
List<TeamMemberResponse> members,
List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments
) {
}
public record DailyDrillAnswerRequest(String answer) {
}
@@ -6,6 +6,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -60,6 +61,14 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit);
}
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) {
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200);
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity);
}
public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id);
}
@@ -71,11 +71,13 @@ public class AihrOrgSyncService {
if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
}
requireSnapshotTable();
int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE);
int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES);
boolean dryRun = Boolean.TRUE.equals(req.dryRun());
if (!dryRun) {
requireSnapshotTable();
}
boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting());
String token = accessToken(baseUrl);
List<String> warnings = new ArrayList<>();
@@ -20,6 +20,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
@@ -35,15 +36,22 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -70,6 +78,115 @@ public class AihrPracticeSeedService {
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final String PILOT_SCOPE_CTE = """
WITH pilot_params AS (
SELECT ? AS tenant_id, ? AS start_time, ? AS end_time
), active_org AS (
SELECT DISTINCT o.ext_party_id, o.project_code, o.person_phone
FROM aihr_org_snapshot o
JOIN pilot_params p ON p.tenant_id = o.tenant_id
WHERE o.employment_status = 'active'
AND o.person_phone IS NOT NULL AND o.person_phone <> ''
), all_org_keys AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM active_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM active_org
), conflict_keys AS (
SELECT identity_key
FROM all_org_keys
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) > 1
), unsafe_org AS (
SELECT DISTINCT k.ext_party_id
FROM all_org_keys k
JOIN conflict_keys c ON c.identity_key = k.identity_key
), safe_org AS (
SELECT o.ext_party_id, o.project_code, o.person_phone
FROM active_org o
LEFT JOIN unsafe_org u ON u.ext_party_id = o.ext_party_id
WHERE u.ext_party_id IS NULL
), org_key_candidates AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM safe_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM safe_org
), org_identity AS (
SELECT identity_key, MIN(ext_party_id) AS ext_party_id
FROM org_key_candidates
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) = 1
), pilot_keys AS (
SELECT s.ext_party_id AS identity_key
FROM aihr_practice_session s
JOIN pilot_params p ON p.tenant_id = s.tenant_id
WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time
UNION
SELECT a.ext_party_id AS identity_key
FROM aihr_practice_assignment a
JOIN pilot_params p ON p.tenant_id = a.tenant_id
WHERE a.create_time >= p.start_time AND a.create_time < p.end_time
), pilot_org AS (
SELECT DISTINCT o.ext_party_id
FROM pilot_keys k
JOIN org_identity o ON o.identity_key = k.identity_key
), formal_sessions AS (
SELECT s.*, o.ext_party_id AS formal_ext_party_id
FROM aihr_practice_session s
JOIN pilot_params p ON p.tenant_id = s.tenant_id
JOIN org_identity o ON o.identity_key = s.ext_party_id
WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time
), completed_org AS (
SELECT formal_ext_party_id
FROM formal_sessions
GROUP BY formal_ext_party_id
HAVING COUNT(*) >= 10
), latest_calibration AS (
SELECT c.session_id, MAX(c.id) AS id
FROM aihr_practice_calibration c
JOIN formal_sessions s ON s.session_id = c.session_id
JOIN pilot_params p ON p.tenant_id = c.tenant_id
WHERE c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
AND c.create_time >= p.start_time AND c.create_time < p.end_time
GROUP BY c.session_id
), calibration_rows AS (
SELECT c.original_score, c.corrected_score
FROM aihr_practice_calibration c
JOIN latest_calibration l ON l.id = c.id
)
""";
private static final String PILOT_METRICS_SQL = PILOT_SCOPE_CTE + """
SELECT
(SELECT COUNT(*) FROM aihr_practice_scenario s JOIN pilot_params p ON p.tenant_id = s.tenant_id
WHERE s.position = '生活顾问' AND s.enabled = 1) AS scenario_count,
(SELECT COUNT(DISTINCT ext_party_id) FROM safe_org) AS org_login_count,
(SELECT COUNT(*) FROM pilot_org) AS pilot_people,
(SELECT COUNT(DISTINCT o.project_code) FROM pilot_org po JOIN safe_org o ON o.ext_party_id = po.ext_party_id
WHERE o.project_code IS NOT NULL AND o.project_code <> '') AS pilot_projects,
(SELECT COUNT(*) FROM formal_sessions) AS training_count,
(SELECT COUNT(*) FROM completed_org) AS completed_people,
(SELECT COUNT(*) FROM formal_sessions WHERE status = '已复盘') AS reviewed_count,
(SELECT COUNT(*) FROM formal_sessions WHERE total_score < 80 OR status = '待复盘') AS alert_count,
COALESCE((SELECT AVG(total_score) FROM formal_sessions), 0) AS average_score,
(SELECT COUNT(*) FROM calibration_rows) AS calibration_count,
COALESCE((SELECT SUM(CASE
WHEN (CASE WHEN original_score < 60 THEN 0 WHEN original_score < 75 THEN 1 WHEN original_score < 90 THEN 2 ELSE 3 END)
= (CASE WHEN corrected_score < 60 THEN 0 WHEN corrected_score < 75 THEN 1 WHEN corrected_score < 90 THEN 2 ELSE 3 END)
THEN 1 ELSE 0 END) FROM calibration_rows), 0) AS calibration_matched
""";
private static final String PILOT_SESSION_ROWS_SQL = PILOT_SCOPE_CTE + """
SELECT s.formal_ext_party_id,
(SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';')
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
s.finished_time, s.summary, s.review_advice, s.incentive_point
FROM formal_sessions s
ORDER BY s.finished_time DESC, s.id DESC
""";
private final Map<String, ScenarioSeed> scenarios = buildScenarios();
private final ConcurrentMap<String, ActiveSession> activeSessions = new ConcurrentHashMap<>();
@@ -77,6 +194,7 @@ public class AihrPracticeSeedService {
private final JdbcTemplate jdbcTemplate;
private final AihrPracticeLlmService practiceLlmService;
private final AihrSopSeedService sopSeedService;
private final TransactionTemplate pilotExportTransaction;
private volatile boolean practiceTableReady;
private volatile boolean assignmentTableReady;
private volatile boolean scenarioTableReady;
@@ -85,11 +203,24 @@ public class AihrPracticeSeedService {
private volatile boolean annotationTableReady;
private volatile boolean audioTableReady;
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, AihrSopSeedService sopSeedService) {
@Autowired
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService, PlatformTransactionManager transactionManager) {
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, new TransactionTemplate(transactionManager));
}
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService) {
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, (TransactionTemplate) null);
}
AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService, TransactionTemplate pilotExportTransaction) {
this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate;
this.practiceLlmService = practiceLlmService;
this.sopSeedService = sopSeedService;
this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction);
}
public List<ScenarioResponse> scenarios(String position, String scenarioType) {
@@ -515,6 +646,131 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit);
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of());
}
return new PracticeTeamResponse(
practiceTeamMembers(scope),
practiceTeamRecords(scope, safeLimit),
practiceTeamAssignments(scope, safeLimit)
);
}
private List<TeamMemberResponse> practiceTeamMembers(TeamScope scope) {
if (!scope.scoped() || !orgSnapshotTableExists()) {
return practiceActivityMembers(scope);
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
String memberId = hasPhone
? "COALESCE(NULLIF(person_phone, ''), ext_party_id)"
: "ext_party_id";
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'");
if (scope.scoped()) {
where.append(" AND (ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
if (hasPhone) {
where.append(" OR person_phone IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
where.append(")");
}
String sql = """
SELECT %s AS ext_party_id,
COALESCE(NULLIF(person_name, ''), %s) AS name
FROM aihr_org_snapshot
%s
ORDER BY position_level, person_name, ext_party_id
""".formatted(memberId, memberId, where);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
), args.toArray());
}
private List<TeamMemberResponse> practiceActivityMembers(TeamScope scope) {
ensurePracticeTable();
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
String sessionScope = "";
String assignmentScope = "";
if (scope.scoped()) {
sessionScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds());
}
args.add(TENANT_ID);
if (scope.scoped()) {
assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds());
}
String sql = """
SELECT ext_party_id, MAX(name) AS name
FROM (
SELECT ext_party_id, trainee_name AS name
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'%s
UNION ALL
SELECT ext_party_id, trainee_name AS name
FROM aihr_practice_assignment
WHERE tenant_id = ?%s
) real_team_members
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
GROUP BY ext_party_id
ORDER BY name, ext_party_id
""".formatted(sessionScope, assignmentScope);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
), args.toArray());
}
private List<RecordResponse> practiceTeamRecords(TeamScope scope, int limit) {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
String sql = """
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
%s
ORDER BY finished_time DESC, id DESC
LIMIT ?
""".formatted(where);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
private List<PracticeAssignmentResponse> practiceTeamAssignments(TeamScope scope, int limit) {
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND source <> 'daily'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
String sql = """
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, feedback, correct
FROM aihr_practice_assignment
%s
ORDER BY create_time DESC, id DESC
LIMIT ?
""".formatted(where);
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
}
public List<RecordResponse> pendingReviewRecords(int limit) {
return pendingReviewRecords("", "", "", limit);
}
@@ -567,62 +823,108 @@ public class AihrPracticeSeedService {
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
public String exportPilotCsv() {
public String exportPilotCsv(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new ServiceException("请选择完整的试点开始和结束日期");
}
if (startDate.isAfter(endDate)) {
throw new ServiceException("试点开始日期不能晚于结束日期");
}
ensurePracticeTable();
ensureAssignmentTable();
ensureCalibrationTable();
int trainingCount = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile'", TENANT_ID);
int completedPeople = countCompletedPilotPeople();
int pilotPeople = countPilotPeople();
boolean completionReady = pilotPeople > 0 && completedPeople * 100 >= pilotPeople * 80;
int reviewed = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND status = '已复盘'", TENANT_ID);
CalibrationStats calibrationStats = calibrationStats();
AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats();
int alertCount = count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND (total_score < 80 OR status = '待复盘')
""", TENANT_ID);
Double averageScore = jdbcTemplate.queryForObject("""
SELECT COALESCE(AVG(total_score), 0)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
""", Double.class, TENANT_ID);
ensureScenarioTable();
sopSeedService.prepareSopReviewStats();
if (pilotExportTransaction == null) {
return exportPilotCsvSnapshot(startDate, endDate);
}
String csv = pilotExportTransaction.execute(status -> exportPilotCsvSnapshot(startDate, endDate));
if (csv == null) {
throw new ServiceException("正式试点指标查询无结果");
}
return csv;
}
private String exportPilotCsvSnapshot(LocalDate startDate, LocalDate endDate) {
Timestamp startTime = Timestamp.valueOf(startDate.atStartOfDay());
Timestamp endTime = Timestamp.valueOf(endDate.plusDays(1).atStartOfDay());
PilotReportMetrics metrics;
List<Map<String, Object>> sessionRows;
try {
metrics = jdbcTemplate.queryForObject(PILOT_METRICS_SQL, (rs, rowNum) -> new PilotReportMetrics(
rs.getInt("scenario_count"),
rs.getInt("org_login_count"),
rs.getInt("pilot_people"),
rs.getInt("pilot_projects"),
rs.getInt("training_count"),
rs.getInt("completed_people"),
rs.getInt("reviewed_count"),
rs.getInt("alert_count"),
rs.getDouble("average_score"),
rs.getInt("calibration_count"),
rs.getInt("calibration_matched")
), TENANT_ID, startTime, endTime);
sessionRows = jdbcTemplate.queryForList(PILOT_SESSION_ROWS_SQL, TENANT_ID, startTime, endTime);
} catch (DataAccessException e) {
throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化");
}
if (metrics == null) {
throw new ServiceException("正式试点指标查询无结果");
}
AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats(
startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
int pilotPeople = metrics.pilotPeople();
int completedPeople = metrics.completedPeople();
boolean scopeReady = metrics.orgLoginCount() >= 20
&& pilotPeople >= 20
&& metrics.pilotProjects() >= 1
&& metrics.pilotProjects() <= 2;
boolean completionReady = scopeReady && completedPeople * 100 >= pilotPeople * 80;
boolean calibrationReady = metrics.calibrationCount() >= 20
&& metrics.calibrationMatched() * 100 >= metrics.calibrationCount() * 70;
boolean sopReady = sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80;
StringBuilder csv = new StringBuilder();
appendCsvRow(csv, "metric", "value");
appendCsvRow(csv, "training_count", trainingCount);
appendCsvRow(csv, "pilot_start_date", startDate);
appendCsvRow(csv, "pilot_end_date", endDate);
appendCsvRow(csv, "data_scope", "active_org_unique_identity");
appendCsvRow(csv, "scenario_count", metrics.scenarioCount());
appendCsvRow(csv, "scenario_count_target", 12);
appendCsvRow(csv, "formal_org_login_count", metrics.orgLoginCount());
appendCsvRow(csv, "formal_pilot_people_count", pilotPeople);
appendCsvRow(csv, "formal_pilot_people_target", 20);
appendCsvRow(csv, "formal_pilot_project_count", metrics.pilotProjects());
appendCsvRow(csv, "formal_pilot_project_target", "1-2");
appendCsvRow(csv, "formal_pilot_scope_ready", scopeReady);
appendCsvRow(csv, "training_count", metrics.trainingCount());
appendCsvRow(csv, "completion_definition", "at_least_10_sessions_per_person");
appendCsvRow(csv, "completion_people_count", completedPeople);
appendCsvRow(csv, "completion_people_total", pilotPeople);
appendCsvRow(csv, "completion_rate", percent(completedPeople, pilotPeople));
appendCsvRow(csv, "completion_rate_target", "80.0%");
appendCsvRow(csv, "completion_rate_ready", completionReady);
appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", averageScore == null ? 0 : averageScore));
appendCsvRow(csv, "alert_count", alertCount);
appendCsvRow(csv, "review_rate", percent(reviewed, trainingCount));
appendCsvRow(csv, "calibration_sample_count", calibrationStats.total());
appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", metrics.averageScore()));
appendCsvRow(csv, "alert_count", metrics.alertCount());
appendCsvRow(csv, "review_rate", percent(metrics.reviewedCount(), metrics.trainingCount()));
appendCsvRow(csv, "calibration_sample_count", metrics.calibrationCount());
appendCsvRow(csv, "ai_human_band_matched_count", metrics.calibrationMatched());
appendCsvRow(csv, "calibration_sample_target", 20);
appendCsvRow(csv, "calibration_sample_ready", calibrationStats.total() >= 20);
appendCsvRow(csv, "ai_human_band_consistency_rate", percent(calibrationStats.matched(), calibrationStats.total()));
appendCsvRow(csv, "calibration_sample_ready", metrics.calibrationCount() >= 20);
appendCsvRow(csv, "ai_human_band_consistency_rate", percent(metrics.calibrationMatched(), metrics.calibrationCount()));
appendCsvRow(csv, "ai_human_band_consistency_target", "70.0%");
appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationStats.total() >= 20 && calibrationStats.matched() * 100 >= calibrationStats.total() * 70);
appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationReady);
appendCsvRow(csv, "sop_review_sample_count", sopStats.total());
appendCsvRow(csv, "sop_answer_usable_count", sopStats.usable());
appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total()));
appendCsvRow(csv, "sop_answer_usable_target", "80.0%");
appendCsvRow(csv, "sop_answer_usable_ready", sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80);
appendCsvRow(csv, "pilot_metric_ready", completionReady
&& calibrationStats.total() >= 20
&& calibrationStats.matched() * 100 >= calibrationStats.total() * 70
&& sopStats.total() > 0
&& sopStats.usable() * 100 >= sopStats.total() * 80);
appendCsvRow(csv, "sop_answer_usable_ready", sopReady);
appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady);
csv.append('\n');
appendCsvRow(csv, "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point");
jdbcTemplate.queryForList("""
SELECT session_id, trainee_name, scenario_name, total_score, status, finished_time, summary, review_advice, incentive_point
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
""", TENANT_ID).forEach(row -> appendCsvRow(
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point");
sessionRows.forEach(row -> appendCsvRow(
csv,
row.get("formal_ext_party_id"),
row.get("project_codes"),
row.get("session_id"),
row.get("trainee_name"),
row.get("scenario_name"),
@@ -636,6 +938,15 @@ public class AihrPracticeSeedService {
return csv.toString();
}
private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null) {
return null;
}
transactionTemplate.setReadOnly(true);
transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
return transactionTemplate;
}
private int countCompletedPilotPeople() {
ensurePracticeTable();
return count("""
@@ -686,22 +997,27 @@ public class AihrPracticeSeedService {
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail;
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id) {
return markReviewed(id, null);
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id, ReviewAdviceRequest request) {
ensurePracticeTable();
if (id == null) {
return false;
}
if (request != null && !isBlank(request.nextScenarioId())) {
ensureAssignmentTable();
}
int updated = jdbcTemplate.update("""
UPDATE aihr_practice_session
SET status = '已复盘',
review_advice = CASE WHEN ? = '' THEN review_advice ELSE ? END,
incentive_point = CASE WHEN ? IS NULL THEN incentive_point ELSE ? END,
update_time = ?
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
WHERE tenant_id = ? AND mode = 'mobile' AND id = ? AND status = '待复盘'
""",
adviceText(request),
adviceText(request),
@@ -711,7 +1027,16 @@ public class AihrPracticeSeedService {
TENANT_ID,
id
);
if (updated > 0 && request != null && !isBlank(request.nextScenarioId())) {
if (updated == 0) {
List<String> statuses = jdbcTemplate.query("""
SELECT status
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
FOR UPDATE
""", (rs, rowNum) -> rs.getString("status"), TENANT_ID, id);
return !statuses.isEmpty() && "已复盘".equals(statuses.get(0));
}
if (request != null && !isBlank(request.nextScenarioId())) {
ReviewDetailResponse detail = reviewDetail(id);
String extPartyId = reviewExtPartyId(id);
createAssignment(new PracticeAssignmentRequest(
@@ -723,9 +1048,10 @@ public class AihrPracticeSeedService {
firstNonBlank(request.nextReason(), "主管复盘后派发下一次训练")
));
}
return updated > 0;
return true;
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) {
if (reviewDetail(id, supervisorExtPartyId) == null) {
return false;
@@ -1457,11 +1783,41 @@ public class AihrPracticeSeedService {
return !scope.scoped() || scope.extPartyIds().contains(extPartyId.trim());
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
String identity = isBlank(authenticatedIdentity) ? "" : authenticatedIdentity.trim();
if (identity.isEmpty() || !orgSnapshotTableExists()) {
throw new ServiceException("无主管权限,无法访问主管功能");
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.add(identity);
String identityWhere = "ext_party_id = ?";
if (hasPhone) {
identityWhere = "(ext_party_id = ? OR person_phone = ?)";
args.add(identity);
}
List<String> positionLevels = jdbcTemplate.query("""
SELECT position_level
FROM aihr_org_snapshot
WHERE tenant_id = ? AND """ + identityWhere + """
AND employment_status = 'active'
LIMIT 1
""", (rs, rowNum) -> rs.getString("position_level"), args.toArray());
if (positionLevels.isEmpty() || !canSeeProject(positionLevels.get(0))) {
throw new ServiceException("无主管权限,无法访问主管功能");
}
return identity;
}
private TeamScope teamScope(String supervisorExtPartyId) {
String owner = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim();
if (owner.isEmpty() || !orgSnapshotTableExists()) {
if (owner.isEmpty()) {
return TeamScope.unscoped();
}
if (!orgSnapshotTableExists()) {
return TeamScope.scoped(List.of(owner));
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
List<Object> ownerArgs = new ArrayList<>();
ownerArgs.add(TENANT_ID);
@@ -2349,6 +2705,13 @@ public class AihrPracticeSeedService {
return Math.min(limit, 20);
}
private int normalizeTeamLimit(int limit) {
if (limit <= 0) {
return 200;
}
return Math.min(limit, 200);
}
private void ensurePracticeTable() {
if (practiceTableReady) {
return;
@@ -2880,6 +3243,21 @@ public class AihrPracticeSeedService {
private record CalibrationStats(int total, int matched) {
}
private record PilotReportMetrics(
int scenarioCount,
int orgLoginCount,
int pilotPeople,
int pilotProjects,
int trainingCount,
int completedPeople,
int reviewedCount,
int alertCount,
double averageScore,
int calibrationCount,
int calibrationMatched
) {
}
private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) {
}
@@ -66,6 +66,7 @@ import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
@@ -1865,6 +1866,21 @@ public class AihrSopSeedService {
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID);
}
public void prepareSopReviewStats() {
ensureSopReviewTable();
}
public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND reviewed_time >= ? AND reviewed_time < ?
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")),
TENANT_ID, Timestamp.valueOf(startTime), Timestamp.valueOf(endTime));
}
private void ensureSopReviewTable() {
if (sopReviewTableReady) {
return;
@@ -1,12 +1,23 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
@Tag("dev")
public class AihrOrgSyncServiceTest {
@@ -26,4 +37,61 @@ public class AihrOrgSyncServiceTest {
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"phone\":\"13900001111\"}")));
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"name\":\"张三\"}")));
}
@Test
public void dryRunNeverTouchesSnapshotSchemaWhenExternalFetchFails() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl", "http://127.0.0.1:1/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
assertThrows(IllegalStateException.class, () -> service.sync(
new SyncRequest(true, true, 1, 1, null, null, null)
));
verifyNoInteractions(jdbcTemplate, transactionTemplate);
}
@Test
public void successfulDryRunNeverTouchesSnapshotStorage() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/open/v1/sync/snapshot", exchange -> {
boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee");
String items = employee
? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]"
: "[]";
byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0)
+ ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl",
"http://127.0.0.1:" + server.getAddress().getPort() + "/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null));
assertTrue(response.dryRun());
assertEquals(1, response.employeeCount());
assertEquals(0, response.syncedCount());
assertEquals(1, response.phoneLinked());
verifyNoInteractions(jdbcTemplate, transactionTemplate);
} finally {
server.stop(0);
}
}
}
@@ -3,6 +3,7 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -10,14 +11,28 @@ import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -29,6 +44,48 @@ import static org.mockito.Mockito.when;
@Tag("dev")
public class AihrPracticeSeedServiceTest {
@Test
public void pilotExportRequiresExplicitOrderedWindow() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
LocalDate today = LocalDate.of(2026, 7, 10);
assertThrows(ServiceException.class, () -> service.exportPilotCsv(null, today));
assertThrows(ServiceException.class, () -> service.exportPilotCsv(today, null));
ServiceException reversed = assertThrows(ServiceException.class,
() -> service.exportPilotCsv(today, today.minusDays(1)));
assertEquals("试点开始日期不能晚于结束日期", reversed.getMessage());
}
@Test
@SuppressWarnings("unchecked")
public void pilotExportUsesReadOnlyRepeatableReadSnapshotAndFormalRows() throws Exception {
RecordingTransactionManager transactionManager = new RecordingTransactionManager();
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
PilotExportJdbcTemplate jdbcTemplate = new PilotExportJdbcTemplate(transactionManager);
AihrSopSeedService sopSeedService = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null);
Constructor<AihrPracticeSeedService> constructor = (Constructor<AihrPracticeSeedService>) AihrPracticeSeedService.class
.getDeclaredConstructor(ObjectMapper.class, JdbcTemplate.class, AihrPracticeLlmService.class,
AihrSopSeedService.class, TransactionTemplate.class);
AihrPracticeSeedService service = constructor.newInstance(
new ObjectMapper(), jdbcTemplate, null, sopSeedService, transactionTemplate);
String csv = service.exportPilotCsv(LocalDate.of(2026, 7, 7), LocalDate.of(2026, 7, 10));
assertNotNull(transactionManager.definition);
assertTrue(transactionManager.definition.isReadOnly());
assertEquals(TransactionDefinition.ISOLATION_REPEATABLE_READ, transactionManager.definition.getIsolationLevel());
assertEquals(1, transactionManager.commits);
assertFalse(transactionManager.active);
assertTrue(jdbcTemplate.scenarioPrepared);
assertTrue(jdbcTemplate.sopReviewPrepared);
assertTrue(jdbcTemplate.sopQueryInTransaction);
assertTrue(jdbcTemplate.metricsSql.contains("HAVING COUNT(*) >= 10"));
assertTrue(jdbcTemplate.metricsSql.contains("JOIN formal_sessions s ON s.session_id = c.session_id"));
assertTrue(jdbcTemplate.sessionRowsSql.contains("JOIN org_identity o ON o.identity_key = s.ext_party_id"));
assertTrue(csv.contains("\"training_count\",\"1\""));
assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\""));
}
@Test
public void createAssignmentRequiresExtPartyId() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
@@ -134,6 +191,211 @@ public class AihrPracticeSeedServiceTest {
assertTrue(scopedSql.contains(condition));
}
@Test
public void supervisorIdentityAcceptsActiveSupervisorAndProjectManager() {
SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of(
"SUP-EXT", "主管",
"13900001111", "项目经理"
));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertEquals("SUP-EXT", service.requireSupervisorIdentity(" SUP-EXT "));
assertEquals("13900001111", service.requireSupervisorIdentity("13900001111"));
assertTrue(jdbcTemplate.authorizationSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.authorizationSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.authorizationSql.contains("ext_party_id = ? OR person_phone = ?"));
assertEquals(List.of("000000", "13900001111", "13900001111"), jdbcTemplate.authorizationArgs);
}
@Test
public void supervisorIdentityRejectsFrontlineUnknownAndBlankIdentity() {
SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of(
"EMP-1", "一线"
));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ServiceException frontline = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity("EMP-1"));
ServiceException unknown = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity("UNKNOWN"));
ServiceException blank = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity(" "));
assertTrue(frontline.getMessage().contains("无主管权限"));
assertTrue(unknown.getMessage().contains("无主管权限"));
assertTrue(blank.getMessage().contains("无主管权限"));
assertEquals(2, jdbcTemplate.authorizationQueryCount);
}
@Test
public void markReviewedOnlyTransitionsPendingReview() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(1, null);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertTrue(service.markReviewed(42L));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("id = ?"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("status = '待复盘'"));
assertEquals(1, jdbcTemplate.sessionUpdateCount);
}
@Test
public void markReviewedTreatsAlreadyReviewedAsSuccessWithoutAssignment() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "已复盘");
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ReviewAdviceRequest request = new ReviewAdviceRequest(
"主管",
"重复提交不应覆盖",
10,
"complaint-water",
"重复提交不应派发"
);
assertTrue(service.markReviewed(42L, request));
assertTrue(jdbcTemplate.lockedStatusSql.contains("SELECT status"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("id = ?"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("FOR UPDATE"));
assertEquals(List.of("000000", 42L), jdbcTemplate.lockedStatusArgs);
assertEquals(1, jdbcTemplate.sessionUpdateCount);
assertEquals(0, jdbcTemplate.assignmentInsertCount);
}
@Test
public void markReviewedReturnsFalseWhenRecordIsNeitherPendingNorReviewed() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "训练完成");
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertFalse(service.markReviewed(404L));
assertEquals(0, jdbcTemplate.assignmentInsertCount);
}
@Test
public void markReviewedExternalOverloadsAreTransactional() throws NoSuchMethodException {
assertTransactional(AihrPracticeSeedService.class.getMethod(
"markReviewed", Long.class, ReviewAdviceRequest.class));
assertTransactional(AihrPracticeSeedService.class.getMethod(
"markReviewed", Long.class, ReviewAdviceRequest.class, String.class));
}
@Test
public void practiceTeamSnapshotUsesActiveScopedMembersAndAllStatusRows() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members");
List<?> records = snapshotItems(snapshot, "records");
List<?> assignments = snapshotItems(snapshot, "assignments");
assertEquals(1, members.size());
assertEquals("稳定员工", recordValue(members.get(0), "name"));
assertEquals("已复盘", recordValue(records.get(0), "status"));
assertEquals("已完成", recordValue(assignments.get(0), "status"));
assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)"));
assertFalse(jdbcTemplate.recordsSql.contains("status = '待复盘'"));
assertTrue(jdbcTemplate.recordsSql.contains("ORDER BY finished_time DESC"));
assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.recordsArgs);
assertTrue(jdbcTemplate.assignmentsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.assignmentsSql.contains("source <> 'daily'"));
assertTrue(jdbcTemplate.assignmentsSql.indexOf("source <> 'daily'")
< jdbcTemplate.assignmentsSql.indexOf("LIMIT ?"));
assertFalse(jdbcTemplate.assignmentsSql.contains("status = '待训练'"));
assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.assignmentsArgs);
}
@Test
public void markReviewedInitializesAssignmentTableBeforeSessionUpdate() {
ReviewAssignmentOrderJdbcTemplate jdbcTemplate = new ReviewAssignmentOrderJdbcTemplate();
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ReviewAdviceRequest request = new ReviewAdviceRequest(
"主管",
"需要补练",
null,
"complaint-water",
"复盘后专项"
);
assertTrue(service.markReviewed(42L, request));
int sessionUpdate = jdbcTemplate.events.indexOf("session-update");
int assignmentInsert = jdbcTemplate.events.indexOf("assignment-insert");
int lastAssignmentDdl = jdbcTemplate.events.lastIndexOf("assignment-ddl");
assertTrue(lastAssignmentDdl >= 0);
assertTrue(lastAssignmentDdl < sessionUpdate, jdbcTemplate.events.toString());
assertTrue(sessionUpdate < assignmentInsert, jdbcTemplate.events.toString());
}
@Test
public void supervisorControllerEndpointsRequireValidatedSupervisorScope() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java");
}
String controllerSource = Files.readString(source);
assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),"));
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, request, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("private String supervisorScopeExtPartyId()"));
assertTrue(controllerSource.contains("mobileSeedService.requireSupervisorIdentity(currentAppUsername())"));
assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()"));
assertFalse(controllerSource.contains("supervisorScopeExtPartyId() {\n return \"\";"));
assertFalse(controllerSource.contains("supervisor H5 keeps team-scope"));
}
@Test
public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "", 200);
List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment"));
assertFalse(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot"));
assertEquals(List.of("000000", "000000"), jdbcTemplate.membersArgs);
}
@Test
public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session"));
assertTrue(jdbcTemplate.membersSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", "000000", "SUP-PHONE"), jdbcTemplate.membersArgs);
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.recordsArgs);
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.assignmentsArgs);
}
@Test
public void competencyDoesNotSeedScoreForEmptyEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
@@ -208,6 +470,7 @@ public class AihrPracticeSeedServiceTest {
try {
int intValue = Integer.parseInt(entry.getValue());
when(resultSet.getInt(entry.getKey())).thenReturn(intValue);
when(resultSet.getLong(entry.getKey())).thenReturn((long) intValue);
} catch (NumberFormatException ignored) {
// String-only test column.
}
@@ -217,6 +480,127 @@ public class AihrPracticeSeedServiceTest {
return mapped;
}
private static void assertTransactional(Method method) {
Transactional annotation = method.getAnnotation(Transactional.class);
assertTrue(annotation != null, method + " must be transactional");
assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class));
}
private static Object invokePracticeTeamSnapshot(AihrPracticeSeedService service, String supervisorExtPartyId, int limit)
throws Exception {
Method method = AihrPracticeSeedService.class.getMethod("practiceTeamSnapshot", String.class, int.class);
return method.invoke(service, supervisorExtPartyId, limit);
}
@SuppressWarnings("unchecked")
private static List<?> snapshotItems(Object snapshot, String accessor) throws Exception {
return (List<?>) snapshot.getClass().getMethod(accessor).invoke(snapshot);
}
private static Object recordValue(Object record, String accessor) throws Exception {
return record.getClass().getMethod(accessor).invoke(record);
}
private static final class RecordingTransactionManager implements PlatformTransactionManager {
private TransactionDefinition definition;
private int starts;
private int commits;
private boolean active;
@Override
public TransactionStatus getTransaction(TransactionDefinition definition) {
this.definition = definition;
starts += 1;
active = true;
return new SimpleTransactionStatus();
}
@Override
public void commit(TransactionStatus status) {
assertTrue(active);
commits += 1;
active = false;
}
@Override
public void rollback(TransactionStatus status) {
active = false;
throw new AssertionError("pilot export should not roll back");
}
}
private static final class PilotExportJdbcTemplate extends JdbcTemplate {
private final RecordingTransactionManager transactionManager;
private String metricsSql = "";
private String sessionRowsSql = "";
private boolean scenarioPrepared;
private boolean sopReviewPrepared;
private boolean sopQueryInTransaction;
private PilotExportJdbcTemplate(RecordingTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public void execute(String sql) {
assertFalse(transactionManager.active);
if (sql.contains("aihr_practice_scenario")) {
scenarioPrepared = true;
}
if (sql.contains("aihr_sop_answer_review")) {
sopReviewPrepared = true;
}
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (requiredType == Integer.class) {
return requiredType.cast(1);
}
return null;
}
@Override
public <T> T queryForObject(String sql, RowMapper<T> rowMapper, Object... args) {
assertTrue(transactionManager.active);
assertTrue(scenarioPrepared);
if (sql.contains("aihr_sop_answer_review")) {
assertTrue(sopReviewPrepared);
sopQueryInTransaction = true;
} else {
metricsSql = sql;
}
try {
ResultSet resultSet = mock(ResultSet.class);
when(resultSet.getInt(ArgumentMatchers.anyString())).thenReturn(1);
when(resultSet.getDouble("average_score")).thenReturn(86.0d);
return rowMapper.mapRow(resultSet, 0);
} catch (SQLException e) {
throw new AssertionError(e);
}
}
@Override
public List<Map<String, Object>> queryForList(String sql, Object... args) {
assertTrue(transactionManager.active);
assertTrue(scenarioPrepared);
sessionRowsSql = sql;
return List.of(Map.ofEntries(
Map.entry("formal_ext_party_id", "FORMAL-1"),
Map.entry("project_codes", "P1"),
Map.entry("session_id", "session-1"),
Map.entry("trainee_name", "员工甲"),
Map.entry("scenario_name", "投诉处理"),
Map.entry("total_score", 86),
Map.entry("status", "已复盘"),
Map.entry("finished_time", Timestamp.valueOf("2026-07-08 10:00:00")),
Map.entry("summary", "正式试点记录"),
Map.entry("review_advice", "保持回访"),
Map.entry("incentive_point", 3)
));
}
}
private static final class AssignmentJdbcTemplate extends JdbcTemplate {
private final String ownerLevel;
private final List<String> scopeIds;
@@ -326,4 +710,265 @@ public class AihrPracticeSeedServiceTest {
}
}
private static final class MarkReviewedJdbcTemplate extends JdbcTemplate {
private final int transitionResult;
private final String lockedStatus;
private String sessionUpdateSql = "";
private int sessionUpdateCount;
private String lockedStatusSql = "";
private List<Object> lockedStatusArgs = List.of();
private int assignmentInsertCount;
private MarkReviewedJdbcTemplate(int transitionResult, String lockedStatus) {
this.transitionResult = transitionResult;
this.lockedStatus = lockedStatus;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (!sql.contains("FOR UPDATE")) {
return List.of();
}
lockedStatusSql = sql;
lockedStatusArgs = List.of(args);
if (lockedStatus == null) {
return List.of();
}
try {
return mapRows(rowMapper, List.of(Map.of("status", lockedStatus)));
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("UPDATE aihr_practice_session")) {
sessionUpdateSql = sql;
sessionUpdateCount += 1;
return transitionResult;
}
if (sql.contains("INSERT INTO aihr_practice_assignment")) {
assignmentInsertCount += 1;
return 1;
}
return 0;
}
@Override
public void execute(String sql) {
// Schema setup is irrelevant for transition tests.
}
}
private static final class SupervisorAuthorizationJdbcTemplate extends JdbcTemplate {
private final Map<String, String> positionLevels;
private String authorizationSql = "";
private List<Object> authorizationArgs = List.of();
private int authorizationQueryCount;
private SupervisorAuthorizationJdbcTemplate(Map<String, String> positionLevels) {
this.positionLevels = positionLevels;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
authorizationSql = sql;
authorizationArgs = List.of(args);
authorizationQueryCount += 1;
String positionLevel = positionLevels.get(String.valueOf(args[1]));
if (positionLevel == null) {
return List.of();
}
try {
return mapRows(rowMapper, List.of(Map.of("position_level", positionLevel)));
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
private static final class TeamSnapshotJdbcTemplate extends JdbcTemplate {
private final boolean orgSnapshotAvailable;
private String membersSql = "";
private List<Object> membersArgs = List.of();
private String recordsSql = "";
private List<Object> recordsArgs = List.of();
private String assignmentsSql = "";
private List<Object> assignmentsArgs = List.of();
private TeamSnapshotJdbcTemplate(boolean orgSnapshotAvailable) {
this.orgSnapshotAvailable = orgSnapshotAvailable;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
if (sql.contains("information_schema.TABLES") && sql.contains("aihr_org_snapshot")) {
return requiredType.cast(orgSnapshotAvailable ? 1 : 0);
}
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("information_schema.COLUMNS") && sql.contains("aihr_org_snapshot")) {
return requiredType.cast(orgSnapshotAvailable ? 1 : 0);
}
return requiredType.cast(1);
}
@Override
public void execute(String sql) {
// Schema setup is irrelevant for snapshot query tests.
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("SELECT project_code, position_level")) {
return mapRows(rowMapper, List.of(Map.of(
"project_code", "P1",
"position_level", "主管"
)));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
return mapRows(rowMapper, List.of(
Map.of("ext_party_id", "EMP-SUP"),
Map.of("ext_party_id", "EMP-1")
));
}
if (sql.contains("SELECT person_phone") && sql.contains("ORDER BY position_level")) {
return mapRows(rowMapper, List.of(
Map.of("person_phone", "SUP-PHONE"),
Map.of("person_phone", "EMP-PHONE-1")
));
}
if (sql.contains("FROM aihr_org_snapshot") && sql.contains("AS name")) {
membersSql = sql;
membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", "EMP-PHONE-1",
"name", "稳定员工"
)));
}
if (sql.contains("UNION ALL")
&& sql.contains("FROM aihr_practice_session")
&& sql.contains("FROM aihr_practice_assignment")) {
membersSql = sql;
membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", sql.contains("ext_party_id IN") ? "SUP-PHONE" : "REAL-1",
"name", "真实训练员工"
)));
}
if (sql.contains("SELECT id, session_id") && sql.contains("FROM aihr_practice_session")) {
recordsSql = sql;
recordsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")
? "SUP-PHONE"
: orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1";
return mapRows(rowMapper, List.of(Map.of(
"id", "1",
"session_id", "session-1",
"trainee_name", "稳定员工",
"scenario_name", "新高分场景",
"total_score", "93",
"status", "已复盘",
"summary", "已完成复盘",
"ext_party_id", extPartyId
)));
}
if (sql.contains("SELECT id, ext_party_id") && sql.contains("FROM aihr_practice_assignment")) {
assignmentsSql = sql;
assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")
? "SUP-PHONE"
: orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1";
return mapRows(rowMapper, List.of(Map.of(
"id", "2",
"ext_party_id", extPartyId,
"trainee_name", orgSnapshotAvailable ? "稳定员工" : "真实训练员工",
"scenario_id", "complaint-water",
"scenario_name", "投诉接待",
"source", "manual",
"reason", "历史专项",
"status", "已完成"
)));
}
return List.of();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
private static final class ReviewAssignmentOrderJdbcTemplate extends JdbcTemplate {
private final List<String> events = new ArrayList<>();
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("TABLE_NAME = 'aihr_practice_assignment'")) {
return requiredType.cast(0);
}
return requiredType.cast(1);
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("UPDATE aihr_practice_session")) {
events.add("session-update");
return 1;
}
if (sql.contains("INSERT INTO aihr_practice_assignment")) {
events.add("assignment-insert");
return 1;
}
return 0;
}
@Override
public void execute(String sql) {
if (sql.contains("aihr_practice_assignment")) {
events.add("assignment-ddl");
}
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("SELECT ext_party_id") && sql.contains("FROM aihr_practice_session")) {
return mapRows(rowMapper, List.of(Map.of("ext_party_id", "EMP-1")));
}
return List.of();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
}
@@ -28,7 +28,15 @@ mobile-uni/
user/practice/
user/profile/
supervisor/index/
supervisor/team/
supervisor/assign/
supervisor/reviews/
supervisor/review/
candidate/index/
candidate/interview/
candidate/materials/
candidate/progress/
candidate/study/
services/
api.ts
auth.ts
@@ -109,7 +117,11 @@ mobile-uni/
- 打勾/打叉提交后页面锁定反馈状态,失败时保留原状态。
- 总结卡显式点击才生成,失败可重试。
## 6. 当前进展
## 6. 当前基线与验证记录
截至 2026-07-10,`mobile-uni/` 已是用户、主管、候选人三端的现役页面栈,旧 `mobile/` 仅作 MVP 兜底。三端均以登录身份和真实 API 驱动:员工完成训练、问师傅和成长回读;主管按后端组织范围进行团队、复盘和专项指派;候选人按真实面试/资料状态进入独立线性页面。H5 构建、真实浏览器主链路和接口回读均已完成验证,不使用前端 mock 掩盖业务状态。
正式试点尚未通过:组织同步 dry-run 的 3474 名上游员工中仅 1 名具备可用于移动端身份映射的手机号,另有 8 条疑似乱码;严格门禁在窗口 `2026-07-07..2026-07-10` 只识别到 1 名正式人员且无人完成 10 次训练。下一阶段优先补齐上游正式组织数据并执行联合试点验收,不继续把功能堆回首页或旧单页。
- 第一批用户侧主链路已落地到 `mobile-uni/`: 登录、今日页、问师傅、答案反馈、总结卡、对练页、个人页。
- 本机开发入口已切到 portless: `https://wygj-mobile-uni.localhost/h5/`。
@@ -139,7 +151,7 @@ mobile-uni/
- 主管端已补团队画像: 复用真实待复盘记录和训练指派数据聚合个人风险、待复盘、待训练和最近得分,可一键带入专项指派。
- 主管端从团队画像派专项时已保留员工姓名和外部 ID: 指派记录不再把已选员工统一写成“手机用户xxxx”。
- 主管端复盘已支持顺手派下一次训练: 复用 `ReviewAdviceRequest.nextScenarioId/nextReason`,标记已复盘时可同步生成专项训练。
- 主管端团队预警的聚合动作已改为“去画像”: 预警本身不含员工标识,点击后滚动定位团队画像,具体派发仍从画像员工行进入,避免一键误派给默认手机号。
- 主管端团队预警的聚合动作已改为进入独立团队画像页: 预警本身不含员工标识,具体派发仍从画像员工行进入,避免一键误派给默认手机号。
- 主管端指派专项已收敛操作面板: 默认不再把主管本人手机号当指派对象,必须选择员工或手动输入;历史记录只展示最近专项,不混入每日三题流水。
- 主管端团队画像待训练口径已收敛: 只统计主管指派专项,不再把员工每日三题误算进主管待训练。
- 主管端概览低分口径已收敛: 顶部低分预警直接跟随后端 `low_score` 预警数量,不再用当前画像截断列表推导高风险人数。
@@ -207,7 +219,7 @@ mobile-uni/
- 候选人端审核中主动作已收敛: 资料待审核/已通过时主按钮只刷新进度,不再提示“重新面试”造成阶段误导;待补资料/资料驳回仍走上传。
- 主管端派专项已补场景自动匹配: 从团队画像选择员工时按投诉/报修/噪音/电梯/催缴等关键词选对应专项,不再把非催费低分默认派成催费沟通。
- 主管端最近专项回读已修正: 后端专项列表改为非 daily 优先,避免每日三题占满前 10 条导致手工指派成功后页面仍显示 0 条。
- 主管端团队画像派专项已用真实浏览器跑通: 从画像低分员工预填对象和场景后提交,最近专项立即回读新指派记录;低分预警“去画像”锚点已落到团队画像面板。
- 主管端团队画像派专项已用真实浏览器跑通: 从画像低分员工预填对象和场景后提交,最近专项立即回读新指派记录;低分预警进入独立团队画像页,不再依赖首页锚点。
- 候选人端页头称呼已收敛: 已登录副标题改为手机号尾号,不再和页面标题拼出“候选人候选人0001”。
- 候选人端未登录主任务卡已收起: 未登录只保留登录面板,不再同时出现两处“去登录”入口。
- 主管端低分预警已落到可操作对象: 待复盘列表优先返回低分记录,团队概览用全量预警数展示,避免“有低分预警但画像看不到低分员工”。
@@ -237,6 +249,24 @@ mobile-uni/
- H5 本地验证噪音已收敛:关闭 uni 统计自动上报并复用现有静态图作为 favicon,真实 390px 浏览器扫描无 `tongji-collector`/`WebTrack.gif`/`favicon.ico` 坏资源。
- 员工练习页场景说明已跟随真实预习卡/主管指派:场景卡优先展示 `/prep-card` 返回的要点,其次展示专项原因,不再固定写“投诉服务或设施问题”。
- 员工端岗位确认提示已对齐正式组织数据边界:组织快照无法按手机号匹配时,明确提示可能是手机号未开放或已脱敏,再让用户手选岗位。
- 主管端已完成真实页面栈拆分: 概览、团队画像、专项指派、复盘列表、复盘详情分别落到独立路由,首页只保留指标、预警和待办摘要,不再把功能揉成长页面或用锚点模拟导航。
- 主管端团队数据已接正式范围接口: `GET /api/aihr/mobile/practice/team` 一次返回组织成员、全状态训练记录和非 daily 专项;后端先按租户、主管项目范围和移动端模式过滤,再做列表限制。
- 主管权限已以后端组织快照为准: 只有在职“主管/项目经理”可访问团队、预警、复盘和指派接口;普通员工真实浏览器验证只显示“无主管权限”及返回员工端/切换账号,不泄露团队指标和表单草稿。
- 主管复盘写入已补事务和幂等: 仅 `待复盘 -> 已复盘` 的首次状态转换创建后续专项;并发重复请求通过行锁读取最终状态,不重复派发。
- 主管端已用真实项目经理账号验证组织范围: 本地正式快照返回 19 名团队成员、1 条待复盘、2 项待训练和 1 条低分预警;团队页、复盘页和指派页均读取真实接口,不补前端 mock。
- 正式组织快照中的不可读问号姓名在用户侧回退为手机号尾号或外部 ID 末尾标识,真实 ID 保持不变;开放组织 dry-run 已确认源接口自身仍返回 8 条疑似乱码,需由上游修复后再同步。
- 候选人端已完成线性页面栈拆分: 原 750 行长页收敛为候选首页、面试任务、补充资料、进度通知和岗前预习 5 个路由;首页只展示真实状态和办理入口,不再原地展开所有表单和结果。
- 候选人流程状态已抽成共享纯函数: 只根据当前候选人的正式面试记录和最新资料状态派生“待面试/待补资料/审核中/已通过”,不读取公开 home seed,不补前端 mock。
- 候选面试页继续复用真实 `/api/recruit/interview/start|answer|finish|records`: 浏览器以当前移动账号验证 `/start` 返回 3 道岗位题,未提交答案和评分,避免产生额外正式面试记录。
- 候选资料页继续复用真实 multipart 上传和本人资料列表: 本轮浏览器只验证正式空态与上传入口,未上传测试文件污染资料库;已有上传-审核-回读证据继续有效。
- 候选进度页按正式记录展示三步办理状态和证据,面试未完成时明确停在第 1 步,不把空数据渲染成已办理。
- 候选岗前预习页改为自然业务问题检索正式 SOP: 浏览器验证投诉预习返回正式答案、关键要点、注意事项,引用命中 `投诉处理 SOP v1.0`、`生活顾问的一天.mp4` 和正式课件,无前端示例答案。
- 演示预检已跟随页面栈更新: `scripts/demo-check.sh` 改为检查主管复盘详情和候选 4 个子路由,不再在旧主管首页查复盘表单标记;本地数据库连接不可用时非严格模式明确跳过样本、严格模式继续失败。
- 本地连接耗尽已处置: 清理未被 portless 使用的旧后端实例并重启本地 MySQL 后,连接稳定在约 `11/151`,当前 API HTTP 200;`dev.sh stop/restart` 已改为安全终止 portless 进程树,避免旧 Java/Vite 子进程继续占用连接池。
- 试点严格预检已改为正式批次口径: 必须指定起止日期,训练、校准和 SOP 评审只统计该时间窗;人员通过唯一手机号映射到在职组织,排除重复手机号和身份碰撞,完训定义为每人至少 10 次。`2026-07-07..2026-07-10` 实测开发活动身份 50 人、10 次完训 0 人,但正式组织仍为 `org_login=1/3001`、`org_pilot=1/20`、`org_ten_sessions=0/1`,所以严格模式正确失败。开放组织 dry-run 返回 `employees=3474 phone_linked=1 masked_phone=3473 suspect_text=8 synced=0`,且 dry-run 不访问本地表结构或写库,证明手机号和 8 条问号数据来自上游且本轮未覆盖快照。
- 管理端试点 CSV 已与严格预检统一口径: 必须选择起止日期,只导出窗口内唯一匹配正式组织的训练明细,完训按每人至少 10 次,校准必须关联正式试点会话。真实 API 返回正式试点 1 人/1 项目、10 次完训 `0/1`、校准 `0/20`、窗口内 SOP `2/2`,总闸门为 `false`;此前开发会话中的 20 条校准不再计入正式验收。
- 发布会正式候选题已重新走查: Q01-Q12 `12/12` 返回有效答案和依据,来源告警 `0`,向量状态 `450/450`;仍有 119 条 SOP 问答待业务评审,不把开发方走查替代业务签字。
- 下一阶段进入三端联合发布门槛: 复跑员工-主管-候选人角色切换、清理本地验证数据、核对正式组织姓名编码并完成 H5 发布前验收;不把本次页面拆分等同一期生产验收完成。
## 7. 风险
@@ -244,3 +274,4 @@ mobile-uni/
- H5 路由和将来小程序路径不完全一致,页面命名要早期克制。
- 旧 H5 在 Alpha 前仍承担演示兜底,新工程未完成替换前不能下线旧入口。
- 如果后端某些接口仍要求管理端 token,需要后端补移动端认证口径,不能在新前端绕过认证。
- 当前本地组织快照存在部分不可读姓名,前端已做可识别展示兜底,但正式试点前仍需从开放组织同步源修复编码并重跑快照同步。
@@ -3,6 +3,7 @@
> 生成时间:2026-07-08
> 来源口径:基于已入库正式资料生成,不使用本地 smoke 题作为验收依据。
> 状态说明:本文件是开发方候选题单,最终题单需业务方确认后锁定。
> 最近复核:2026-07-10 运行 `node scripts/verify-demo-questions.mjs --prefix=Q`,Q01-Q12 结果 `12/12`、来源告警 `0`、向量 `450/450`。
## 生成依据
+5
View File
@@ -9,6 +9,7 @@
> v0.6 修订:并入 2026-07-07 立项会结论(见《银城员工端APP分阶段实施总纲》)——本计划升格为总纲**阶段一**的执行文档;时间锚点对齐 Alpha/Beta/半年会/收费季;收费场景权重上调;新增新员工每日三题;埋 `knowledge_gap` 钩子;岗位术语待 M0 对齐。
> v0.7 确认:客户方确认"管家"="生活顾问"(同一岗位,术语统一为"管家(生活顾问)",`position` 标签不迁移);APP 形态定为 H5 套壳(维持 D6)。M0 两项待确认项关闭。
> v0.8 修订:并入 2026-07-08 产品讨论结论(见《数字师傅学练问报整合方案 v1.1》,docs/20260708/)——①M1 新增 `sop_answer` 直给格式模板(结论先行 + 一二三四五编号步骤 + 引用后置),随 2026-07-10 测试版上线;②M2 新增对练回复调节按钮(turn 支持 style 重生成)与问答答案打勾/打叉反馈;③师徒演练暂缓升级为**定向师带徒不做**,由阶段二开放问题榜承载(主管传帮带工作台不受影响);④M4 成长路径补学分/统考资格口径。
> v0.9 同步:正式试点导出和严格门禁已完成工程收口。两者均强制批次起止日、唯一在职组织身份和每人至少 10 次完训;校准/SOP 门禁以原始计数判定,不以四舍五入后的比率反推。当前仍等待上游提供至少 20 名可登录正式试点人员及业务评审,不将工程就绪写成业务验收通过。
> 结论:二期不做“24 个缺口全量铺开”,先把 AI 陪练从 MVP 演示闭环推进到**生活顾问岗位试点可用**。
## 1. 二期目标
@@ -249,6 +250,9 @@
- 住宅类 SOP 问答人工评审可用率目标 ≥80%。
- 语音单轮响应目标 ≤5 秒;超过时有文本降级。
- 试点数据可导出:训练次数、完训率、平均分、预警数、主管复盘率。
- 导出与严格预检必须指定同一批次起止日;只统计能唯一映射到在职组织的正式会话,明细保留正式身份和项目,并同时导出校准命中数、SOP 可用数及对应比率。
当前状态(2026-07-10):工程链路、真实 API 验证、回归和复审已完成;本地正式窗口 `2026-07-07..2026-07-10` 只有 1 名可登录正式人员、`0/1` 人达到十次完训、正式校准 `0/20`、已评审 SOP `2/2`,另有 119 条待评审。因此 M5 的**工程能力已就绪,正式业务试点未通过**。下一动作是上游修复/开放正式手机号和姓名编码后做 dry-run、同步,再按真实试点批次积累训练、校准和 SOP 评审证据。
## 5. 二期 API 建议
@@ -430,3 +434,4 @@
- 画像能支撑“下一步练什么”的规则分发。
- 系统能产出初/中/高级生活顾问评级证据包,但不自动给出晋升结论。
- 试点所需的合规、成本、人工校准底线已经留好。
- 二期技术闭环完成不等于一期业务验收完成;业务通过必须以 M5 的正式组织数据、训练记录和业务评审证据为准。
+4 -3
View File
@@ -8,15 +8,16 @@
|---|---|---|
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数 |
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数和 SOP 可用数,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL) | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页大小参数按线上接口使用 `limit`,映射并刷新 `aihr_org_snapshot`;员工手机号只落 `person_phone` 用于移动端登录身份映射,不在组织快照列表响应暴露;岗位优先识别 `position/job_title/post/job_name/role/title` 等字段,避免正式数据把主管/项目经理误落为一线;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;主管端复盘默认按登录手机号映射组织快照后做项目/主管范围过滤,组织快照缺失时保持演示降级不阻断;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV |
| 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;APP 候选人资料、面试启动和面试记录以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `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,不兜前端示例答案 |
## 后端落点
+5 -3
View File
@@ -15,7 +15,7 @@
| 顺序 | 页面 | 点击路径 | 必须看到 |
|---|---|---|---|
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端今日页进入问师傅、练习、学习页、案例素材和个人页;学习页读取今日任务、岗位 SOP 和训练前预习卡;练习页完成开始练习、提交回应、结束评分、每日三题提交;问师傅页可检索、反馈、生成总结卡;案例素材页可上传语音并回读入库记录;候选人端点“开始面试/面试练习”和“补充资料”;主管端查看团队画像、待复盘、标记已复盘并从画像派专项 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;学习页不再落到「建设中」,内容来自现有任务、知识库和预习卡接口;底部 tab 整页切换(员工端为 今日/练/问/我);未实现入口进「建设中」整页(返回按钮),无死按钮;主管端待复盘列表增加,复盘详情可看评分、话术、导师改写,并可标记已复盘;从团队画像派专项后最近专项立即回读;候选人端可拉题/答题/评分,资料上传后显示审核状态;HR 审核后候选人端显示 `已通过/已驳回` |
| 2 | uni-app 三端 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index` | 打开后先手机号登录;员工端从今日页进入问师傅、练习、学习、案例素材和个人页;候选首页按真实状态进入独立 `/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;主管首页进入独立团队画像、指派、复盘列表和复盘详情 | 员工端显示训练评分、历史和画像;候选面试页可真实拉题/答题/评分,资料页显示审核三态,进度页展示办理步骤,预习页从正式知识库返回依据;主管端待复盘列表、复盘详情、专项指派和团队范围均为独立页面且普通员工无主管权限 |
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 |
@@ -29,10 +29,12 @@
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
| 2 | 员工端 `/h5/#/pages/user/practice/index` | 今日任务 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
| 3 | 主管端 `/h5/#/pages/supervisor/index/index` | 团队画像/待复盘 → 进入复盘详情 → 看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;从团队画像派专项后员工端出现专项/再练任务 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 导出试点 CSV | 导出包含训练次数、完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率 |
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 |
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
试点正式验收前,用 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门;人员完训率、校准样本、AI/人工分档一致率和 SOP 可用率未达标时应失败,不用静态演示 marker 代替真实试点数据。
试点正式验收前,用 `AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,日期替换为本批试点的真实起止日。闸门只统计该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。
2026-07-10 本地快照(时间窗 `2026-07-07..2026-07-10`):场景 `12/12`、开发活动身份 `50` 人,但达到 10 次对练者 `0` 人;窗口内能关联正式试点会话的校准样本为 `0/20`,因此分档一致率暂不可验收;已评审 SOP `2/2` 可用,另有 `119` 条待评审。严格预检当前**失败**于正式组织人员门槛:3001 名本地在职人员只有 1 名具备唯一可用手机号,活动数据也只有 1 人能唯一匹配正式组织,且该人员达到 10 次对练的数量为 `0/1`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。
## 录屏兜底
+8
View File
@@ -38,6 +38,8 @@
portless run --name wygj-api ./scripts/dev-backend.sh
```
重复启动或切换分支时优先使用 `./scripts/dev.sh restart`,停止使用 `./scripts/dev.sh stop`。脚本会递归终止 portless 包装进程及其 Java/Vite 子进程,避免旧后端脱离后继续占用 MySQL 连接池;进程树回归可用 `bash scripts/tests/process-tree.test.sh` 验证。
也可以只让 portless 按根目录 `portless.json` 启动前端工程:
```bash
@@ -48,6 +50,12 @@ portless
Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/api/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;业务请求会用 client secret 生成 HMAC-SHA256 hex 签名。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。若开放平台凭证放在 `backend/.env`,启动脚本也会加载该文件,并把 `client_id`/`client_secret` 映射为组织同步实际读取的 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`。
组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。只有 `phone_linked` 能覆盖至少 20 名正式试点人员且上游修复疑似乱码后,才切换管理端组织同步为写入模式。
正式试点预检必须指定当前批次时间窗,例如:`AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受 `YYYY-MM-DD`,只统计窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。
管理端正式试点 CSV 也必须显式选择同一批次日期。可用真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。
本地 `.env.local` 示例:
```bash
+2 -2
View File
@@ -8,11 +8,11 @@
|---|---|
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 |
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、业务模块落点、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
| [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 |
| [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 |
| [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 2026-07-07 立项会后的三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜) → 北森对接;身份三轴模型与防遗漏分发策略 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 |
| [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 |
| [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 2026-07-08 产品讨论定案:产品定义(学练问报+问题榜+学分晋升)与半年会发布会方案分层;客户"第三版"逐项处置对照 |
| [基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get 笔记】.md](基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get%20笔记】.md) | 2026-07-07 立项会纪要原文(总纲的输入源,只作留档) |
+2 -1
View File
@@ -217,10 +217,11 @@ export function calibratePracticeSession(sessionId: string, data: PracticeCalibr
});
}
export function exportPracticePilotCsv(): Promise<Blob> {
export function exportPracticePilotCsv(params: { startDate: string; endDate: string }): Promise<Blob> {
return request({
url: '/api/train/practice/export',
method: 'get',
params,
responseType: 'blob'
}) as unknown as Promise<Blob>;
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolvePilotDateWindow } from './pilot-export';
describe('resolvePilotDateWindow', () => {
it('returns null when the Element Plus date range is cleared', () => {
expect(resolvePilotDateWindow(null)).toBeNull();
});
it('returns an explicit start and end date only for a complete range', () => {
expect(resolvePilotDateWindow(['2026-07-07', '2026-07-10'])).toEqual({
startDate: '2026-07-07',
endDate: '2026-07-10'
});
});
});
+15
View File
@@ -0,0 +1,15 @@
export type PilotDateWindow = {
startDate: string;
endDate: string;
};
export function resolvePilotDateWindow(value: unknown): PilotDateWindow | null {
if (!Array.isArray(value) || value.length !== 2) {
return null;
}
const [startDate, endDate] = value;
if (typeof startDate !== 'string' || typeof endDate !== 'string' || !startDate || !endDate) {
return null;
}
return { startDate, endDate };
}
+27 -4
View File
@@ -8,7 +8,17 @@
<div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
<el-button :loading="exportingPilot" @click="exportPilotCsv">导出试点CSV</el-button>
<el-date-picker
v-model="pilotDateRange"
class="pilot-date-range"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="试点开始"
end-placeholder="试点结束"
unlink-panels
/>
<el-button :icon="Download" :loading="exportingPilot" :disabled="!pilotExportWindow" @click="exportPilotCsv">导出试点CSV</el-button>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button>
</div>
@@ -292,7 +302,7 @@
<script setup name="TrainPractice" lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { Download, Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import FileSaver from 'file-saver';
@@ -313,6 +323,7 @@ import {
type PracticeScenarioResponse
} from '@/api/aihr/practice';
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
import { resolvePilotDateWindow } from './pilot-export';
type ChatRole = 'customer' | 'trainee' | 'coach';
@@ -461,6 +472,8 @@ const calibrationReason = ref('管理端人工校准');
const calibrationSaving = ref(false);
const calibrationMessage = ref('');
const exportingPilot = ref(false);
const pilotDateRange = ref<string[] | null>(null);
const pilotExportWindow = computed(() => resolvePilotDateWindow(pilotDateRange.value));
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
const defaultPromptForm = (): PromptTemplateForm => ({
id: null,
@@ -797,10 +810,16 @@ const saveCalibration = async () => {
};
const exportPilotCsv = async () => {
const dateWindow = pilotExportWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择完整的试点批次日期');
return;
}
const { startDate, endDate } = dateWindow;
exportingPilot.value = true;
try {
const data = await exportPracticePilotCsv();
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), 'aihr-practice-pilot.csv');
const data = await exportPracticePilotCsv({ startDate, endDate });
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), `aihr-practice-pilot-${startDate}-${endDate}.csv`);
ElMessage.success('试点数据 CSV 已导出');
} catch {
ElMessage.error('试点数据导出失败,请检查权限');
@@ -967,6 +986,10 @@ onMounted(() => {
flex-wrap: wrap;
}
.pilot-date-range {
width: 250px;
}
.flow-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+48
View File
@@ -61,11 +61,59 @@
"navigationBarTitleText": "主管"
}
},
{
"path": "pages/supervisor/team/index",
"style": {
"navigationBarTitleText": "团队画像"
}
},
{
"path": "pages/supervisor/assign/index",
"style": {
"navigationBarTitleText": "指派专项"
}
},
{
"path": "pages/supervisor/reviews/index",
"style": {
"navigationBarTitleText": "待复盘"
}
},
{
"path": "pages/supervisor/review/index",
"style": {
"navigationBarTitleText": "复盘详情"
}
},
{
"path": "pages/candidate/index/index",
"style": {
"navigationBarTitleText": "候选人"
}
},
{
"path": "pages/candidate/interview/index",
"style": {
"navigationBarTitleText": "面试任务"
}
},
{
"path": "pages/candidate/materials/index",
"style": {
"navigationBarTitleText": "补充资料"
}
},
{
"path": "pages/candidate/progress/index",
"style": {
"navigationBarTitleText": "进度通知"
}
},
{
"path": "pages/candidate/study/index",
"style": {
"navigationBarTitleText": "岗前预习"
}
}
],
"globalStyle": {
+223 -595
View File
@@ -3,9 +3,11 @@
<view class="page-head">
<view>
<text class="page-title">候选人</text>
<text class="page-subtitle">{{ candidateSubtitle }}</text>
<text class="page-subtitle">{{ subtitle }}</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">刷新</button>
<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>
@@ -13,396 +15,178 @@
<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>
<view v-if="candidatePrimary" class="panel task-panel">
<text class="task-badge">{{ candidatePrimary.badge }}</text>
<text class="task-title">{{ candidatePrimary.title }}</text>
<text class="task-desc">{{ candidatePrimary.desc }}</text>
<template v-else>
<view class="panel task-panel">
<view class="task-head">
<text class="task-badge">{{ flow.badge }}</text>
<text class="task-progress">{{ flow.progressText }}</text>
</view>
<text class="task-title">{{ flow.title }}</text>
<text class="task-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${candidatePrimary.progress}%` }" />
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
<text class="muted">{{ candidatePrimary.progressText }}</text>
<button class="primary-button interview-start" :disabled="primaryBusy" @click="runPrimaryAction">
{{ interviewStatus === 'active' && !needsMaterialUpload ? '重新生成题目' : primaryBusy ? '处理中' : candidatePrimary.cta }}
<button class="primary-button task-button" :disabled="loading" @click="openRoute(flow.route)">
{{ flow.cta }}
</button>
</view>
<view class="panel progress-panel">
<text class="panel-title">进度通知</text>
<text class="progress-notice">{{ progressNotice }}</text>
</view>
<view v-if="interviewQuestions.length" class="panel">
<view class="panel action-panel">
<view class="section-head">
<text class="panel-title">面试问答</text>
<text class="panel-title">办理事项</text>
<text class="muted">按步骤完成</text>
</view>
<view class="question-list">
<view v-for="item in interviewQuestions" :key="item.questionId" class="question-card">
<text class="question-meta">{{ item.seq }} · {{ item.scenario }}</text>
<text class="question-title">{{ item.questionText }}</text>
<text class="question-point">{{ item.point }}</text>
<textarea
v-model="interviewAnswers[item.questionId]"
class="textarea answer-textarea"
maxlength="400"
:disabled="interviewStatus !== 'active'"
placeholder="输入你的回答"
/>
<view class="action-list">
<button
v-for="item in actionItems"
:key="item.route"
class="action-row"
@click="openRoute(item.route)"
>
<text class="action-index">{{ item.index }}</text>
<view class="action-copy">
<text class="action-title">{{ item.title }}</text>
<text class="action-desc">{{ item.desc }}</text>
</view>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
提交评分
<text class="action-status" :class="item.tone">{{ item.status }}</text>
<text class="action-arrow">›</text>
</button>
</view>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="panel activity-panel">
<view class="section-head">
<text class="panel-title">面试结果</text>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
<text class="panel-title">最新动态</text>
<button class="text-button" @click="openRoute('/pages/candidate/progress/index')">查看进度</button>
</view>
<text class="result-title">{{ interviewResult.result }}</text>
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
<view>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
<view v-if="latestRecord" class="activity-row">
<text class="activity-label">面试</text>
<view class="activity-copy">
<text class="activity-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="activity-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<text class="muted">{{ interviewRecords.length }} 条</text>
</view>
<view v-if="interviewRecords.length" class="interview-list">
<view v-for="item in interviewRecords" :key="`${item.time}-${item.candidate}-${item.score}`" class="interview-row">
<view class="interview-copy">
<text class="interview-title">{{ item.candidate }} · {{ item.position }}</text>
<text class="interview-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="interview-score">
<text>{{ item.score }}</text>
<text>{{ item.result }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无面试记录</view>
</view>
<view class="panel">
<view class="section-head">
<text class="panel-title">岗前预习</text>
<button class="ghost-button study-button" :disabled="studyLoading" @click="loadStudy">
{{ loggedIn ? studyLoading ? '检索中' : '生成预习' : '登录查看' }}
</button>
</view>
<text v-if="studyAnswer" class="study-answer">{{ studyAnswer }}</text>
<view v-if="studyRefs.length" class="study-refs">
<text v-for="item in studyRefs" :key="item" class="study-ref">{{ item }}</text>
</view>
<view v-if="!studyAnswer" class="empty">{{ loggedIn ? '点击生成预习,从正式知识库生成要点' : '登录后从正式知识库生成预习要点' }}</view>
</view>
<view v-if="loggedIn" class="panel">
<view class="section-head">
<text class="panel-title">补充资料</text>
<button class="primary-button upload-button" :disabled="uploading" @click="uploadMaterial">
{{ uploading ? '上传中' : '上传' }}
</button>
</view>
<view v-if="materials.length" class="material-list">
<view v-for="item in materials" :key="item.id" class="material-row">
<view class="material-copy">
<text class="material-title">{{ item.fileName }}</text>
<text class="material-desc">OSS {{ item.ossId }} · {{ item.materialType }} · {{ formatSize(item.fileSize) }}</text>
</view>
<view class="status-pill" :class="statusTone(item.status)">
{{ item.status }}
</view>
</view>
</view>
<view v-else class="empty">暂无已提交资料</view>
</view>
<view class="panel">
<text class="panel-title">流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.title" class="step-row">
<text class="step-dot">{{ step.index }}</text>
<view>
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
<view v-if="latestMaterial" class="activity-row">
<text class="activity-label">资料</text>
<view class="activity-copy">
<text class="activity-title">{{ latestMaterial.fileName }}</text>
<text class="activity-desc">{{ latestMaterial.time }} · {{ latestMaterial.status }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewFinishResponse, InterviewQuestion, InterviewRecord, PrimaryTask, RoleHome } from '@/types/api';
import { getMobileHome } from '@/services/home';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { getCandidateInterviewRecords, getCandidateMaterials, getCandidateName } from '@/services/candidate';
import { buildCandidateFlow } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
import {
chooseCandidateFile,
finishCandidateInterview,
getCandidateInterviewRecords,
getCandidateName,
getCandidateMaterials,
saveInterviewAnswer,
startCandidateInterview,
uploadCandidateMaterial
} from '@/services/candidate';
const home = ref<RoleHome | null>(null);
const currentPath = '/pages/candidate/index/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const interviewRecords = ref<InterviewRecord[]>([]);
const interviewQuestions = ref<InterviewQuestion[]>([]);
const interviewAnswers = ref<Record<string, string>>({});
const interviewResult = ref<InterviewFinishResponse | null>(null);
const interviewSessionId = ref('');
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
const loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const studyLoading = ref(false);
const studyAnswer = ref('');
const studyRefs = ref<string[]>([]);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const latestMaterialStatus = computed(() => materials.value[0]?.status || '');
const candidateSubtitle = computed(() => loggedIn.value ? `尾号${getCandidateName().slice(-4)},面试、补资料、等通知` : '面试、补资料、等通知');
const hasInterview = computed(() => interviewRecords.value.length > 0 || interviewStatus.value === 'finished');
const needsMaterialUpload = computed(() =>
loggedIn.value && hasInterview.value && (!materials.value.length || latestMaterialStatus.value === '已驳回')
);
const primaryBusy = computed(() => loading.value || interviewBusy.value || (needsMaterialUpload.value && uploading.value));
const defaultCandidateTask: PrimaryTask = {
badge: '待面试',
title: '结构化面试',
desc: '按岗位题目完成回答,提交后生成面试记录。',
progress: 0,
progressText: '0/2',
cta: '开始面试'
};
const candidatePrimary = computed<PrimaryTask | null>(() => {
if (!loggedIn.value) return null;
const base = home.value?.primary || defaultCandidateTask;
const materialStatus = latestMaterialStatus.value;
if (!hasInterview.value) return { ...base, badge: '待面试', progress: 0, progressText: '0/2', cta: '开始面试' };
if (materialStatus === '已通过') return { ...base, badge: '资料已通过', title: '等待后续通知', desc: '面试和补充资料已完成,留意 HR 通知。', progress: 100, progressText: '2/2', cta: '刷新进度' };
if (materialStatus === '已驳回') return { ...base, badge: '资料被驳回', title: '重新提交资料', desc: '请按驳回结果补充或替换资料。', progress: 50, progressText: '1/2', cta: '重新上传' };
if (materials.value.length) return { ...base, badge: '审核中', title: '资料审核中', desc: '资料已提交,等待 HR 审核。', progress: 75, progressText: '1/2', cta: '刷新进度' };
return { ...base, badge: '待补资料', title: '上传补充资料', desc: '面试已完成,请补充简历、证书或图片资料。', progress: 50, progressText: '1/2', cta: '上传资料' };
});
const progressNotice = computed(() => {
if (!loggedIn.value) return '登录后查看面试和资料进度';
if (!hasInterview.value) return '待完成结构化面试';
if (!materials.value.length) return '面试已完成,请上传补充资料';
if (latestMaterialStatus.value === '已通过') return '资料已通过,等待后续通知';
if (latestMaterialStatus.value === '已驳回') return '资料被驳回,请重新上传';
return '资料已提交,等待 HR 审核';
});
const steps = [
{ index: 1, title: '完成面试', desc: '按岗位题目回答,生成面试记录' },
{ index: 2, title: '补充资料', desc: '上传简历、证书或图片资料' },
{ index: 3, title: '等待审核', desc: 'HR 审核后同步显示状态' }
];
const clearCandidatePrivateData = () => {
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const subtitle = computed(() => loggedIn.value
? `尾号${getCandidateName().slice(-4)},按当前任务办理`
: '面试、补资料、查进度');
const actionItems = computed(() => [
{
index: 1,
title: '面试任务',
desc: records.value.length ? '查看最近结果或重新面试' : '完成岗位结构化问答',
status: records.value.length ? '已完成' : '待完成',
tone: records.value.length ? 'done' : 'current',
route: '/pages/candidate/interview/index'
},
{
index: 2,
title: '补充资料',
desc: latestMaterial.value ? latestMaterial.value.fileName : '上传简历、证书或图片',
status: latestMaterial.value?.status || '未提交',
tone: latestMaterial.value?.status === '已通过' ? 'done' : flow.value.stage === 'materials' ? 'current' : 'pending',
route: '/pages/candidate/materials/index'
},
{
index: 3,
title: '进度通知',
desc: flow.value.notice,
status: flow.value.progressText,
tone: flow.value.stage === 'complete' ? 'done' : 'pending',
route: '/pages/candidate/progress/index'
},
{
index: 4,
title: '岗前预习',
desc: '从正式知识库生成岗位要点',
status: '按需查看',
tone: 'pending',
route: '/pages/candidate/study/index'
}
]);
const clearData = () => {
records.value = [];
materials.value = [];
interviewRecords.value = [];
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
studyAnswer.value = '';
studyRefs.value = [];
};
const clearInterviewDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
refreshAuthState();
if (wasLoggedIn && !loggedIn.value) {
clearCandidatePrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadPage = async () => {
refreshAuthState();
const wasLoggedIn = loggedIn.value;
loading.value = true;
message.value = '';
try {
home.value = await getMobileHome('candidate');
} catch (error) {
home.value = null;
}
try {
if (!loggedIn.value) {
clearCandidatePrivateData();
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
interviewRecords.value = nextRecords;
records.value = nextRecords;
} catch (error) {
handleRequestError(error, '候选人数据加载失败', wasLoggedIn);
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '候选人进度加载失败';
}
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id} / OSS ${saved.ossId}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败', true);
} finally {
uploading.value = false;
}
};
const loadStudy = async () => {
if (studyLoading.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
studyLoading.value = true;
message.value = '';
try {
const result = await searchKnowledge('生活顾问岗前预习 投诉处理 报事报修 客户沟通', '生活顾问');
studyAnswer.value = result.answer;
studyRefs.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 3);
} catch (error) {
handleRequestError(error, '岗前预习生成失败', true);
} finally {
studyLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
clearInterviewDraft();
message.value = '';
try {
const data = await startCandidateInterview();
interviewSessionId.value = data.sessionId;
interviewQuestions.value = data.questions || [];
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
interviewStatus.value = 'active';
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
} catch (error) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败', true);
}
};
const runPrimaryAction = () => {
if (needsMaterialUpload.value) {
void uploadMaterial();
return;
}
if (hasInterview.value && materials.value.length) {
void loadPage();
return;
}
void startInterview();
};
const finishInterview = async () => {
if (interviewBusy.value) return;
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
message.value = '请先开始面试';
return;
}
const answers = Object.fromEntries(
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).filter(([, value]) => value).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
interviewRecords.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败', true)) {
interviewStatus.value = 'active';
}
}
};
const formatSize = (size: number) => {
if (!size) return '0KB';
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
const statusTone = (status: string) => {
if (status === '已通过') return 'passed';
if (status === '已驳回') return 'rejected';
return 'pending';
const openRoute = (route: string) => {
uni.navigateTo({ url: route });
};
const goLogin = () => {
rememberLoginRedirect('/pages/candidate/index/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
@@ -417,12 +201,16 @@ onShow(() => {
background: #f4f7fb;
}
.candidate-page .panel,
.candidate-page button {
border-radius: 8px;
}
.page-head,
.section-head,
.login-panel,
.interview-row,
.material-row,
.step-row {
.task-head,
.activity-row {
display: flex;
align-items: center;
justify-content: space-between;
@@ -430,9 +218,7 @@ onShow(() => {
}
.refresh-button,
.login-button,
.upload-button,
.study-button {
.login-button {
min-width: 84px;
}
@@ -440,28 +226,26 @@ onShow(() => {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.task-panel {
background: #fff7ed;
border-left: 4px solid #e60012;
}
.task-badge {
display: inline-flex;
padding: 5px 9px;
border-radius: 999px;
background: #fed7aa;
color: #9a3412;
font-size: 12px;
.task-badge,
.task-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.task-title {
display: block;
margin-top: 12px;
margin-top: 14px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
font-weight: 800;
}
.task-desc {
@@ -469,282 +253,126 @@ onShow(() => {
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin: 16px 0 10px;
margin-top: 16px;
overflow: hidden;
border-radius: 999px;
background: #ffedd5;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #ea580c;
background: #e60012;
}
.progress-panel {
background: #eff6ff;
}
.progress-notice {
display: block;
margin-top: 10px;
color: #1e3a8a;
font-size: 15px;
font-weight: 800;
line-height: 1.45;
}
.interview-start,
.finish-button {
.task-button {
width: 100%;
margin-top: 16px;
}
.question-list,
.dimension-list {
margin-top: 14px;
}
.question-card {
.action-list {
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.question-meta {
color: #ea580c;
font-size: 12px;
font-weight: 800;
.action-row {
display: grid;
width: 100%;
min-height: 72px;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
border-radius: 0 !important;
background: transparent;
text-align: left;
grid-template-columns: 28px minmax(0, 1fr) auto 12px;
align-items: center;
gap: 10px;
}
.question-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 16px;
font-weight: 900;
line-height: 1.45;
}
.question-point {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.answer-textarea {
margin-top: 12px;
}
.result-score {
padding: 6px 10px;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 14px;
font-weight: 900;
}
.result-title {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.dimension-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.dimension-title {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.dimension-note {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.dimension-value {
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.interview-list {
margin-top: 14px;
}
.interview-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.interview-copy {
flex: 1;
min-width: 0;
}
.interview-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.interview-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.interview-score {
display: flex;
min-width: 64px;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.interview-score text:first-child {
color: #ea580c;
font-size: 22px;
font-weight: 900;
}
.study-answer {
display: block;
margin-top: 12px;
color: #0f172a;
font-size: 14px;
line-height: 1.6;
}
.study-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.study-ref {
padding: 5px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.material-list,
.step-list {
margin-top: 14px;
}
.material-row {
margin-top: 10px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
}
.material-copy {
flex: 1;
min-width: 0;
}
.material-title,
.step-title {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.step-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 6px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.step-row {
justify-content: flex-start;
margin-top: 12px;
align-items: flex-start;
}
.step-dot {
.action-index,
.activity-label {
display: flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e0f2fe;
color: #0369a1;
background: #f1f5f9;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.action-copy,
.activity-copy {
min-width: 0;
flex: 1;
}
.action-title,
.activity-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.action-desc,
.activity-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.action-status {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.action-status.current {
color: #b91c1c;
}
.action-status.done {
color: #15803d;
}
.action-status.pending {
color: #64748b;
}
.action-arrow {
color: #94a3b8;
font-size: 20px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
font-weight: 900;
font-weight: 700;
}
.activity-row {
justify-content: flex-start;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,437 @@
<template>
<view class="page candidate-subpage interview-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">面试任务</text>
<text class="page-subtitle">生活顾问结构化面试</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后开始面试并查看本人记录。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel intro-panel">
<text class="intro-label">当前任务</text>
<text class="intro-title">完成岗位问答</text>
<text class="intro-desc">题目由已配置模型生成,全部作答后形成正式面试记录。</text>
<button class="primary-button start-button" :disabled="interviewBusy" @click="startInterview">
{{ interviewStatus === 'active' ? '重新生成题目' : interviewBusy ? '处理中' : '开始面试' }}
</button>
</view>
<view v-if="interviewQuestions.length" class="question-section">
<view class="section-heading">
<text class="section-title">面试问答</text>
<text class="muted">{{ answeredCount }}/{{ interviewQuestions.length }} 已填写</text>
</view>
<view
v-for="item in interviewQuestions"
:key="item.questionId"
class="question-block"
>
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</text>
<text class="question-title">{{ item.questionText }}</text>
<text class="question-point">考察点:{{ item.point }}</text>
<textarea
v-model="interviewAnswers[item.questionId]"
class="textarea answer-textarea"
maxlength="400"
:disabled="interviewStatus !== 'active'"
placeholder="输入你的真实回答"
/>
</view>
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="result-head">
<view>
<text class="panel-title">本次结果</text>
<text class="result-title">{{ interviewResult.result }}</text>
</view>
<text class="result-score">{{ interviewResult.totalScore }}分</text>
</view>
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
<view class="dimension-copy">
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看办理进度</button>
</view>
<view class="panel records-panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<button class="text-button" :disabled="recordsLoading" @click="loadRecords">
{{ recordsLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="item in records" :key="`${item.time}-${item.score}`" class="record-row">
<view class="record-copy">
<text class="record-title">{{ item.position }} · {{ item.result }}</text>
<text class="record-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<text class="record-score">{{ item.score }}</text>
</view>
</view>
<view v-else-if="!recordsLoading" class="empty compact-empty">暂无面试记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
finishCandidateInterview,
getCandidateInterviewRecords,
saveInterviewAnswer,
startCandidateInterview
} from '@/services/candidate';
import { candidateRoutes } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
const interviewQuestions = ref<InterviewQuestion[]>([]);
const interviewAnswers = ref<Record<string, string>>({});
const interviewResult = ref<InterviewFinishResponse | null>(null);
const interviewSessionId = ref('');
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
const recordsLoading = ref(false);
const loggedIn = ref(false);
const message = ref('');
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
Boolean(interviewAnswers.value[item.questionId]?.trim())).length);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearDraft = () => {
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
};
const clearPrivateData = () => {
clearDraft();
records.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadRecords = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
if (recordsLoading.value) return;
recordsLoading.value = true;
try {
records.value = await getCandidateInterviewRecords();
} catch (error) {
handleRequestError(error, '面试记录加载失败');
} finally {
recordsLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
interviewStatus.value = 'starting';
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
message.value = '';
try {
const data = await startCandidateInterview();
interviewSessionId.value = data.sessionId;
interviewQuestions.value = data.questions || [];
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
interviewStatus.value = 'active';
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
} catch (error) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败');
}
};
const finishInterview = async () => {
if (interviewBusy.value) return;
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
message.value = '请先开始面试';
return;
}
const answers = Object.fromEntries(
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
records.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败')) {
interviewStatus.value = 'active';
}
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
void loadRecords();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button,
.question-block {
border-radius: 8px;
}
.login-panel,
.section-head,
.result-head,
.record-row,
.dimension-row,
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.intro-panel {
border-left: 4px solid #e60012;
}
.intro-label,
.question-meta {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.intro-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.intro-desc,
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.start-button,
.finish-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.question-section {
margin-top: 22px;
}
.section-title {
color: #172033;
font-size: 20px;
font-weight: 800;
}
.question-block {
margin-top: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.question-title {
display: block;
margin-top: 8px;
color: #172033;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.question-point {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.answer-textarea {
margin-top: 12px;
}
.result-title {
display: block;
margin-top: 8px;
color: #15803d;
font-size: 15px;
font-weight: 800;
}
.result-score,
.record-score {
color: #b91c1c;
font-size: 24px;
font-weight: 800;
}
.dimension-list,
.record-list {
margin-top: 14px;
border-top: 1px solid #e2e8f0;
}
.dimension-row,
.record-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
align-items: flex-start;
}
.dimension-copy,
.record-copy {
min-width: 0;
flex: 1;
}
.dimension-title,
.record-title {
display: block;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.dimension-note,
.record-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.dimension-value {
color: #172033;
font-size: 18px;
font-weight: 800;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,293 @@
<template>
<view class="page candidate-subpage materials-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">补充资料</text>
<text class="page-subtitle">提交简历、证书或相关图片</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后上传并查看本人资料。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel upload-panel">
<text class="upload-label">支持 PDF、Word 和常见图片</text>
<text class="upload-title">选择一份补充资料</text>
<text class="upload-desc">提交后由 HR 审核,最新一条资料状态决定当前办理进度。</text>
<button class="primary-button upload-button" :disabled="uploading || loading" @click="uploadMaterial">
{{ uploading ? '上传中' : '选择并上传' }}
</button>
</view>
<view class="panel material-panel">
<view class="section-head">
<text class="panel-title">已提交资料</text>
<button class="text-button" :disabled="loading || uploading" @click="loadMaterials">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="materials.length" class="material-list">
<view v-for="item in materials" :key="item.id" class="material-row">
<view class="material-main">
<view class="material-title-row">
<text class="material-title">{{ item.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(item.status)">{{ item.status }}</text>
</view>
<text class="material-desc">{{ item.time }} · {{ item.materialType }} · {{ formatCandidateFileSize(item.fileSize) }}</text>
<text class="material-id">资料编号 {{ item.id }} · OSS {{ item.ossId }}</text>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty compact-empty">暂无已提交资料</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看审核进度</button>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
chooseCandidateFile,
getCandidateMaterials,
uploadCandidateMaterial
} from '@/services/candidate';
import {
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/materials/index';
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const uploading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
materials.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadMaterials = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value || uploading.value) return;
loading.value = true;
try {
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料列表加载失败');
} finally {
loading.value = false;
}
};
const uploadMaterial = async () => {
if (uploading.value || loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
uploading.value = true;
message.value = '';
try {
const file = await chooseCandidateFile();
const saved = await uploadCandidateMaterial(file);
message.value = `资料已上传:#${saved.id}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败');
} finally {
uploading.value = false;
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadMaterials();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.section-head,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.upload-panel {
border-left: 4px solid #e60012;
}
.upload-label {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.upload-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.upload-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.upload-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.material-list {
margin-top: 12px;
border-top: 1px solid #e2e8f0;
}
.material-row {
padding: 14px 0;
border-bottom: 1px solid #e2e8f0;
}
.material-main {
min-width: 0;
}
.material-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.material-desc,
.material-id {
display: block;
margin-top: 6px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.material-id {
color: #94a3b8;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,401 @@
<template>
<view class="page candidate-subpage progress-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">进度通知</text>
<text class="page-subtitle">面试与资料审核状态</text>
</view>
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看本人面试和资料审核进度。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel status-panel">
<view class="status-head">
<text class="status-badge">{{ flow.badge }}</text>
<text class="status-progress">{{ flow.progressText }}</text>
</view>
<text class="status-title">{{ flow.notice }}</text>
<text class="status-desc">{{ flow.desc }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: `${flow.progress}%` }" />
</view>
<button class="primary-button next-button" :disabled="loading" @click="runCurrentAction">
{{ flow.stage === 'review' || flow.stage === 'complete' ? '刷新进度' : flow.cta }}
</button>
</view>
<view class="panel steps-panel">
<text class="panel-title">办理流程</text>
<view class="step-list">
<view v-for="step in steps" :key="step.index" class="step-row" :class="step.status">
<view class="step-rail">
<text class="step-dot">{{ step.status === 'complete' ? '✓' : step.index }}</text>
<view v-if="step.index < steps.length" class="step-line" />
</view>
<view class="step-copy">
<text class="step-title">{{ step.title }}</text>
<text class="step-desc">{{ step.desc }}</text>
</view>
<text class="step-status">{{ stepStatusLabel(step.status) }}</text>
</view>
</view>
</view>
<view class="panel evidence-panel">
<text class="panel-title">办理依据</text>
<view v-if="latestRecord" class="evidence-row">
<text class="evidence-label">面试记录</text>
<view class="evidence-copy">
<text class="evidence-title">{{ latestRecord.position }} · {{ latestRecord.score }}分</text>
<text class="evidence-desc">{{ latestRecord.time }} · {{ latestRecord.result }}</text>
</view>
</view>
<view v-if="latestMaterial" class="evidence-row">
<text class="evidence-label">最新资料</text>
<view class="evidence-copy">
<view class="material-title-row">
<text class="evidence-title">{{ latestMaterial.fileName }}</text>
<text class="status-pill" :class="materialStatusTone(latestMaterial.status)">{{ latestMaterial.status }}</text>
</view>
<text class="evidence-desc">{{ latestMaterial.time }} · {{ formatCandidateFileSize(latestMaterial.fileSize) }}</text>
</view>
</view>
<view v-if="!latestRecord && !latestMaterial && !loading" class="empty compact-empty">暂无办理记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getCandidateInterviewRecords, getCandidateMaterials } from '@/services/candidate';
import {
buildCandidateFlow,
buildCandidateSteps,
candidateRoutes,
formatCandidateFileSize,
materialStatusTone
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/progress/index';
const records = ref<InterviewRecord[]>([]);
const materials = ref<CandidateMaterial[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const flow = computed(() => buildCandidateFlow(records.value, materials.value));
const steps = computed(() => buildCandidateSteps(flow.value));
const latestRecord = computed(() => records.value[0] || null);
const latestMaterial = computed(() => materials.value[0] || null);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearData = () => {
records.value = [];
materials.value = [];
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextMaterials, nextRecords] = await Promise.all([
getCandidateMaterials(),
getCandidateInterviewRecords()
]);
materials.value = nextMaterials;
records.value = nextRecords;
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearData();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '办理进度加载失败';
}
} finally {
loading.value = false;
}
};
const runCurrentAction = () => {
if (flow.value.stage === 'review' || flow.value.stage === 'complete') {
void loadPage();
return;
}
uni.navigateTo({ url: flow.value.route });
};
const stepStatusLabel = (status: 'complete' | 'current' | 'pending') => {
if (status === 'complete') return '已完成';
if (status === 'current') return '当前';
return '待办理';
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel,
.status-head,
.evidence-row,
.material-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.refresh-button {
min-width: 76px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.status-panel {
border-left: 4px solid #e60012;
}
.status-badge,
.status-progress {
color: #b91c1c;
font-size: 13px;
font-weight: 800;
}
.status-title {
display: block;
margin-top: 14px;
color: #172033;
font-size: 21px;
font-weight: 800;
}
.status-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.progress-bar {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 4px;
background: #fee2e2;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.next-button {
width: 100%;
margin-top: 16px;
}
.step-list {
margin-top: 16px;
}
.step-row {
display: grid;
min-height: 76px;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 12px;
}
.step-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.step-row.complete .step-dot {
background: #dcfce7;
color: #166534;
}
.step-row.current .step-dot {
background: #fee2e2;
color: #b91c1c;
}
.step-line {
width: 2px;
flex: 1;
background: #e2e8f0;
}
.step-title {
display: block;
color: #172033;
font-size: 15px;
font-weight: 800;
}
.step-desc {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.step-status {
color: #64748b;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.step-row.current .step-status {
color: #b91c1c;
}
.step-row.complete .step-status {
color: #15803d;
}
.evidence-row {
justify-content: flex-start;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
align-items: flex-start;
}
.evidence-label {
flex: 0 0 64px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.evidence-copy {
min-width: 0;
flex: 1;
}
.evidence-title {
min-width: 0;
overflow-wrap: anywhere;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.evidence-desc {
display: block;
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.status-pill {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.status-pill.pending {
background: #fef3c7;
color: #92400e;
}
.status-pill.passed {
background: #dcfce7;
color: #166534;
}
.status-pill.rejected {
background: #fee2e2;
color: #991b1b;
}
.compact-empty {
padding-bottom: 4px;
}
</style>
@@ -0,0 +1,333 @@
<template>
<view class="page candidate-subpage study-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">岗前预习</text>
<text class="page-subtitle">从正式知识库生成岗位要点</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后生成生活顾问岗前预习。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel query-panel">
<text class="query-label">预习主题</text>
<textarea
v-model="queryText"
class="textarea query-input"
maxlength="120"
placeholder="输入要预习的岗位问题"
/>
<view class="topic-row">
<button
v-for="topic in topics"
:key="topic"
class="topic-button"
:class="{ active: queryText === topic }"
:disabled="loading"
@click="queryText = topic"
>
{{ topic }}
</button>
</view>
<button class="primary-button generate-button" :disabled="loading || !queryText.trim()" @click="loadStudy">
{{ loading ? '生成中' : '生成预习' }}
</button>
</view>
<view v-if="answer" class="panel answer-panel">
<text class="panel-title">预习要点</text>
<text class="answer-text">{{ answer }}</text>
<view v-if="keyPoints.length" class="answer-section">
<text class="section-title">关键要点</text>
<view v-for="(item, index) in keyPoints" :key="`${index}-${item}`" class="point-row">
<text class="point-index">{{ index + 1 }}</text>
<text class="point-text">{{ item }}</text>
</view>
</view>
<view v-if="cautions.length" class="answer-section caution-section">
<text class="section-title">注意事项</text>
<text v-for="item in cautions" :key="item" class="caution-text">{{ item }}</text>
</view>
</view>
<view v-if="references.length" class="panel reference-panel">
<text class="panel-title">引用依据</text>
<view v-for="item in references" :key="item" class="reference-row">
<text class="reference-dot">•</text>
<text class="reference-title">{{ item }}</text>
</view>
</view>
<view v-if="!answer && !loading" class="empty-state">
<text class="empty-title">尚未生成预习</text>
<text class="empty-desc">选择主题后调用正式 SOP 知识库,不使用前端示例答案。</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { candidateRoutes } from '@/services/candidate-view';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
const currentPath = '/pages/candidate/study/index';
const topics = [
'生活顾问接到业主投诉时,应该如何安抚、记录、跟进和反馈?',
'生活顾问接到报事报修后,应该如何登记、派单和回访?',
'生活顾问与情绪激动的业主沟通时,有哪些原则和禁忌?'
];
const queryText = ref(topics[0]);
const answer = ref('');
const keyPoints = ref<string[]>([]);
const cautions = ref<string[]>([]);
const references = ref<string[]>([]);
const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearResult = () => {
answer.value = '';
keyPoints.value = [];
cautions.value = [];
references.value = [];
};
const loadStudy = async () => {
if (loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
const query = queryText.value.trim();
if (!query) {
message.value = '请输入预习主题';
return;
}
loading.value = true;
message.value = '';
clearResult();
try {
const result = await searchKnowledge(query, '生活顾问');
answer.value = result.answer;
keyPoints.value = result.keyPoints || [];
cautions.value = result.cautions || [];
references.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 5);
if (!answer.value) message.value = '正式知识库暂未返回可用答案';
} catch (error) {
refreshAuthState();
if (!loggedIn.value) {
clearResult();
message.value = '登录已过期,请重新登录';
} else {
message.value = error instanceof Error ? error.message : '岗前预习生成失败';
}
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) clearResult();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button {
border-radius: 8px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.query-panel {
border-left: 4px solid #e60012;
}
.query-label {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.query-input {
min-height: 88px;
margin-top: 10px;
}
.topic-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.topic-button {
min-height: 34px;
padding: 0 10px;
border: 1px solid #cbd5e1;
background: #ffffff;
color: #475569;
font-size: 12px;
}
.topic-button.active {
border-color: #e60012;
background: #fff1f2;
color: #b91c1c;
}
.generate-button {
width: 100%;
margin-top: 16px;
}
.answer-text {
display: block;
margin-top: 12px;
color: #172033;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
}
.answer-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.section-title {
color: #172033;
font-size: 14px;
font-weight: 800;
}
.point-row {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: flex-start;
}
.point-index {
display: flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.point-text,
.caution-text {
color: #334155;
font-size: 14px;
line-height: 1.6;
}
.caution-text {
display: block;
margin-top: 8px;
}
.reference-row {
display: flex;
gap: 8px;
margin-top: 10px;
align-items: flex-start;
}
.reference-dot {
color: #b91c1c;
font-weight: 800;
}
.reference-title {
overflow-wrap: anywhere;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.empty-state {
padding: 56px 24px;
text-align: center;
}
.empty-title {
display: block;
color: #172033;
font-size: 17px;
font-weight: 800;
}
.empty-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
</style>
@@ -0,0 +1,432 @@
<template>
<view class="page assign-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" title="返回" :disabled="assigning" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">指派专项</text>
<text class="page-subtitle">{{ selectedTargetLabel }}</text>
</view>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后指派并查看最近专项。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel assignment-form">
<text class="field-label">训练对象</text>
<input
v-model="targetExtPartyId"
class="input field-input"
maxlength="32"
placeholder="输入手机号或外部ID"
@input="targetName = ''"
/>
<input
v-model="targetName"
class="input field-input"
maxlength="40"
placeholder="员工姓名(可选)"
/>
<text class="field-label section-label">训练场景</text>
<view class="scenario-row">
<button
v-for="item in supervisorScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }"
:disabled="assigning"
@click="assignmentScenarioId = item.id"
>
{{ item.name }}
</button>
</view>
<text class="field-label section-label">指派原因</text>
<textarea
v-model="assignmentReason"
class="textarea reason-input"
maxlength="180"
placeholder="填写本次专项训练原因"
/>
<button class="primary-button submit-button" :disabled="assigning || historyLoading" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }}
</button>
</view>
<view class="panel history-panel">
<view class="section-head">
<text class="panel-title">最近专项</text>
<button
class="ghost-button refresh-button"
:disabled="historyLoading || assigning"
@click="loadAssignments()"
>
{{ historyLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="recentAssignments.length" class="assignment-list">
<view v-for="item in recentAssignments" :key="item.id" class="assignment-row">
<view class="assignment-copy">
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
<text class="assignment-desc">{{ assignmentSummary(item) }}</text>
</view>
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
</view>
</view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeTeamSnapshot } from '@/services/practice';
import {
createAsyncGenerationGuard,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
manualPracticeAssignments,
supervisorScenarioOptions,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/assign/index';
const targetExtPartyId = ref('');
const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const assigning = ref(false);
const historyLoading = ref(false);
const message = ref('');
const historyRequests = createAsyncGenerationGuard();
const selectedTargetLabel = computed(() => {
if (!loggedIn.value) return '选择员工并配置训练任务';
const target = targetExtPartyId.value.trim();
if (!target) return '选择员工并配置训练任务';
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
});
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const queryText = (value: unknown) => {
if (typeof value !== 'string') return '';
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
};
const isScenarioId = (value: string): value is SupervisorScenarioId =>
supervisorScenarioOptions.some((item) => item.id === value);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const resetDraft = () => {
targetExtPartyId.value = '';
targetName.value = '';
assignmentScenarioId.value = 'fee-parking';
assignmentReason.value = '';
};
const clearPrivateState = () => {
resetDraft();
assignments.value = [];
historyLoading.value = false;
historyRequests.invalidate();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearPrivateState();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAssignments = async (force = false) => {
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
return;
}
if (!force && (historyLoading.value || assigning.value)) return;
const generation = historyRequests.begin();
historyLoading.value = true;
try {
const snapshot = await getPracticeTeamSnapshot();
if (historyRequests.isCurrent(generation)) {
accessDenied.value = false;
assignments.value = snapshot.assignments;
}
} catch (error) {
if (historyRequests.isCurrent(generation)) {
handleRequestError(error, '最近专项加载失败');
}
} finally {
if (historyRequests.isCurrent(generation)) {
historyLoading.value = false;
}
}
};
const loadPage = () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearPrivateState();
return;
}
void loadAssignments();
};
const assignPractice = async () => {
if (assigning.value || historyLoading.value) return;
const extPartyId = targetExtPartyId.value.trim();
if (!extPartyId) {
message.value = '请填写员工手机号或外部ID';
return;
}
assigning.value = true;
message.value = '';
try {
const scenario = supervisorScenarioOptions.find((item) => item.id === assignmentScenarioId.value);
await createPracticeAssignment({
extPartyId,
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
scenarioId: assignmentScenarioId.value,
scenarioName: scenario?.name,
reason: assignmentReason.value.trim() || '主管派发专项训练'
});
resetDraft();
uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true);
} catch (error) {
handleRequestError(error, '指派失败');
} finally {
assigning.value = false;
}
};
const assignmentSourceLabel = (source: string) => {
if (source === 'retry') return '再练';
if (source === 'camp') return '训练营';
return '专项';
};
const assignmentSummary = (item: PracticeAssignment) => {
const reason = item.reason?.trim() || '主管派发专项训练';
return `${item.createTime} · ${item.status} · ${reason}`;
};
const goLogin = () => {
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (assigning.value) return;
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onLoad((query) => {
targetExtPartyId.value = queryText(query?.extPartyId);
targetName.value = queryText(query?.name);
assignmentReason.value = queryText(query?.reason);
const requestedScenario = queryText(query?.scenarioId);
assignmentScenarioId.value = isScenarioId(requestedScenario)
? requestedScenario
: inferSupervisorScenarioId(assignmentReason.value);
});
onShow(() => {
resetPageScroll();
loadPage();
});
onBeforeUnmount(() => {
historyRequests.dispose();
});
</script>
<style scoped>
.assign-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.login-panel,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 82px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.field-label {
display: block;
color: #334155;
font-size: 13px;
font-weight: 800;
}
.section-label {
margin-top: 16px;
}
.field-input,
.reason-input,
.scenario-row,
.submit-button {
margin-top: 9px;
}
.field-input + .field-input {
margin-top: 10px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.scenario-chip {
border-radius: 8px;
}
.scenario-chip.active {
background: #fee2e2;
color: #b91c1c;
}
.submit-button {
width: 100%;
}
.assignment-list {
margin-top: 8px;
}
.assignment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid #e2e8f0;
}
.assignment-row:last-child {
border-bottom: 0;
}
.assignment-copy {
min-width: 0;
flex: 1;
}
.assignment-title,
.assignment-desc {
display: block;
}
.assignment-title {
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.assignment-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.assignment-source {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.compact-empty {
padding: 18px 8px 4px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
<template>
<view class="page review-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回待复盘" title="返回待复盘" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">复盘详情</text>
<text class="page-subtitle">{{ detail ? `${detail.trainee} · ${detail.scene}` : '训练记录' }}</text>
</view>
<button v-if="loggedIn && reviewId && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadDetail">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看并完成复盘。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else-if="detail">
<view class="panel summary-panel">
<view class="summary-head">
<view class="summary-copy">
<text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text>
<text class="detail-meta">{{ detail.time }} · {{ detail.status }}</text>
</view>
<text class="score-pill">{{ detail.score }}分</text>
</view>
<text class="detail-desc">{{ detail.aiComment || detail.summary }}</text>
</view>
<view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text>
<view class="score-list">
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view class="score-copy">
<text class="score-label">{{ item.label }}</text>
<text class="score-note">{{ item.note }}</text>
</view>
<text class="score-value">{{ item.value }}</text>
</view>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<view class="dialogue-list">
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
<view v-if="hasAudio(turn)" class="dialogue-audio-block">
<audio v-if="audioSrc(turn, index)" class="dialogue-audio" controls :src="audioSrc(turn, index)"></audio>
<button
v-else
class="secondary-button audio-button"
:disabled="audioLoading[audioKey(index)]"
@click="loadAudio(turn, index)"
>
{{ audioLoading[audioKey(index)] ? '加载中' : '加载录音' }}
</button>
<text v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</text>
</view>
</view>
</view>
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">标注与建议</text>
<view v-if="detail.annotations?.length" class="annotation-list">
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
<text class="annotation-sop">依据:{{ item.sopPoint }}</text>
</view>
</view>
<view v-if="detail.mentorRewrite" class="mentor-block">
<text class="subsection-title">推荐话术</text>
<text class="mentor-text">{{ detail.mentorRewrite }}</text>
</view>
</view>
<view v-if="editable" class="panel review-form">
<text class="panel-title">主管复盘</text>
<text class="field-label">复盘建议</text>
<textarea
v-model="advice"
class="textarea review-textarea"
maxlength="300"
placeholder="填写复盘建议,可留空直接标记"
/>
<text class="field-label">人工贡献分</text>
<input
v-model="incentivePoint"
class="input point-input"
type="number"
maxlength="3"
placeholder="可留空"
/>
<view class="next-training-section">
<text class="field-label">复盘后专项</text>
<view class="scenario-row">
<button class="chip scenario-chip" :class="{ active: !nextScenarioId }" @click="clearNextScenario">
暂不派发
</button>
<button
v-for="item in supervisorScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: nextScenarioId === item.id }"
@click="selectNextScenario(item.id)"
>
{{ item.name }}
</button>
</view>
<textarea
v-if="nextScenarioId"
v-model="nextReason"
class="textarea next-reason-input"
maxlength="180"
placeholder="填写下一次训练原因"
/>
</view>
<button class="primary-button submit-button" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中' : '标记已复盘' }}
</button>
</view>
<view v-else class="panel review-complete">
<text class="complete-title">复盘已完成</text>
<text class="complete-desc">{{ detail.reviewAdvice || '该训练记录已完成主管复盘。' }}</text>
<text v-if="detail.incentivePoint != null" class="complete-point">
人工贡献分 {{ detail.incentivePoint }}
</text>
</view>
</template>
<view v-else-if="loggedIn && !loading" class="panel empty-panel">
<view class="empty">{{ reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID' }}</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, markPracticeReviewed } from '@/services/practice';
import {
createAsyncGenerationGuard,
isReviewEditable,
isSupervisorAccessDenied,
reviewCompletionAction,
supervisorScenarioOptions,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/review/index';
const reviewId = ref(0);
const detail = ref<ReviewDetail | null>(null);
const advice = ref('');
const incentivePoint = ref('');
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
const nextReason = ref('');
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const marking = ref(false);
const message = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard();
const editable = computed(() => isReviewEditable(detail.value?.status));
const audioKey = (index: number) => `${reviewId.value || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl);
const audioSrc = (turn: ReviewDialogue, index: number) =>
audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const releaseAudioSources = () => {
audioRequests.invalidate();
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const clearDetail = () => {
releaseAudioSources();
detail.value = null;
advice.value = '';
incentivePoint.value = '';
nextScenarioId.value = '';
nextReason.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearDetail();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAudio = async (turn: ReviewDialogue, index: number) => {
const key = audioKey(index);
if (audioSrc(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
const generation = audioRequests.capture();
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const source = await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`);
if (!audioRequests.isCurrent(generation)) {
releaseAuthenticatedFileUrl(source);
return;
}
audioSources.value = {
...audioSources.value,
[key]: source
};
} catch (error) {
if (!audioRequests.isCurrent(generation)) return;
if (isSupervisorAccessDenied(error)) {
handleRequestError(error, '录音加载失败');
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
if (error instanceof Error && error.message === '网络请求失败') {
message.value = '录音加载失败,请检查网络后重试';
}
} finally {
if (audioRequests.isCurrent(generation)) {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
}
};
const loadDetail = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
if (!reviewId.value) {
clearDetail();
message.value = '缺少有效的复盘ID';
return;
}
if (loading.value) return;
loading.value = true;
releaseAudioSources();
try {
const nextDetail = await getPracticeReviewDetail(reviewId.value);
accessDenied.value = false;
detail.value = nextDetail;
advice.value = detail.value.reviewAdvice || '';
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
nextScenarioId.value = '';
nextReason.value = '';
} catch (error) {
clearDetail();
handleRequestError(error, '复盘详情加载失败');
} finally {
loading.value = false;
}
};
const selectNextScenario = (scenarioId: SupervisorScenarioId) => {
nextScenarioId.value = scenarioId;
const scenario = supervisorScenarioOptions.find((item) => item.id === scenarioId);
nextReason.value = `复盘后建议补练${scenario?.name || '专项训练'}`;
};
const clearNextScenario = () => {
nextScenarioId.value = '';
nextReason.value = '';
};
const markReviewed = async () => {
if (marking.value || !detail.value?.id) return;
if (!editable.value) {
message.value = '该记录已完成复盘,不能重复提交';
return;
}
const pointText = incentivePoint.value.trim();
const point = pointText ? Number(pointText) : undefined;
if (point !== undefined && !Number.isFinite(point)) {
message.value = '贡献分必须是数字';
return;
}
marking.value = true;
message.value = '';
const assignedNext = Boolean(nextScenarioId.value);
try {
await markPracticeReviewed(detail.value.id, {
reviewer: '主管',
advice: advice.value.trim(),
incentivePoint: point,
nextScenarioId: nextScenarioId.value || undefined,
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
});
uni.showToast({ title: assignedNext ? '已复盘并派专项' : '已复盘', icon: 'none' });
const pages = getCurrentPages();
const previousRoute = pages.length > 1 ? pages[pages.length - 2]?.route : undefined;
clearDetail();
if (reviewCompletionAction(previousRoute) === 'back') {
uni.navigateBack({ delta: 1, fail: goToReviews });
} else {
goToReviews();
}
} catch (error) {
handleRequestError(error, '标记失败');
} finally {
marking.value = false;
}
};
const currentPath = () => reviewId.value ? `${basePath}?id=${reviewId.value}` : basePath;
const goLogin = () => {
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToReviews = () => {
uni.redirectTo({ url: '/pages/supervisor/reviews/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
goToReviews();
return;
}
uni.navigateBack({ delta: 1, fail: goToReviews });
};
onLoad((query) => {
const value = Number(query?.id);
reviewId.value = Number.isInteger(value) && value > 0 ? value : 0;
});
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
if (!detail.value && !loading.value) void loadDetail();
});
onBeforeUnmount(() => {
audioRequests.dispose();
releaseAudioSources();
});
</script>
<style scoped>
.review-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-panel,
.summary-head,
.score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.review-complete {
border-left: 4px solid #16a34a;
}
.complete-title,
.complete-desc,
.complete-point {
display: block;
}
.complete-title {
color: #166534;
font-size: 16px;
font-weight: 900;
}
.complete-desc,
.complete-point {
margin-top: 8px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.summary-copy,
.score-copy {
min-width: 0;
flex: 1;
}
.detail-title,
.detail-meta,
.detail-desc,
.score-label,
.score-note,
.dialogue-label,
.dialogue-text,
.annotation-issue,
.annotation-rewrite,
.annotation-sop,
.subsection-title,
.mentor-text,
.field-label {
display: block;
}
.detail-title {
color: #0f172a;
font-size: 17px;
font-weight: 900;
}
.detail-meta {
margin-top: 5px;
color: #64748b;
font-size: 12px;
}
.detail-desc {
margin-top: 12px;
color: #475569;
font-size: 14px;
line-height: 1.55;
}
.score-pill {
flex: 0 0 auto;
padding: 7px 10px;
border-radius: 8px;
background: #fee2e2;
color: #b91c1c;
font-size: 15px;
font-weight: 900;
}
.score-list,
.dialogue-list,
.annotation-list {
margin-top: 10px;
}
.score-row,
.dialogue-row,
.annotation-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.score-row:last-child,
.dialogue-row:last-child,
.annotation-row:last-child {
border-bottom: 0;
}
.score-label,
.dialogue-label,
.annotation-issue,
.subsection-title,
.field-label {
color: #1e293b;
font-size: 14px;
font-weight: 800;
}
.score-note,
.dialogue-text,
.annotation-rewrite,
.annotation-sop,
.mentor-text {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.score-value {
flex: 0 0 auto;
color: #0f172a;
font-size: 20px;
font-weight: 900;
}
.dialogue-audio-block {
margin-top: 10px;
}
.dialogue-audio {
display: block;
width: 100%;
}
.audio-button {
width: 104px;
min-height: 36px;
font-size: 13px;
}
.audio-error {
display: block;
margin-top: 6px;
color: #dc2626;
font-size: 12px;
}
.mentor-block {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.field-label {
margin-top: 16px;
}
.review-textarea,
.point-input,
.scenario-row,
.next-reason-input,
.submit-button {
margin-top: 9px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.scenario-chip {
border-radius: 8px;
}
.scenario-chip.active {
background: #fee2e2;
color: #b91c1c;
}
.next-training-section {
margin-top: 16px;
padding-top: 2px;
border-top: 1px solid #e2e8f0;
}
.submit-button,
.back-list-button {
width: 100%;
}
.empty-panel {
padding-bottom: 18px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<view class="page reviews-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回主管首页" title="返回主管首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">待复盘</text>
<text class="page-subtitle">{{ reviewCountText }}</text>
</view>
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看待复盘对练。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-else class="panel review-panel">
<view class="section-head">
<text class="panel-title">复盘队列</text>
<text class="muted">低分优先</text>
</view>
<view v-if="reviews.length" class="review-list">
<button
v-for="item in reviews"
:key="item.id || item.sessionId || item.time"
class="review-row"
@click="openReview(item)"
>
<view class="review-copy">
<text class="review-title">{{ item.trainee }} · {{ item.scene }}</text>
<text class="review-desc">{{ item.time }} · {{ item.summary }}</text>
<text class="review-status">{{ item.status }}</text>
</view>
<view class="review-score">
<text>{{ item.score }}</text>
<text>查看</text>
</view>
</button>
</view>
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeRecord } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/reviews/index';
const reviews = ref<PracticeRecord[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const pendingReviewTotal = computed(() =>
buildSupervisorMetrics(reviews.value, [], alerts.value)
.find((item) => item.key === 'pendingReviews')?.value || 0);
const reviewCountText = computed(() =>
pendingReviewTotal.value > reviews.value.length
? `${reviews.value.length}/${pendingReviewTotal.value} 条`
: `${reviews.value.length} 条`);
const clearData = () => {
reviews.value = [];
alerts.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearData();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const [nextReviews, nextAlerts] = await Promise.all([
getPracticeReviews(),
getPracticeAlerts()
]);
accessDenied.value = false;
reviews.value = nextReviews;
alerts.value = nextAlerts;
} catch (error) {
handleRequestError(error, '待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const openReview = (item: PracticeRecord) => {
if (!item.id) {
message.value = '该记录缺少复盘ID';
return;
}
uni.navigateTo({ url: `/pages/supervisor/review/index?id=${item.id}` });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.reviews-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-panel,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.review-list {
margin-top: 8px;
}
.review-row {
display: flex;
width: 100%;
min-height: 88px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-radius: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
text-align: left;
}
.review-row:last-child {
border-bottom: 0;
}
.review-copy {
min-width: 0;
flex: 1;
}
.review-title,
.review-desc,
.review-status {
display: block;
}
.review-title {
color: #0f172a;
font-size: 15px;
font-weight: 800;
}
.review-desc {
margin-top: 5px;
overflow: hidden;
color: #64748b;
font-size: 12px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.review-status {
margin-top: 6px;
color: #c2410c;
font-size: 12px;
font-weight: 800;
}
.review-score {
display: flex;
min-width: 52px;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #64748b;
font-size: 12px;
}
.review-score text:first-child {
color: #dc2626;
font-size: 24px;
font-weight: 900;
}
</style>
@@ -0,0 +1,366 @@
<template>
<view class="page team-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回主管首页" title="返回主管首页" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">团队画像</text>
<text class="page-subtitle">{{ teamProfiles.length }} 名成员</text>
</view>
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
{{ loading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看团队画像。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="risk-summary">
<view class="risk-item high">
<text class="risk-value">{{ riskCounts.high }}</text>
<text class="risk-label">高风险</text>
</view>
<view class="risk-item medium">
<text class="risk-value">{{ riskCounts.medium }}</text>
<text class="risk-label">待复盘</text>
</view>
<view class="risk-item low">
<text class="risk-value">{{ riskCounts.low }}</text>
<text class="risk-label">正常</text>
</view>
</view>
<view class="panel team-panel">
<view class="section-head">
<text class="panel-title">成员列表</text>
<text class="muted">按风险排序</text>
</view>
<view v-if="teamProfiles.length" class="team-list">
<view v-for="member in teamProfiles" :key="member.key" class="team-row" :class="member.risk">
<view class="team-main">
<view class="team-title-row">
<text class="team-name">{{ member.displayName }}</text>
<text class="team-score">{{ member.latestScoreText }}</text>
</view>
<text class="team-desc">{{ member.reason }}</text>
<view class="team-tags">
<text class="team-tag">待复盘 {{ member.pendingReview }}</text>
<text class="team-tag">待训练 {{ member.pendingAssignment }}</text>
<text v-if="member.latestScene" class="team-tag">{{ member.latestScene }}</text>
</view>
</view>
<button class="secondary-button team-action" @click="assignMember(member)">派专项</button>
</view>
</view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment, PracticeRecord, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeTeamSnapshot } from '@/services/practice';
import {
buildTeamProfiles,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
type TeamProfile
} from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/team/index';
const records = ref<PracticeRecord[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const message = ref('');
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
const riskCounts = computed(() => teamProfiles.value.reduce(
(counts, item) => {
counts[item.risk] += 1;
return counts;
},
{ high: 0, medium: 0, low: 0 }
));
const clearData = () => {
records.value = [];
assignments.value = [];
members.value = [];
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearData();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadPage = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearData();
return;
}
if (loading.value) return;
loading.value = true;
try {
const snapshot = await getPracticeTeamSnapshot();
accessDenied.value = false;
records.value = snapshot.records;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
} catch (error) {
handleRequestError(error, '团队画像加载失败');
} finally {
loading.value = false;
}
};
const assignMember = (member: TeamProfile) => {
const scenarioId = inferSupervisorScenarioId(`${member.latestScene} ${member.reason}`);
const query = [
`extPartyId=${encodeURIComponent(member.extPartyId)}`,
`name=${encodeURIComponent(member.name)}`,
`scenarioId=${encodeURIComponent(scenarioId)}`,
`reason=${encodeURIComponent(member.reason)}`
].join('&');
uni.navigateTo({ url: `/pages/supervisor/assign/index?${query}` });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onShow(() => {
resetPageScroll();
void loadPage();
});
</script>
<style scoped>
.team-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.subpage-header {
align-items: center;
}
.login-button {
min-width: 104px;
}
.refresh-button {
min-width: 78px;
padding: 0 10px;
font-size: 13px;
}
.login-panel,
.section-head,
.team-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.risk-summary {
display: grid;
margin-top: 16px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
grid-template-columns: repeat(3, 1fr);
}
.risk-item {
padding: 13px 8px;
border-right: 1px solid #e2e8f0;
text-align: center;
}
.risk-item:last-child {
border-right: 0;
}
.risk-value,
.risk-label {
display: block;
}
.risk-value {
color: #0f172a;
font-size: 23px;
font-weight: 900;
}
.risk-label {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.risk-item.high .risk-value {
color: #b91c1c;
}
.risk-item.medium .risk-value {
color: #c2410c;
}
.risk-item.low .risk-value {
color: #15803d;
}
.team-list {
margin-top: 8px;
}
.team-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 0 14px 12px;
border-bottom: 1px solid #e2e8f0;
border-left: 4px solid #22c55e;
}
.team-row:last-child {
border-bottom: 0;
}
.team-row.high {
border-left-color: #dc2626;
}
.team-row.medium {
border-left-color: #f59e0b;
}
.team-main {
min-width: 0;
flex: 1;
}
.team-name {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 15px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-score {
flex: 0 0 auto;
color: #1d4ed8;
font-size: 14px;
font-weight: 900;
}
.team-desc {
display: block;
margin-top: 6px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.team-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.team-tag {
padding: 4px 7px;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.team-action {
min-width: 72px;
min-height: 38px;
flex: 0 0 auto;
padding: 0 10px;
font-size: 12px;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
export const candidateRoutes = {
index: '/pages/candidate/index/index',
interview: '/pages/candidate/interview/index',
materials: '/pages/candidate/materials/index',
progress: '/pages/candidate/progress/index',
study: '/pages/candidate/study/index'
} as const;
export type CandidateStage = 'interview' | 'materials' | 'review' | 'complete';
export interface CandidateFlow {
stage: CandidateStage;
badge: string;
title: string;
desc: string;
progress: number;
progressText: string;
notice: string;
cta: string;
route: string;
}
export interface CandidateStep {
index: number;
title: string;
desc: string;
status: 'complete' | 'current' | 'pending';
}
export const buildCandidateFlow = (
records: InterviewRecord[],
materials: CandidateMaterial[]
): CandidateFlow => {
if (!records.length) {
return {
stage: 'interview',
badge: '待面试',
title: '完成结构化面试',
desc: '按岗位题目完成回答,提交后生成正式面试记录。',
progress: 0,
progressText: '0/2',
notice: '待完成结构化面试',
cta: '开始面试',
route: candidateRoutes.interview
};
}
const materialStatus = materials[0]?.status || '';
if (!materials.length || materialStatus === '已驳回') {
const rejected = materialStatus === '已驳回';
return {
stage: 'materials',
badge: rejected ? '资料被驳回' : '待补资料',
title: rejected ? '重新提交资料' : '上传补充资料',
desc: rejected ? '请按审核结果补充或替换资料。' : '上传简历、证书或相关图片,供 HR 审核。',
progress: 50,
progressText: '1/2',
notice: rejected ? '资料被驳回,请重新上传' : '面试已完成,请上传补充资料',
cta: rejected ? '重新上传' : '上传资料',
route: candidateRoutes.materials
};
}
if (materialStatus === '已通过') {
return {
stage: 'complete',
badge: '资料已通过',
title: '等待后续通知',
desc: '面试和补充资料已完成,请留意 HR 后续通知。',
progress: 100,
progressText: '2/2',
notice: '资料已通过,等待后续通知',
cta: '查看进度',
route: candidateRoutes.progress
};
}
return {
stage: 'review',
badge: '审核中',
title: '资料审核中',
desc: '资料已提交,审核结果会同步到进度页。',
progress: 75,
progressText: '1/2',
notice: '资料已提交,等待 HR 审核',
cta: '查看进度',
route: candidateRoutes.progress
};
};
export const buildCandidateSteps = (flow: CandidateFlow): CandidateStep[] => {
const interviewComplete = flow.stage !== 'interview';
const materialsComplete = flow.stage === 'complete';
const materialsCurrent = flow.stage === 'materials' || flow.stage === 'review';
return [
{
index: 1,
title: '完成面试',
desc: '按岗位题目作答并生成面试记录',
status: interviewComplete ? 'complete' : 'current'
},
{
index: 2,
title: '提交资料',
desc: '上传简历、证书或相关图片',
status: materialsComplete ? 'complete' : materialsCurrent ? 'current' : 'pending'
},
{
index: 3,
title: '等待通知',
desc: 'HR 审核后同步展示最新状态',
status: materialsComplete ? 'current' : 'pending'
}
];
};
export const formatCandidateFileSize = (size: number) => {
if (!size) return '0KB';
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
export const materialStatusTone = (status: string) => {
if (status === '已通过') return 'passed';
if (status === '已驳回') return 'rejected';
return 'pending';
};
+8
View File
@@ -2,6 +2,7 @@ import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeRecord,
PracticeTeamSnapshot,
PracticeStartResponse,
PracticeTurnResponse,
CompetencyProfile,
@@ -105,6 +106,13 @@ export const getPracticeReviews = () =>
timeout: 15000
});
export const getPracticeTeamSnapshot = () =>
apiRequest<PracticeTeamSnapshot>({
url: '/api/aihr/mobile/practice/team',
method: 'GET',
timeout: 15000
});
export const getPracticeReviewDetail = (id: number) =>
apiRequest<ReviewDetail>({
url: `/api/aihr/mobile/practice/reviews/${id}`,
+284
View File
@@ -0,0 +1,284 @@
import type {
PracticeAlert,
PracticeAssignment,
PracticeRecord,
PracticeTeamMember
} from '@/types/api';
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
export interface SupervisorMetric {
key: SupervisorMetricKey;
title: string;
value: number;
desc: string;
primary?: boolean;
}
export interface TeamProfile {
key: string;
extPartyId: string;
name: string;
displayName: string;
latestScore?: number;
latestScoreText: string;
latestScene: string;
lastPracticeAt: string;
pendingReview: number;
pendingAssignment: number;
reason: string;
risk: 'high' | 'medium' | 'low';
}
export const supervisorScenarioOptions = [
{ id: 'fee-parking', name: '催费沟通' },
{ id: 'complaint-water', name: '投诉接待' },
{ id: 'repair-elevator-delay', name: '电梯故障' }
] as const;
export type SupervisorScenarioId = typeof supervisorScenarioOptions[number]['id'];
export interface AsyncGenerationGuard {
capture: () => number;
begin: () => number;
invalidate: () => void;
isCurrent: (candidate: number) => boolean;
dispose: () => void;
}
export const createAsyncGenerationGuard = (): AsyncGenerationGuard => {
let generation = 0;
let disposed = false;
return {
capture: () => generation,
begin: () => ++generation,
invalidate: () => {
generation += 1;
},
isCurrent: (candidate) => !disposed && candidate === generation,
dispose: () => {
disposed = true;
generation += 1;
}
};
};
export const inferSupervisorScenarioId = (text: string): SupervisorScenarioId => {
if (/电梯/.test(text)) return 'repair-elevator-delay';
if (/投诉|报修|漏水|噪音|摔倒/.test(text)) return 'complaint-water';
return 'fee-parking';
};
interface TeamProfileDraft {
key: string;
extPartyId: string;
name: string;
hasReadableName: boolean;
latestScene: string;
latestPractice?: {
score: number;
scene: string;
time: string;
timeValue: number;
};
pendingLowScore?: number;
pendingReview: number;
pendingAssignment: number;
}
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
assignments.filter((item) => item.source !== 'daily');
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable');
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error
? error.message
: typeof error === 'string' ? error : '';
return message.includes('无主管权限');
};
export const reviewCompletionAction = (previousRoute: string | undefined): 'back' | 'replace' =>
previousRoute?.replace(/^\/+/, '') === 'pages/supervisor/reviews/index' ? 'back' : 'replace';
const alertCount = (alerts: PracticeAlert[], type: string) => {
const match = alerts.find((item) => item.type === type)?.desc.match(/\d+/);
return match ? Number(match[0]) : 0;
};
export const buildSupervisorMetrics = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
alerts: PracticeAlert[]
): SupervisorMetric[] => {
const pendingReviewAlertCount = alertCount(alerts, 'pending_review');
const pendingReviews = pendingReviewAlertCount
|| reviews.filter((item) => item.status === '待复盘').length;
const pendingAssignments = manualPracticeAssignments(assignments)
.filter((item) => item.status !== '已完成').length;
const lowScores = alertCount(alerts, 'low_score');
const actionableAlertCount = actionablePracticeAlerts(alerts).length;
return [
{
key: 'pendingReviews',
title: '当前待复盘',
value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true
},
{
key: 'pendingAssignments',
title: '当前待训练',
value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
},
{
key: 'lowScores',
title: '低分预警',
value: lowScores,
desc: lowScores ? '训练低于 80 分' : '暂无低分预警'
},
{
key: 'alerts',
title: '团队预警',
value: actionableAlertCount,
desc: actionableAlertCount ? '请及时跟进风险项' : '暂无团队预警'
}
];
};
export const buildTeamProfiles = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
members: PracticeTeamMember[] = []
): TeamProfile[] => {
const rows = new Map<string, TeamProfileDraft>();
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
const extPartyId = rawExtPartyId?.trim() || '';
const candidateName = rawName.trim();
const hasReadableName = candidateName.length > 0 && !/^[??]{3,}/.test(candidateName);
const key = rawKey?.trim() || extPartyId || candidateName || 'unknown';
const identity = extPartyId || key;
const name = hasReadableName ? candidateName : `员工 ${identitySuffix(identity)}`;
const current = rows.get(key);
if (current) {
if (hasReadableName && !current.hasReadableName) {
current.name = candidateName;
current.hasReadableName = true;
}
current.extPartyId = current.extPartyId || extPartyId || key;
return current;
}
const next: TeamProfileDraft = {
key,
extPartyId: extPartyId || key,
name,
hasReadableName,
latestScene: '',
pendingReview: 0,
pendingAssignment: 0
};
rows.set(key, next);
return next;
};
members.forEach((item) => {
ensure(item.extPartyId, item.name, item.extPartyId);
});
reviews.forEach((item) => {
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
if (isReviewEditable(item.status)) {
row.pendingReview += 1;
if (typeof item.score === 'number' && Number.isFinite(item.score) && item.score < 80) {
row.pendingLowScore = Math.min(row.pendingLowScore ?? item.score, item.score);
}
}
if (typeof item.score === 'number' && Number.isFinite(item.score)) {
const timeValue = practiceTimeValue(item.time);
if (!row.latestPractice || timeValue > row.latestPractice.timeValue) {
row.latestPractice = {
score: item.score,
scene: item.scene.trim(),
time: item.time,
timeValue
};
}
}
});
manualPracticeAssignments(assignments).forEach((item) => {
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
if (item.status !== '已完成') row.pendingAssignment += 1;
row.latestScene ||= item.scenarioName.trim();
});
const nameCounts = Array.from(rows.values()).reduce<Record<string, number>>((counts, row) => {
counts[row.name] = (counts[row.name] || 0) + 1;
return counts;
}, {});
const riskRank: Record<TeamProfile['risk'], number> = { high: 0, medium: 1, low: 2 };
return Array.from(rows.values())
.map<TeamProfile>((row) => {
const latestScore = row.latestPractice?.score;
const latestScene = row.latestPractice?.scene || row.latestScene;
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined
? 'high'
: row.pendingReview > 0
? 'medium'
: 'low';
const reason = risk === 'high'
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练`
: risk === 'medium'
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项`
: row.pendingAssignment > 0
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成`
: `${row.name} 当前无待处理训练,可按需派发专项`;
return {
key: row.key,
extPartyId: row.extPartyId,
name: row.name,
displayName: nameCounts[row.name] > 1
? `${row.name} · ${identitySuffix(row.extPartyId)}`
: row.name,
latestScore,
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
latestScene,
lastPracticeAt: row.latestPractice?.time || '',
pendingReview: row.pendingReview,
pendingAssignment: row.pendingAssignment,
reason,
risk
};
})
.sort((a, b) =>
riskRank[a.risk] - riskRank[b.risk]
|| b.pendingReview - a.pendingReview
|| b.pendingAssignment - a.pendingAssignment
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
};
function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`;
return value;
}
function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
return Number.NEGATIVE_INFINITY;
}
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
while (normalized.length < 6) normalized.push(0);
const [year, month, day, hour, minute, second] = normalized;
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
}
+25
View File
@@ -183,6 +183,31 @@ button::after {
line-height: 1.5;
}
.supervisor-access-panel {
border-left: 4px solid #e60012;
}
.supervisor-access-copy {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.6;
}
.supervisor-access-actions {
display: grid;
margin-top: 18px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.supervisor-access-actions button {
width: 100%;
min-width: 0;
padding: 0 10px;
font-size: 14px;
}
.empty {
padding: 24px 12px;
color: #64748b;
+11
View File
@@ -186,6 +186,17 @@ export interface PracticeRecord {
summary: string;
}
export interface PracticeTeamMember {
extPartyId: string;
name: string;
}
export interface PracticeTeamSnapshot {
members: PracticeTeamMember[];
records: PracticeRecord[];
assignments: PracticeAssignment[];
}
export interface ReviewDialogue {
role: string;
label: string;
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test, { after } from 'node:test';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const vite = await createServer({
root: fileURLToPath(new URL('..', import.meta.url)),
configFile: false,
optimizeDeps: { noDiscovery: true },
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
after(async () => {
await vite.close();
});
const candidateView = await vite.ssrLoadModule('/src/services/candidate-view.ts');
const { buildCandidateFlow, candidateRoutes, formatCandidateFileSize, materialStatusTone } = candidateView;
const record = (overrides = {}) => ({
time: '2026-07-10 10:00:00',
candidate: '候选人0001',
position: '生活顾问',
score: 86,
result: '建议进入下一轮',
summary: '沟通完整',
...overrides
});
const material = (overrides = {}) => ({
id: 1,
ossId: 2,
fileName: '简历.pdf',
materialType: '补充资料',
fileSize: 2048,
status: '待审核',
time: '2026-07-10 10:30:00',
...overrides
});
const pageSource = async (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('候选流程从面试、补资料、审核到完成只依赖真实记录', () => {
assert.deepEqual(buildCandidateFlow([], []), {
stage: 'interview',
badge: '待面试',
title: '完成结构化面试',
desc: '按岗位题目完成回答,提交后生成正式面试记录。',
progress: 0,
progressText: '0/2',
notice: '待完成结构化面试',
cta: '开始面试',
route: candidateRoutes.interview
});
assert.equal(buildCandidateFlow([record()], []).stage, 'materials');
assert.equal(buildCandidateFlow([record()], []).route, candidateRoutes.materials);
assert.equal(buildCandidateFlow([record()], [material({ status: '已驳回' })]).badge, '资料被驳回');
assert.equal(buildCandidateFlow([record()], [material()]).stage, 'review');
assert.equal(buildCandidateFlow([record()], [material()]).route, candidateRoutes.progress);
assert.equal(buildCandidateFlow([record()], [material({ status: '已通过' })]).stage, 'complete');
assert.equal(buildCandidateFlow([record()], [material({ status: '已通过' })]).progress, 100);
});
test('资料展示格式保持三态且文件大小可读', () => {
assert.equal(materialStatusTone('已通过'), 'passed');
assert.equal(materialStatusTone('已驳回'), 'rejected');
assert.equal(materialStatusTone('待审核'), 'pending');
assert.equal(formatCandidateFileSize(0), '0KB');
assert.equal(formatCandidateFileSize(2048), '2KB');
assert.equal(formatCandidateFileSize(1572864), '1.5MB');
});
test('候选首页只做状态总览和独立页面分流', async () => {
const source = await pageSource('../src/pages/candidate/index/index.vue');
assert.match(source, /getCandidateInterviewRecords/);
assert.match(source, /getCandidateMaterials/);
Object.values(candidateRoutes).forEach((route) => assert.match(source, new RegExp(route.replaceAll('/', '\\/'))));
assert.doesNotMatch(source, /startCandidateInterview/);
assert.doesNotMatch(source, /uploadCandidateMaterial/);
assert.doesNotMatch(source, /searchKnowledge/);
assert.doesNotMatch(source, /interviewQuestions/);
});
test('面试、资料、进度和预习页各自只承载一类真实任务', async () => {
const [interview, materials, progress, study] = await Promise.all([
pageSource('../src/pages/candidate/interview/index.vue'),
pageSource('../src/pages/candidate/materials/index.vue'),
pageSource('../src/pages/candidate/progress/index.vue'),
pageSource('../src/pages/candidate/study/index.vue')
]);
assert.match(interview, /startCandidateInterview/);
assert.match(interview, /saveInterviewAnswer/);
assert.match(interview, /finishCandidateInterview/);
assert.doesNotMatch(interview, /uploadCandidateMaterial|searchKnowledge/);
assert.match(materials, /chooseCandidateFile/);
assert.match(materials, /uploadCandidateMaterial/);
assert.doesNotMatch(materials, /startCandidateInterview|searchKnowledge/);
assert.match(progress, /buildCandidateFlow/);
assert.match(progress, /getCandidateInterviewRecords/);
assert.match(progress, /getCandidateMaterials/);
assert.doesNotMatch(progress, /startCandidateInterview|uploadCandidateMaterial|searchKnowledge/);
assert.match(study, /searchKnowledge/);
assert.doesNotMatch(study, /startCandidateInterview|uploadCandidateMaterial/);
});
test('五个候选页面登录都使用 redirectTo 并保留当前任务页', async () => {
const sources = await Promise.all([
pageSource('../src/pages/candidate/index/index.vue'),
pageSource('../src/pages/candidate/interview/index.vue'),
pageSource('../src/pages/candidate/materials/index.vue'),
pageSource('../src/pages/candidate/progress/index.vue'),
pageSource('../src/pages/candidate/study/index.vue')
]);
sources.forEach((source) => {
const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || '';
assert.match(goLogin, /rememberLoginRedirect\(currentPath\)/);
assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/);
assert.doesNotMatch(goLogin, /uni\.navigateTo\(/);
});
});
test('候选子路由已注册为真实页面', async () => {
const pages = await pageSource('../src/pages.json');
assert.match(pages, /pages\/candidate\/interview\/index/);
assert.match(pages, /pages\/candidate\/materials\/index/);
assert.match(pages, /pages\/candidate\/progress\/index/);
assert.match(pages, /pages\/candidate\/study\/index/);
});
+367
View File
@@ -0,0 +1,367 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test, { after } from 'node:test';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const vite = await createServer({
root: fileURLToPath(new URL('..', import.meta.url)),
configFile: false,
optimizeDeps: { noDiscovery: true },
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
after(async () => {
await vite.close();
});
const supervisorView = await vite.ssrLoadModule('/src/services/supervisor-view.ts');
const {
actionablePracticeAlerts,
buildSupervisorMetrics,
buildTeamProfiles,
createAsyncGenerationGuard,
inferSupervisorScenarioId,
isReviewEditable,
isSupervisorAccessDenied,
reviewCompletionAction,
supervisorScenarioOptions
} = supervisorView;
const assignPageSource = await readFile(
new URL('../src/pages/supervisor/assign/index.vue', import.meta.url),
'utf8'
);
const reviewPageSource = await readFile(
new URL('../src/pages/supervisor/review/index.vue', import.meta.url),
'utf8'
);
const dashboardPageSource = await readFile(
new URL('../src/pages/supervisor/index/index.vue', import.meta.url),
'utf8'
);
const teamPageSource = await readFile(
new URL('../src/pages/supervisor/team/index.vue', import.meta.url),
'utf8'
);
const reviewsPageSource = await readFile(
new URL('../src/pages/supervisor/reviews/index.vue', import.meta.url),
'utf8'
);
const practiceServiceSource = await readFile(
new URL('../src/services/practice.ts', import.meta.url),
'utf8'
);
const review = (overrides = {}) => ({
id: 1,
extPartyId: '13800138000',
time: '2026-07-10 09:00:00',
trainee: '张三',
scene: '投诉接待',
score: 86,
status: '待复盘',
summary: '训练摘要',
...overrides
});
const assignment = (overrides = {}) => ({
id: 1,
extPartyId: '13800138000',
traineeName: '张三',
scenarioId: 'complaint-water',
scenarioName: '投诉接待',
source: 'manual',
reason: '主管指派',
status: '待训练',
createTime: '2026-07-10 09:00:00',
...overrides
});
const alert = (overrides = {}) => ({
type: 'low_score',
title: '低分训练预警',
desc: '0 条训练低于 80 分',
severity: 'high',
...overrides
});
test('daily 任务不计入主管待训练指标,也不创建团队成员', () => {
const assignments = [
assignment({ id: 1, extPartyId: 'daily-user', traineeName: '每日任务员工', source: 'daily' }),
assignment({ id: 2, extPartyId: 'manual-user', traineeName: '专项员工' }),
assignment({ id: 3, extPartyId: 'done-user', traineeName: '已完成员工', status: '已完成' })
];
const metrics = buildSupervisorMetrics([], assignments, []);
const profiles = buildTeamProfiles([], assignments);
assert.equal(metrics.find((item) => item.key === 'pendingAssignments')?.value, 1);
assert.deepEqual(profiles.map((item) => item.extPartyId).sort(), ['done-user', 'manual-user']);
});
test('低分指标只取 alerts 的后端聚合值', () => {
const metrics = buildSupervisorMetrics(
[review({ score: 32 })],
[],
[alert({ desc: '7 条训练低于 80 分,建议派发定向再练' })]
);
assert.equal(metrics.find((item) => item.key === 'lowScores')?.value, 7);
assert.equal(buildSupervisorMetrics([review({ score: 32 })], [], [])
.find((item) => item.key === 'lowScores')?.value, 0);
});
test('按 extPartyId 聚合复盘和专项,并区分同名成员', () => {
const profiles = buildTeamProfiles(
[
review({ id: 1, extPartyId: '13800138000', trainee: '张三', score: 76 }),
review({ id: 2, extPartyId: '13800138000', trainee: '张三', score: 91, status: '已复盘' })
],
[
assignment({ id: 1, extPartyId: '13800138000', traineeName: '张三' }),
assignment({ id: 2, extPartyId: '13900139000', traineeName: '张三', status: '已完成' })
]
);
assert.equal(profiles.length, 2);
assert.deepEqual(
profiles.map((item) => item.displayName).sort(),
['张三 · 尾号8000', '张三 · 尾号9000'].sort()
);
const first = profiles.find((item) => item.extPartyId === '13800138000');
assert.equal(first?.pendingReview, 1);
assert.equal(first?.pendingAssignment, 1);
assert.equal(first?.latestScore, 76);
assert.equal(first?.latestScene, '投诉接待');
});
test('低于 80 分为高风险,有待复盘为中风险,其余为低风险', () => {
const profiles = buildTeamProfiles(
[
review({ id: 1, extPartyId: 'high', trainee: '高风险', score: 79 }),
review({ id: 2, extPartyId: 'medium', trainee: '中风险', score: 88 }),
review({ id: 3, extPartyId: 'low', trainee: '低风险', score: 95, status: '已复盘' })
],
[]
);
assert.equal(profiles.find((item) => item.extPartyId === 'high')?.risk, 'high');
assert.equal(profiles.find((item) => item.extPartyId === 'medium')?.risk, 'medium');
assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low');
});
test('主管页面共享三个业务场景及文本推荐规则', () => {
assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [
'fee-parking',
'complaint-water',
'repair-elevator-delay'
]);
assert.equal(inferSupervisorScenarioId('电梯故障沟通低分'), 'repair-elevator-delay');
assert.equal(inferSupervisorScenarioId('投诉接待需要补练'), 'complaint-water');
assert.equal(inferSupervisorScenarioId('催缴物业费'), 'fee-parking');
});
test('stable 提示不计入团队预警指标', () => {
const alerts = [
alert({ type: 'stable', title: '团队状态稳定', desc: '当前无新增风险', severity: 'low' }),
alert({ type: 'pending_review', title: '待复盘积压', desc: '2 条对练等待主管复盘', severity: 'medium' })
];
const metrics = buildSupervisorMetrics([], [], alerts);
const alertMetric = metrics.find((item) => item.key === 'alerts');
assert.equal(alertMetric?.value, 1);
assert.equal(alertMetric?.desc, '请及时跟进风险项');
assert.deepEqual(actionablePracticeAlerts(alerts).map((item) => item.type), ['pending_review']);
assert.deepEqual(alerts.map((item) => item.type), ['stable', 'pending_review']);
assert.equal(buildSupervisorMetrics([], [], [
alert({ type: 'stable', title: '团队状态稳定', desc: '当前无新增风险', severity: 'low' })
]).find((item) => item.key === 'alerts')?.value, 0);
});
test('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => {
const profiles = buildTeamProfiles([
review({
id: 1,
time: '2026-07-08 09:00:00',
scene: '旧低分场景',
score: 62,
status: '待复盘'
}),
review({
id: 2,
time: '2026-07-10 10:30:00',
scene: '新高分场景',
score: 93,
status: '已复盘'
})
], []);
const profile = profiles[0];
assert.equal(profile.latestScore, 93);
assert.equal(profile.latestScoreText, '93分');
assert.equal(profile.latestScene, '新高分场景');
assert.equal(profile.lastPracticeAt, '2026-07-10 10:30:00');
assert.equal(profile.risk, 'high');
assert.match(profile.reason, /未复盘低分/);
});
test('组织快照中的稳定成员即使没有训练记录也保留在画像中', () => {
const profiles = buildTeamProfiles([], [], [
{ extPartyId: 'stable-1', name: '稳定员工' }
]);
assert.equal(profiles.length, 1);
assert.equal(profiles[0].extPartyId, 'stable-1');
assert.equal(profiles[0].latestScoreText, '暂无评分');
assert.equal(profiles[0].risk, 'low');
});
test('组织快照姓名不可读时用稳定身份标识展示', () => {
const profiles = buildTeamProfiles([], [], [
{ extPartyId: '13900001111', name: '???????' },
{ extPartyId: 'employee-project-A-000123', name: '????????A_000123' }
]);
assert.deepEqual(
profiles.map((item) => item.displayName).sort(),
['员工 尾号1111', '员工 末尾000123'].sort()
);
assert.equal(profiles.every((item) => !item.name.includes('?')), true);
assert.equal(profiles.every((item) => !item.reason.includes('?')), true);
});
test('只有待复盘可编辑,成功返回仅在上一页为 reviews 时后退', () => {
assert.equal(isReviewEditable('待复盘'), true);
assert.equal(isReviewEditable('已复盘'), false);
assert.equal(reviewCompletionAction('pages/supervisor/reviews/index'), 'back');
assert.equal(reviewCompletionAction('/pages/supervisor/reviews/index'), 'back');
assert.equal(reviewCompletionAction('pages/supervisor/index/index'), 'replace');
assert.equal(reviewCompletionAction(undefined), 'replace');
});
test('异步代次守卫拒绝刷新后的旧结果和组件销毁后的结果', () => {
const guard = createAsyncGenerationGuard();
const audioGeneration = guard.capture();
assert.equal(guard.isCurrent(audioGeneration), true);
guard.invalidate();
assert.equal(guard.isCurrent(audioGeneration), false);
const historyGeneration = guard.begin();
assert.equal(guard.isCurrent(historyGeneration), true);
guard.dispose();
assert.equal(guard.isCurrent(historyGeneration), false);
});
test('只把主管权限错误识别为访问拒绝', () => {
assert.equal(isSupervisorAccessDenied(new Error('无主管权限,无法访问主管功能')), true);
assert.equal(isSupervisorAccessDenied('当前账号无主管权限'), true);
assert.equal(isSupervisorAccessDenied(new Error('登录已过期,请重新登录')), false);
assert.equal(isSupervisorAccessDenied(new Error('网络请求失败')), false);
assert.equal(isSupervisorAccessDenied({ message: '无主管权限' }), false);
assert.equal(isSupervisorAccessDenied(null), false);
});
test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
assert.match(reviewPageSource, /const editable = computed\(\(\) => isReviewEditable\(detail\.value\?\.status\)\)/);
assert.match(reviewPageSource, /v-if="editable" class="panel review-form"/);
assert.match(reviewPageSource, /v-else class="panel review-complete"/);
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/);
assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/);
});
test('复盘详情登录和完成导航不堆叠页面', () => {
assert.match(reviewPageSource, /rememberLoginRedirect\(currentPath\(\)\);\s*uni\.redirectTo/);
assert.match(reviewPageSource, /reviewCompletionAction\(previousRoute\)/);
assert.match(reviewPageSource, /uni\.navigateBack\(\{ delta: 1, fail: goToReviews \}\)/);
assert.match(reviewPageSource, /const goToReviews = \(\) => \{\s*uni\.redirectTo/);
});
test('复盘音频晚到结果被释放且不会写入页面状态', () => {
assert.match(reviewPageSource, /const audioRequests = createAsyncGenerationGuard\(\)/);
assert.match(reviewPageSource, /const generation = audioRequests\.capture\(\)/);
assert.match(reviewPageSource, /if \(!audioRequests\.isCurrent\(generation\)\) \{\s*releaseAuthenticatedFileUrl\(source\);\s*return;/);
assert.match(reviewPageSource, /audioRequests\.dispose\(\)/);
});
test('指派页登录失效清空私密状态且登录跳转不携带草稿', () => {
assert.match(assignPageSource, /const clearPrivateState = \(\) => \{[\s\S]*resetDraft\(\);[\s\S]*assignments\.value = \[\]/);
assert.match(assignPageSource, /if \(!loggedIn\.value\) return '选择员工并配置训练任务'/);
assert.match(assignPageSource, /rememberLoginRedirect\(basePath\);\s*uni\.redirectTo/);
assert.doesNotMatch(assignPageSource, /const loginRedirect =/);
});
test('指派中锁定返回和刷新,写入成功后强制启动新历史读取', () => {
assert.match(assignPageSource, /class="subpage-back-button"[\s\S]*:disabled="assigning"/);
assert.match(assignPageSource, /:disabled="historyLoading \|\| assigning"\s*@click="loadAssignments\(\)"/);
assert.match(assignPageSource, /const loadAssignments = async \(force = false\)/);
assert.match(assignPageSource, /const generation = historyRequests\.begin\(\)/);
assert.match(assignPageSource, /await loadAssignments\(true\)/);
assert.match(assignPageSource, /const goBack = \(\) => \{\s*if \(assigning\.value\) return;/);
});
test('指派页销毁时废止历史请求,晚到结果不能写回', () => {
assert.match(assignPageSource, /import \{ computed, onBeforeUnmount, ref \} from 'vue'/);
assert.match(assignPageSource, /onBeforeUnmount\(\(\) => \{\s*historyRequests\.dispose\(\);\s*\}\)/);
});
test('五个主管页面优先显示统一无权限状态并清理私密数据', () => {
const pages = [
{ source: dashboardPageSource, clear: 'clearData' },
{ source: teamPageSource, clear: 'clearData' },
{ source: assignPageSource, clear: 'clearPrivateState' },
{ source: reviewsPageSource, clear: 'clearData' },
{ source: reviewPageSource, clear: 'clearDetail' }
];
pages.forEach(({ source, clear }) => {
assert.match(source, /const accessDenied = ref\(false\)/);
assert.match(source, /<view v-if="accessDenied" class="panel supervisor-access-panel">[\s\S]*无主管权限[\s\S]*当前账号不是主管或项目经理,无法查看团队训练数据。[\s\S]*@click="goToEmployee"[\s\S]*返回员工端[\s\S]*@click="switchAccount"[\s\S]*切换账号[\s\S]*<\/view>\s*<view v-else-if="!loggedIn"/);
assert.match(source, new RegExp(`if \\(isSupervisorAccessDenied\\(error\\)\\) \\{[\\s\\S]*${clear}\\(\\);[\\s\\S]*accessDenied\\.value = true;[\\s\\S]*return;`));
assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;/);
assert.match(source, /accessDenied\.value = false;[\s\S]*(?:snapshot|nextReviews|detail\.value|nextAssignments)/);
assert.match(source, /const goToEmployee = \(\) => \{\s*uni\.switchTab\(\{ url: '\/pages\/user\/today\/index' \}\);\s*\};/);
assert.match(source, /const switchAccount = \(\) => \{\s*clearAuth\(\);\s*rememberLoginRedirect\([\s\S]*?\);\s*uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\);\s*\};/);
});
});
test('指派页通过团队快照在进入时校验主管权限', () => {
assert.match(assignPageSource, /getPracticeTeamSnapshot/);
assert.match(assignPageSource, /const snapshot = await getPracticeTeamSnapshot\(\)/);
assert.match(assignPageSource, /assignments\.value = snapshot\.assignments/);
assert.doesNotMatch(assignPageSource, /getPracticeAssignments/);
});
test('指派页保留 H5 查询参数二次解码', () => {
assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/);
});
test('主管 dashboard 和 team 使用团队快照,reviews 继续使用待复盘接口', () => {
assert.match(practiceServiceSource, /export const getPracticeTeamSnapshot = \(\) =>/);
assert.match(practiceServiceSource, /url: '\/api\/aihr\/mobile\/practice\/team'/);
assert.match(dashboardPageSource, /getPracticeTeamSnapshot/);
assert.match(dashboardPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value\)/);
assert.match(teamPageSource, /getPracticeTeamSnapshot/);
assert.match(teamPageSource, /buildTeamProfiles\(records\.value, assignments\.value, members\.value\)/);
assert.match(reviewsPageSource, /getPracticeReviews/);
assert.doesNotMatch(reviewsPageSource, /getPracticeTeamSnapshot/);
});
test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () => {
const pages = [
dashboardPageSource,
teamPageSource,
reviewsPageSource,
assignPageSource,
reviewPageSource
];
pages.forEach((source) => {
const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || '';
assert.match(goLogin, /rememberLoginRedirect\(/);
assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/);
assert.doesNotMatch(goLogin, /uni\.navigateTo\(/);
});
});
+183 -19
View File
@@ -47,7 +47,9 @@ check_mobile_h5_shell() {
}
pilot_sql_scalar() {
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D ry-vue -e "$1" 2>/dev/null
local database="${AIHR_PILOT_DB:-ry-vue}"
[[ "$database" =~ ^[A-Za-z0-9_-]+$ ]] || fail "invalid AIHR_PILOT_DB: $database"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D "$database" -e "$1" 2>/dev/null
}
check_pilot_samples() {
@@ -57,46 +59,196 @@ check_pilot_samples() {
return
fi
local scenario_count completed_people pilot_people calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending
if ! pilot_sql_scalar "SELECT 1" >/dev/null; then
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || fail "pilot DB sample counts unavailable (database connection failed)"
echo "SKIP: pilot DB sample counts (database connection failed)"
return
fi
if [[ "${AIHR_PILOT_STRICT:-false}" == "true" ]] && {
[[ -z "${AIHR_PILOT_START_DATE:-}" ]] || [[ -z "${AIHR_PILOT_END_DATE:-}" ]]
}; then
fail "strict pilot check requires AIHR_PILOT_START_DATE and AIHR_PILOT_END_DATE"
fi
local pilot_start="${AIHR_PILOT_START_DATE:-$(date +%F)}"
local pilot_end="${AIHR_PILOT_END_DATE:-$pilot_start}"
[[ "$pilot_start" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "invalid AIHR_PILOT_START_DATE: $pilot_start"
[[ "$pilot_end" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_start') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_START_DATE: $pilot_start"
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
local scenario_count completed_people pilot_people org_active org_phone_linked org_pilot_people org_pilot_projects org_completed_people
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending
local org_identity_cte="
WITH active_org AS (
SELECT DISTINCT ext_party_id, project_code, person_phone
FROM aihr_org_snapshot
WHERE tenant_id = '000000' AND employment_status = 'active'
AND person_phone IS NOT NULL AND person_phone <> ''
), all_org_keys AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM active_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM active_org
), conflict_keys AS (
SELECT identity_key
FROM all_org_keys
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) > 1
), unsafe_org AS (
SELECT DISTINCT k.ext_party_id
FROM all_org_keys k
JOIN conflict_keys c ON c.identity_key = k.identity_key
), safe_org AS (
SELECT o.ext_party_id, o.project_code, o.person_phone
FROM active_org o
LEFT JOIN unsafe_org u ON u.ext_party_id = o.ext_party_id
WHERE u.ext_party_id IS NULL
), org_key_candidates AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM safe_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM safe_org
), org_identity AS (
SELECT identity_key, MIN(ext_party_id) AS ext_party_id
FROM org_key_candidates
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) = 1
)"
scenario_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '000000' AND position = '生活顾问' AND enabled = 1")"
completed_people="$(pilot_sql_scalar "SELECT COUNT(*) FROM (SELECT DISTINCT ext_party_id FROM aihr_practice_session WHERE tenant_id = '000000' AND mode = 'mobile') completed_people")"
completed_people="$(pilot_sql_scalar "
SELECT COUNT(*)
FROM (
SELECT ext_party_id
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY ext_party_id
HAVING COUNT(*) >= 10
) completed_people")"
pilot_people="$(pilot_sql_scalar "
SELECT COUNT(*)
FROM (
SELECT ext_party_id
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND status = '待训练'
AND (source <> 'daily' OR DATE(create_time) = CURRENT_DATE())
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
) pilot_people")"
org_active="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_org_snapshot WHERE tenant_id = '000000' AND employment_status = 'active'")"
org_phone_linked="$(pilot_sql_scalar "$org_identity_cte
SELECT COUNT(DISTINCT ext_party_id)
FROM safe_org")"
org_pilot_people="$(pilot_sql_scalar "
$org_identity_cte,
pilot_identity AS (
SELECT ext_party_id identity_key
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id identity_key
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
)
SELECT COUNT(DISTINCT o.ext_party_id)
FROM pilot_identity p
JOIN org_identity o ON o.identity_key = p.identity_key")"
org_pilot_projects="$(pilot_sql_scalar "
$org_identity_cte,
pilot_identity AS (
SELECT ext_party_id identity_key
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id identity_key
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
), pilot_org AS (
SELECT DISTINCT o.ext_party_id
FROM pilot_identity p
JOIN org_identity o ON o.identity_key = p.identity_key
)
SELECT COUNT(DISTINCT o.project_code)
FROM pilot_org p
JOIN safe_org o ON o.ext_party_id = p.ext_party_id
WHERE o.project_code IS NOT NULL AND o.project_code <> ''")"
org_completed_people="$(pilot_sql_scalar "
$org_identity_cte,
session_identity AS (
SELECT ext_party_id AS identity_key, COUNT(*) AS session_count
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY ext_party_id
), completed_org AS (
SELECT o.ext_party_id
FROM session_identity s
JOIN org_identity o ON o.identity_key = s.identity_key
GROUP BY o.ext_party_id
HAVING SUM(s.session_count) >= 10
)
SELECT COUNT(*)
FROM completed_org")"
calibration_stats="$(pilot_sql_scalar "
$org_identity_cte,
formal_sessions AS (
SELECT s.session_id
FROM aihr_practice_session s
JOIN org_identity o ON o.identity_key = s.ext_party_id
WHERE s.tenant_id = '000000' AND s.mode = 'mobile'
AND s.finished_time >= '${pilot_start} 00:00:00'
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
), latest_calibration AS (
SELECT c.session_id, MAX(c.id) AS id
FROM aihr_practice_calibration c
JOIN formal_sessions s ON s.session_id = c.session_id
WHERE c.tenant_id = '000000' AND c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
AND c.create_time >= '${pilot_start} 00:00:00'
AND c.create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY c.session_id
)
SELECT COUNT(*) AS total,
COALESCE(SUM(CASE
WHEN (CASE WHEN c.original_score < 60 THEN 0 WHEN c.original_score < 75 THEN 1 WHEN c.original_score < 90 THEN 2 ELSE 3 END)
= (CASE WHEN c.corrected_score < 60 THEN 0 WHEN c.corrected_score < 75 THEN 1 WHEN c.corrected_score < 90 THEN 2 ELSE 3 END)
THEN 1 ELSE 0 END), 0) AS matched
FROM aihr_practice_calibration c
JOIN (
SELECT session_id, MAX(id) AS id
FROM aihr_practice_calibration
WHERE tenant_id = '000000' AND original_score IS NOT NULL AND corrected_score IS NOT NULL
GROUP BY session_id
) latest ON latest.id = c.id
JOIN latest_calibration latest ON latest.id = c.id
WHERE c.tenant_id = '000000'")"
read -r calibration_count calibration_matched <<<"$calibration_stats"
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审'")"
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审'")"
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审'")"
echo "OK: pilot gates scenarios=${scenario_count}/12 completion=${completed_people}/${pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending}"
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 ten_sessions=${completed_people}/${pilot_people} org_login=${org_phone_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending}"
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
(( pilot_people > 0 )) || fail "pilot completion samples missing"
(( completed_people * 100 >= pilot_people * 80 )) || fail "pilot completion rate below 80% (${completed_people}/${pilot_people})"
(( org_phone_linked >= 20 )) || fail "pilot mobile-login org members ${org_phone_linked}/20 (active org ${org_active})"
(( org_pilot_people >= 20 )) || fail "pilot activity matched to active org ${org_pilot_people}/20"
(( org_pilot_projects >= 1 && org_pilot_projects <= 2 )) || fail "pilot project scope must be 1-2 projects (actual ${org_pilot_projects})"
(( org_completed_people * 100 >= org_pilot_people * 80 )) || fail "pilot org members with at least 10 completed sessions below 80% (${org_completed_people}/${org_pilot_people})"
(( calibration_count >= 20 )) || fail "pilot calibration samples ${calibration_count}/20"
(( calibration_matched * 100 >= calibration_count * 70 )) || fail "pilot AI/human band consistency below 70%"
(( sop_total > 0 )) || fail "pilot SOP review samples missing"
@@ -104,6 +256,10 @@ check_pilot_samples() {
}
}
if [[ "${AIHR_DEMO_CHECK_LIBRARY_ONLY:-false}" == "true" ]]; then
return 0 2>/dev/null || exit 0
fi
need curl
check_url "frontend" "$FRONTEND_URL/"
@@ -132,6 +288,10 @@ contains mobile-uni/src/pages.json "pages/user/today/index"
contains mobile-uni/src/pages.json "pages/user/learning/index"
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
contains mobile-uni/src/pages.json "pages/candidate/index/index"
contains mobile-uni/src/pages.json "pages/candidate/interview/index"
contains mobile-uni/src/pages.json "pages/candidate/materials/index"
contains mobile-uni/src/pages.json "pages/candidate/progress/index"
contains mobile-uni/src/pages.json "pages/candidate/study/index"
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
contains mobile-uni/src/pages/auth/login/index.vue "/auth/mobile/sms-login"
contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
@@ -145,6 +305,10 @@ contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains mobile-uni/src/pages/candidate/interview/index.vue "startCandidateInterview"
contains mobile-uni/src/pages/candidate/materials/index.vue "uploadCandidateMaterial"
contains mobile-uni/src/pages/candidate/progress/index.vue "buildCandidateFlow"
contains mobile-uni/src/pages/candidate/study/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments"
contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge"
@@ -186,9 +350,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains mobile-uni/src/pages/supervisor/index/index.vue "人工贡献分"
contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile-uni/src/pages/supervisor/index/index.vue "标记已复盘"
contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
+5 -3
View File
@@ -2,6 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/process-tree.sh"
PIDS=()
usage() {
@@ -20,7 +21,7 @@ EOF
cleanup() {
trap - INT TERM EXIT
for pid in "${PIDS[@]}"; do
kill "$pid" >/dev/null 2>&1 || true
stop_process_tree "$pid"
done
}
@@ -94,14 +95,15 @@ stop_portless_route() {
fi
echo "Stopping portless route $name: $pids"
kill $pids >/dev/null 2>&1 || true
for pid in $pids; do
stop_process_tree "$pid"
done
for _ in {1..20}; do
if ! portless list 2>/dev/null | grep -q "https://${name}[.]localhost"; then
return
fi
sleep 0.2
done
kill -9 $pids >/dev/null 2>&1 || true
}
stop_launchctl_job() {
+295
View File
@@ -0,0 +1,295 @@
const REQUIRED_METRICS = [
'pilot_start_date',
'pilot_end_date',
'data_scope',
'scenario_count',
'scenario_count_target',
'formal_org_login_count',
'formal_pilot_people_count',
'formal_pilot_people_target',
'formal_pilot_project_count',
'formal_pilot_project_target',
'formal_pilot_scope_ready',
'training_count',
'completion_definition',
'completion_people_count',
'completion_people_total',
'completion_rate',
'completion_rate_target',
'completion_rate_ready',
'average_score',
'alert_count',
'review_rate',
'calibration_sample_count',
'ai_human_band_matched_count',
'calibration_sample_target',
'calibration_sample_ready',
'ai_human_band_consistency_rate',
'ai_human_band_consistency_target',
'ai_human_band_consistency_ready',
'sop_review_sample_count',
'sop_answer_usable_count',
'sop_answer_usable_rate',
'sop_answer_usable_target',
'sop_answer_usable_ready',
'pilot_metric_ready'
];
const INTEGER_METRICS = [
'scenario_count',
'scenario_count_target',
'formal_org_login_count',
'formal_pilot_people_count',
'formal_pilot_people_target',
'formal_pilot_project_count',
'training_count',
'completion_people_count',
'completion_people_total',
'alert_count',
'calibration_sample_count',
'ai_human_band_matched_count',
'calibration_sample_target',
'sop_review_sample_count',
'sop_answer_usable_count'
];
const PERCENTAGE_METRICS = [
'completion_rate',
'completion_rate_target',
'review_rate',
'ai_human_band_consistency_rate',
'ai_human_band_consistency_target',
'sop_answer_usable_rate',
'sop_answer_usable_target'
];
const BOOLEAN_METRICS = [
'formal_pilot_scope_ready',
'completion_rate_ready',
'calibration_sample_ready',
'ai_human_band_consistency_ready',
'sop_answer_usable_ready',
'pilot_metric_ready'
];
const DETAIL_HEADERS = [
'formal_ext_party_id',
'project_codes',
'session_id',
'trainee',
'scene',
'score',
'status',
'finished_time',
'summary',
'review_advice',
'incentive_point'
];
function fail(message) {
throw new Error(`pilot export contract: ${message}`);
}
export function parseCsv(csv) {
const rows = [];
let row = [];
let cell = '';
let quoted = false;
let quoteClosed = false;
let fieldStart = true;
const text = String(csv || '').replace(/^\uFEFF/, '');
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
quoted = false;
quoteClosed = true;
} else {
cell += char;
}
continue;
}
if (quoteClosed) {
if (char !== ',' && char !== '\n' && char !== '\r') {
fail('unexpected character after closing quote');
}
quoteClosed = false;
}
if (char === '"') {
if (!fieldStart) fail('quote must start a field');
quoted = true;
fieldStart = false;
continue;
}
if (char === ',') {
row.push(cell);
cell = '';
fieldStart = true;
continue;
}
if (char === '\n' || char === '\r') {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
fieldStart = true;
continue;
}
cell += char;
fieldStart = false;
}
if (quoted) fail('unclosed quoted field');
if (cell || row.length || !fieldStart) {
row.push(cell);
rows.push(row);
}
return rows;
}
function isInteger(value) {
return /^\d+$/.test(value);
}
function isPercentage(value) {
return /^\d+(?:\.\d+)?%$/.test(value);
}
function integerMetric(metrics, key) {
return Number(metrics.get(key));
}
function percentageMetric(metrics, key) {
return Number(metrics.get(key).slice(0, -1));
}
function expectedPercentage(part, total) {
return total <= 0 ? '0.0%' : `${(Math.round((part * 1000) / total) / 10).toFixed(1)}%`;
}
function expectedAverage(scores) {
if (!scores.length) return '0.0';
const total = scores.reduce((sum, score) => sum + score, 0);
return (Math.round((total * 10) / scores.length) / 10).toFixed(1);
}
function expectMetric(metrics, key, expected) {
if (metrics.get(key) !== expected) fail(`metric ${key}=${metrics.get(key)}, expected ${expected}`);
}
export function verifyPilotExportContract({ csv, startDate, endDate, contentDisposition }) {
const rows = parseCsv(csv);
const blankRow = rows.findIndex((row) => row.length === 1 && row[0] === '');
if (blankRow < 0) fail('missing summary/detail separator');
const summaryRows = rows.slice(0, blankRow);
const detailRows = rows.slice(blankRow + 1).filter((row) => row.some((cell) => cell !== ''));
if (summaryRows.length < 2 || summaryRows[0].length !== 2
|| summaryRows[0][0] !== 'metric' || summaryRows[0][1] !== 'value') {
fail('missing metric,value header');
}
const metrics = new Map();
for (const row of summaryRows.slice(1)) {
if (row.length !== 2 || !row[0]) fail('invalid metric row');
if (metrics.has(row[0])) fail(`duplicate metric ${row[0]}`);
metrics.set(row[0], row[1]);
}
for (const key of REQUIRED_METRICS) {
if (!metrics.has(key)) fail(`missing metric ${key}`);
}
const exactValues = new Map([
['pilot_start_date', startDate],
['pilot_end_date', endDate],
['data_scope', 'active_org_unique_identity'],
['scenario_count_target', '12'],
['formal_pilot_people_target', '20'],
['formal_pilot_project_target', '1-2'],
['completion_definition', 'at_least_10_sessions_per_person'],
['completion_rate_target', '80.0%'],
['calibration_sample_target', '20'],
['ai_human_band_consistency_target', '70.0%'],
['sop_answer_usable_target', '80.0%']
]);
for (const [key, expected] of exactValues) {
if (metrics.get(key) !== expected) fail(`metric ${key}=${metrics.get(key)}, expected ${expected}`);
}
for (const key of INTEGER_METRICS) {
if (!isInteger(metrics.get(key))) fail(`metric ${key} must be a non-negative integer`);
}
if (!/^\d+(?:\.\d+)?$/.test(metrics.get('average_score'))) {
fail('metric average_score must be numeric');
}
for (const key of PERCENTAGE_METRICS) {
if (!isPercentage(metrics.get(key))) fail(`metric ${key} must be a percentage`);
}
for (const key of BOOLEAN_METRICS) {
if (!['true', 'false'].includes(metrics.get(key))) fail(`metric ${key} must be true or false`);
}
for (const key of PERCENTAGE_METRICS) {
const percentage = percentageMetric(metrics, key);
if (percentage < 0 || percentage > 100) fail(`metric ${key} must be between 0% and 100%`);
}
const formalPeople = integerMetric(metrics, 'formal_pilot_people_count');
const completionTotal = integerMetric(metrics, 'completion_people_total');
const completedPeople = integerMetric(metrics, 'completion_people_count');
const projects = integerMetric(metrics, 'formal_pilot_project_count');
const orgLogin = integerMetric(metrics, 'formal_org_login_count');
if (completionTotal !== formalPeople) {
fail(`completion_people_total ${completionTotal} does not match formal_pilot_people_count ${formalPeople}`);
}
if (completedPeople > completionTotal) fail('completion_people_count exceeds completion_people_total');
const scopeReady = orgLogin >= 20 && formalPeople >= 20 && projects >= 1 && projects <= 2;
expectMetric(metrics, 'formal_pilot_scope_ready', String(scopeReady));
expectMetric(metrics, 'completion_rate', expectedPercentage(completedPeople, completionTotal));
const completionReady = scopeReady && completedPeople * 100 >= completionTotal * 80;
expectMetric(metrics, 'completion_rate_ready', String(completionReady));
const calibrationSamples = integerMetric(metrics, 'calibration_sample_count');
const calibrationMatched = integerMetric(metrics, 'ai_human_band_matched_count');
if (calibrationMatched > calibrationSamples) fail('ai_human_band_matched_count exceeds calibration_sample_count');
expectMetric(metrics, 'ai_human_band_consistency_rate', expectedPercentage(calibrationMatched, calibrationSamples));
const calibrationReady = calibrationSamples >= 20 && calibrationMatched * 100 >= calibrationSamples * 70;
expectMetric(metrics, 'calibration_sample_ready', String(calibrationSamples >= 20));
expectMetric(metrics, 'ai_human_band_consistency_ready', String(calibrationReady));
const sopSamples = integerMetric(metrics, 'sop_review_sample_count');
const sopUsable = integerMetric(metrics, 'sop_answer_usable_count');
if (sopUsable > sopSamples) fail('sop_answer_usable_count exceeds sop_review_sample_count');
expectMetric(metrics, 'sop_answer_usable_rate', expectedPercentage(sopUsable, sopSamples));
const sopReady = sopSamples > 0 && sopUsable * 100 >= sopSamples * 80;
expectMetric(metrics, 'sop_answer_usable_ready', String(sopReady));
const pilotReady = integerMetric(metrics, 'scenario_count') >= 12 && completionReady && calibrationReady && sopReady;
expectMetric(metrics, 'pilot_metric_ready', String(pilotReady));
if (!detailRows.length || detailRows[0].length !== DETAIL_HEADERS.length
|| detailRows[0].some((header, index) => header !== DETAIL_HEADERS[index])) {
fail('missing formal detail header');
}
const detailDataRows = detailRows.slice(1);
if (Number(metrics.get('training_count')) !== detailDataRows.length) {
fail(`training_count ${metrics.get('training_count')} does not match detail rows ${detailDataRows.length}`);
}
const scores = [];
let reviewed = 0;
let alerts = 0;
for (const row of detailDataRows) {
if (row.length !== DETAIL_HEADERS.length) fail(`detail row has ${row.length} columns`);
if (!row[0] || !row[1] || !row[2]) fail('detail row is missing formal identity, project, or session id');
const finishedDate = row[7]?.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(finishedDate) || finishedDate < startDate || finishedDate > endDate) {
fail(`detail finished_time ${row[7] || '<missing>'} is outside requested window`);
}
const score = Number(row[5]);
if (!Number.isFinite(score) || score < 0 || score > 100) fail(`detail score ${row[5] || '<missing>'} is invalid`);
scores.push(score);
if (row[6] === '已复盘') reviewed += 1;
if (score < 80 || row[6] === '待复盘') alerts += 1;
}
expectMetric(metrics, 'average_score', expectedAverage(scores));
expectMetric(metrics, 'alert_count', String(alerts));
expectMetric(metrics, 'review_rate', expectedPercentage(reviewed, detailDataRows.length));
const expectedFilename = `filename=aihr-practice-pilot-${startDate}-${endDate}.csv`;
if (!String(contentDisposition || '').toLowerCase().includes(expectedFilename)) {
fail(`Content-Disposition is missing ${expectedFilename}`);
}
return { metrics, detailRows: detailDataRows };
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
process_identity_token() {
local pid="$1"
ps -o lstart= -p "$pid" 2>/dev/null | xargs || true
}
same_process_identity() {
local pid="$1"
local expected_token="$2"
local current_token
current_token="$(process_identity_token "$pid")"
[[ -n "$current_token" && "$current_token" == "$expected_token" ]]
}
snapshot_process_tree() {
local pid="$1"
local token
local child
local child_snapshot
local descendants=""
[[ "$pid" =~ ^[0-9]+$ ]] || return 0
token="$(process_identity_token "$pid")"
[[ -n "$token" ]] || return 0
while IFS= read -r child; do
if [[ -n "$child" ]]; then
child_snapshot="$(snapshot_process_tree "$child")"
if [[ -n "$child_snapshot" ]]; then
descendants="${descendants}${child_snapshot}"$'\n'
fi
fi
done < <(pgrep -P "$pid" 2>/dev/null || true)
same_process_identity "$pid" "$token" || return 0
printf '%s' "$descendants"
printf '%s|%s\n' "$pid" "$token"
}
signal_process_snapshot() {
local snapshot="$1"
local signal="${2:-TERM}"
local pid
local token
[[ -n "$snapshot" ]] || return 0
while IFS='|' read -r pid token; do
if [[ -n "$pid" ]] && same_process_identity "$pid" "$token"; then
kill "-$signal" "$pid" >/dev/null 2>&1 || true
fi
done <<<"$snapshot"
}
process_snapshot_alive() {
local snapshot="$1"
local pid
local token
[[ -n "$snapshot" ]] || return 1
while IFS='|' read -r pid token; do
if [[ -n "$pid" ]] && same_process_identity "$pid" "$token"; then
return 0
fi
done <<<"$snapshot"
return 1
}
stop_process_tree() {
local root_pid="$1"
local wait_loops="${PROCESS_TREE_TERM_WAIT_LOOPS:-20}"
local snapshot
local descendants
local current
local all_snapshots
[[ "$root_pid" =~ ^[0-9]+$ ]] || return 0
snapshot="$(snapshot_process_tree "$root_pid")"
[[ -n "$snapshot" ]] || return 0
all_snapshots="$snapshot"
# Stop the first child snapshot before the wrapper so it cannot orphan the
# already-known Java/Vite process when the wrapper exits.
descendants="$(printf '%s\n' "$snapshot" | sed '$d')"
signal_process_snapshot "$descendants" TERM
current="$(snapshot_process_tree "$root_pid")"
if [[ -n "$current" ]]; then
all_snapshots="${all_snapshots}"$'\n'"${current}"
signal_process_snapshot "$current" TERM
fi
for _ in $(seq 1 "$wait_loops"); do
current="$(snapshot_process_tree "$root_pid")"
if [[ -n "$current" ]]; then
all_snapshots="${all_snapshots}"$'\n'"${current}"
signal_process_snapshot "$current" TERM
fi
if ! process_snapshot_alive "$all_snapshots"; then
return 0
fi
sleep 0.2
done
signal_process_snapshot "$all_snapshots" KILL
}
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT_DIR/scripts/demo-check.sh"
assert_contains() {
local text="$1"
grep -Fq "$text" "$SCRIPT" || {
echo "FAIL: demo-check.sh missing: $text" >&2
exit 1
}
}
assert_not_contains() {
local text="$1"
if grep -Fq "$text" "$SCRIPT"; then
echo "FAIL: demo-check.sh still contains unsafe match: $text" >&2
exit 1
fi
}
assert_contains 'AIHR_PILOT_START_DATE'
assert_contains 'AIHR_PILOT_END_DATE'
assert_contains 'AIHR_DEMO_CHECK_LIBRARY_ONLY'
assert_contains 'safe_org AS'
assert_contains 'HAVING SUM(s.session_count) >= 10'
assert_contains 'HAVING COUNT(DISTINCT ext_party_id) = 1'
assert_contains 'finished_time >='
assert_contains 'reviewed_time >='
assert_not_contains 'p.identity_key = o.ext_party_id OR p.identity_key = o.person_phone'
assert_not_contains 's.ext_party_id = o.ext_party_id OR s.ext_party_id = o.person_phone'
if ! command -v docker >/dev/null || ! docker ps --format '{{.Names}}' | grep -qx 'wygj-mysql'; then
echo "SKIP: behavioral pilot gate test requires wygj-mysql"
exit 0
fi
export AIHR_DEMO_CHECK_LIBRARY_ONLY=true
# shellcheck source=../demo-check.sh
source "$SCRIPT"
unset AIHR_PILOT_DB
[[ "$(pilot_sql_scalar 'SELECT 1')" == "1" ]] || {
echo "FAIL: default ry-vue pilot database is unavailable" >&2
exit 1
}
TEST_DB="wygj_demo_check_test_$$"
cleanup() {
docker exec wygj-mysql mysql -uroot -proot -e "DROP DATABASE IF EXISTS \`$TEST_DB\`" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 <<SQL >/dev/null
CREATE DATABASE \`$TEST_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE \`$TEST_DB\`;
CREATE TABLE aihr_practice_scenario (tenant_id varchar(20), position varchar(80), enabled tinyint);
CREATE TABLE aihr_practice_session (tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime);
CREATE TABLE aihr_practice_assignment (tenant_id varchar(20), ext_party_id varchar(100), create_time datetime);
CREATE TABLE aihr_org_snapshot (
tenant_id varchar(20), employment_status varchar(20), ext_party_id varchar(100),
project_code varchar(50), person_phone varchar(30)
);
CREATE TABLE aihr_practice_calibration (
id bigint auto_increment primary key, tenant_id varchar(20), session_id varchar(100),
original_score int, corrected_score int, create_time datetime
);
CREATE TABLE aihr_sop_answer_review (
tenant_id varchar(20), status varchar(30), usable tinyint,
reviewed_time datetime, create_time datetime
);
INSERT INTO aihr_practice_scenario
SELECT '000000','生活顾问',1 FROM (
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12
) n;
INSERT INTO aihr_org_snapshot VALUES
('000000','active','A','P1','13900000001'),
('000000','active','B','P1','13900000001'),
('000000','active','C','P2','D'),
('000000','active','D','P2','13900000004'),
('000000','active','E','P3','13900000005'),
('000000','active','F','P3','13900000006');
INSERT INTO aihr_practice_assignment VALUES
('000000','D','2026-07-08 08:00:00'),
('000000','13900000001','2026-07-08 08:00:00'),
('000000','E','2026-07-08 08:00:00'),
('000000','13900000005','2026-07-08 08:00:00'),
('000000','F','2026-07-08 08:00:00');
INSERT INTO aihr_practice_session
SELECT '000000', CONCAT('e-', n), CASE WHEN n <= 5 THEN 'E' ELSE '13900000005' END, 'mobile', '2026-07-08 09:00:00'
FROM (
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
) n;
INSERT INTO aihr_practice_session
SELECT '000000', CONCAT('f-', n), 'F','mobile','2026-07-08 10:00:00' FROM (
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
) n;
INSERT INTO aihr_practice_session VALUES
('000000','e-out','E','mobile','2026-07-06 10:00:00'),
('000000','unsafe-inside','D','mobile','2026-07-08 10:00:00');
INSERT INTO aihr_practice_calibration (tenant_id,session_id,original_score,corrected_score,create_time) VALUES
('000000','f-1',80,82,'2026-07-08 11:00:00'),
('000000','unsafe-inside',80,40,'2026-07-08 11:00:00'),
('000000','e-out',80,40,'2026-07-06 11:00:00');
INSERT INTO aihr_sop_answer_review VALUES
('000000','已评审',1,'2026-07-08 12:00:00','2026-07-08 11:00:00'),
('000000','已评审',0,'2026-07-06 12:00:00','2026-07-06 11:00:00'),
('000000','待评审',NULL,NULL,'2026-07-08 13:00:00'),
('000000','待评审',NULL,NULL,'2026-07-06 13:00:00');
SQL
export AIHR_PILOT_DB="$TEST_DB"
pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 check_pilot_samples)"
[[ "$pilot_output" == *'ten_sessions=1/5'* ]] || { echo "FAIL: 9/10 boundary or raw window count: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'org_login=2/6'* ]] || { echo "FAIL: unsafe org identities were not excluded: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'org_pilot=2/20'* ]] || { echo "FAIL: collision or dual-key identity was miscounted: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'projects=1/1-2'* ]] || { echo "FAIL: safe pilot project count: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'org_ten_sessions=1/2'* ]] || { echo "FAIL: formal 9/10 boundary: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'calibration=1/20 consistency=1/1'* ]] || { echo "FAIL: calibration window: $pilot_output" >&2; exit 1; }
[[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; }
pilot_sql_scalar "INSERT INTO aihr_practice_session VALUES ('000000','e-10','13900000005','mobile','2026-07-09 09:00:00')" >/dev/null
pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 check_pilot_samples)"
[[ "$pilot_output" == *'org_ten_sessions=2/2'* ]] || { echo "FAIL: formal 10/10 boundary: $pilot_output" >&2; exit 1; }
if (AIHR_PILOT_STRICT=true AIHR_PILOT_START_DATE= AIHR_PILOT_END_DATE= check_pilot_samples) >/tmp/wygj-pilot-strict-test.log 2>&1; then
echo "FAIL: strict mode accepted an implicit date window" >&2
exit 1
fi
grep -Fq 'strict pilot check requires AIHR_PILOT_START_DATE and AIHR_PILOT_END_DATE' /tmp/wygj-pilot-strict-test.log || {
echo "FAIL: strict mode missing-date error is unclear" >&2
exit 1
}
echo "PASS: demo pilot gates use a bounded window and unique formal identities"
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseCsv, verifyPilotExportContract } from '../lib/pilot-export-contract.mjs';
const startDate = '2026-07-07';
const endDate = '2026-07-10';
function validCsv() {
const metrics = {
pilot_start_date: startDate,
pilot_end_date: endDate,
data_scope: 'active_org_unique_identity',
scenario_count: '12',
scenario_count_target: '12',
formal_org_login_count: '20',
formal_pilot_people_count: '20',
formal_pilot_people_target: '20',
formal_pilot_project_count: '1',
formal_pilot_project_target: '1-2',
formal_pilot_scope_ready: 'true',
training_count: '1',
completion_definition: 'at_least_10_sessions_per_person',
completion_people_count: '1',
completion_people_total: '20',
completion_rate: '5.0%',
completion_rate_target: '80.0%',
completion_rate_ready: 'false',
average_score: '86.0',
alert_count: '0',
review_rate: '100.0%',
calibration_sample_count: '1',
ai_human_band_matched_count: '1',
calibration_sample_target: '20',
calibration_sample_ready: 'false',
ai_human_band_consistency_rate: '100.0%',
ai_human_band_consistency_target: '70.0%',
ai_human_band_consistency_ready: 'false',
sop_review_sample_count: '2',
sop_answer_usable_count: '2',
sop_answer_usable_rate: '100.0%',
sop_answer_usable_target: '80.0%',
sop_answer_usable_ready: 'true',
pilot_metric_ready: 'false'
};
const lines = ['metric,value', ...Object.entries(metrics).map(([key, value]) => `${key},${value}`)];
lines.push('', 'formal_ext_party_id,project_codes,session_id,trainee,scene,score,status,finished_time,summary,review_advice,incentive_point');
lines.push('FORMAL-1,P1,session-1,员工甲,投诉处理,86,已复盘,2026-07-08 10:00:00,正式记录,保持回访,3');
return lines.join('\n');
}
test('accepts a complete formal export whose training summary matches its details', () => {
const result = verifyPilotExportContract({
csv: validCsv(),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
});
assert.equal(result.metrics.get('training_count'), '1');
assert.equal(result.detailRows.length, 1);
});
test('rejects a missing metric and a detail row outside the requested date window', () => {
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('calibration_sample_count,1\n', ''),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /missing metric calibration_sample_count/);
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('2026-07-08 10:00:00', '2026-07-11 10:00:00'),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /outside requested window/);
});
test('rejects contradictory pilot metrics and incomplete formal details', () => {
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('completion_people_total,20', 'completion_people_total,19'),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /completion_people_total/);
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('training_count,1', 'training_count,2'),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /does not match detail rows/);
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('FORMAL-1,P1,session-1', ',P1,session-1'),
startDate,
endDate,
contentDisposition: 'attachment; filename=wrong.csv'
}), /missing formal identity/);
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('ai_human_band_consistency_rate,100.0%', 'ai_human_band_consistency_rate,0.0%'),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /ai_human_band_consistency_rate/);
});
test('parses valid quoted CSV fields and rejects malformed quoting', () => {
assert.deepEqual(parseCsv('metric,value\nsummary,"逗号,转义""引号""\n第二行"\n'), [
['metric', 'value'],
['summary', '逗号,转义"引号"\n第二行']
]);
assert.deepEqual(parseCsv('""'), [['']]);
assert.throws(() => parseCsv('metric,value\nsummary,"未闭合'), /unclosed quoted field/);
assert.throws(() => parseCsv('metric,value\nsummary,值"错误'), /quote must start a field/);
assert.throws(() => parseCsv('metric,value\nsummary,"值"错误'), /unexpected character after closing quote/);
assert.throws(() => verifyPilotExportContract({
csv: validCsv().replace('metric,value', '"metric,value"'),
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}), /missing metric,value header/);
});
test('uses raw numerators for threshold readiness instead of rounded percentages', () => {
const csv = validCsv()
.replace('calibration_sample_count,1', 'calibration_sample_count,203')
.replace('ai_human_band_matched_count,1', 'ai_human_band_matched_count,142')
.replace('ai_human_band_consistency_rate,100.0%', 'ai_human_band_consistency_rate,70.0%')
.replace('calibration_sample_ready,false', 'calibration_sample_ready,true')
.replace('sop_review_sample_count,2', 'sop_review_sample_count,2049')
.replace('sop_answer_usable_count,2', 'sop_answer_usable_count,1639')
.replace('sop_answer_usable_rate,100.0%', 'sop_answer_usable_rate,80.0%')
.replace('sop_answer_usable_ready,true', 'sop_answer_usable_ready,false');
assert.doesNotThrow(() => verifyPilotExportContract({
csv,
startDate,
endDate,
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
}));
});
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/process-tree.sh"
sleep 60 &
protected_pid="$!"
signal_process_snapshot "${protected_pid}|not-the-real-start-time" KILL
if ! kill -0 "$protected_pid" >/dev/null 2>&1; then
echo "FAIL: identity mismatch killed an unrelated process" >&2
exit 1
fi
kill "$protected_pid" >/dev/null 2>&1 || true
wait "$protected_pid" 2>/dev/null || true
sh -c 'sleep 60 & wait' &
parent_pid="$!"
sleep 0.1
child_pid="$(pgrep -P "$parent_pid" | head -n 1)"
if [[ -z "$child_pid" ]]; then
echo "FAIL: dummy child process was not created" >&2
stop_process_tree "$parent_pid" KILL
exit 1
fi
stop_process_tree "$parent_pid"
wait "$parent_pid" 2>/dev/null || true
sleep 0.1
child_state="$(ps -o stat= -p "$child_pid" 2>/dev/null | xargs || true)"
if [[ -n "$child_state" && "$child_state" != Z* ]]; then
echo "FAIL: child process $child_pid is still running ($child_state)" >&2
stop_process_tree "$child_pid" KILL
exit 1
fi
echo "PASS: process tree stopped"
PROCESS_TREE_TERM_WAIT_LOOPS=2
export PROCESS_TREE_TERM_WAIT_LOOPS
sh -c 'trap "" TERM; sh -c '\''trap "" TERM; sleep 60'\'' & wait' &
stubborn_parent_pid="$!"
sleep 0.1
stubborn_child_pid="$(pgrep -P "$stubborn_parent_pid" | head -n 1)"
stop_process_tree "$stubborn_parent_pid"
wait "$stubborn_parent_pid" 2>/dev/null || true
sleep 0.1
stubborn_child_state="$(ps -o stat= -p "$stubborn_child_pid" 2>/dev/null | xargs || true)"
if [[ -n "$stubborn_child_state" && "$stubborn_child_state" != Z* ]]; then
echo "FAIL: TERM-resistant child process $stubborn_child_pid is still running ($stubborn_child_state)" >&2
kill -KILL "$stubborn_child_pid" >/dev/null 2>&1 || true
exit 1
fi
echo "PASS: TERM-resistant process tree escalated safely"
identity_marker="$(mktemp)"
rm -f "$identity_marker"
process_identity_token() {
if [[ ! -f "$identity_marker" ]]; then
touch "$identity_marker"
echo "first identity"
else
echo "reused identity"
fi
}
reused_snapshot="$(snapshot_process_tree 999999)"
rm -f "$identity_marker"
if [[ -n "$reused_snapshot" ]]; then
echo "FAIL: snapshot retained a process tree after root identity changed" >&2
exit 1
fi
echo "PASS: root identity change invalidates the snapshot"
+92 -1
View File
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { verifyPilotExportContract } from './lib/pilot-export-contract.mjs';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const args = new Map(process.argv.slice(2).map((arg) => {
@@ -21,6 +22,10 @@ const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
const ids = new Set((args.get('ids') || '').split(',').map((item) => item.trim()).filter(Boolean));
const limit = Number(args.get('limit') || 0);
const orgDryRun = args.has('org-dry-run');
const pilotExport = args.has('pilot-export');
const pilotStartDate = args.get('start-date') || '';
const pilotEndDate = args.get('end-date') || '';
function readEnv(filePath) {
const env = {};
@@ -100,6 +105,26 @@ async function request(method, endpoint, body, token, encrypted = false) {
}
}
async function requestText(method, endpoint, token) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: { clientid, 'Content-Language': 'zh_CN', Authorization: `Bearer ${token}` },
signal: controller.signal
});
return {
status: response.status,
contentType: response.headers.get('content-type') || '',
disposition: response.headers.get('content-disposition') || '',
text: await response.text()
};
} finally {
clearTimeout(timeout);
}
}
async function login() {
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
const codeResponse = await request('GET', '/auth/code');
@@ -160,13 +185,79 @@ function evidenceTitles(result) {
].filter(Boolean);
}
const token = await login();
if (pilotExport) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(pilotStartDate) || !/^\d{4}-\d{2}-\d{2}$/.test(pilotEndDate)) {
throw new Error('--pilot-export requires --start-date=YYYY-MM-DD and --end-date=YYYY-MM-DD');
}
const missingWindow = await requestText('GET', '/api/train/practice/export', token);
let missingWindowData;
try {
missingWindowData = JSON.parse(missingWindow.text);
} catch {
missingWindowData = null;
}
if (missingWindow.status < 400 && (missingWindow.contentType.includes('text/csv') || missingWindowData?.code === 200)) {
console.error('FAIL: pilot export accepted a request without an explicit date window');
process.exit(1);
}
const endpoint = `/api/train/practice/export?startDate=${encodeURIComponent(pilotStartDate)}&endDate=${encodeURIComponent(pilotEndDate)}`;
const response = await requestText('GET', endpoint, token);
if (response.status !== 200 || !response.contentType.includes('text/csv')) {
console.error(`FAIL: pilot export HTTP ${response.status}, content-type=${response.contentType}`);
if (verbose) console.error(response.text.slice(0, 1000));
process.exit(1);
}
let verifiedExport;
try {
verifiedExport = verifyPilotExportContract({
csv: response.text,
startDate: pilotStartDate,
endDate: pilotEndDate,
contentDisposition: response.disposition
});
} catch (error) {
console.error(`FAIL: ${error.message}`);
process.exit(1);
}
const { metrics } = verifiedExport;
console.log(
`PILOT_EXPORT window=${pilotStartDate}..${pilotEndDate}`
+ ` org_login=${metrics.get('formal_org_login_count')}`
+ ` people=${metrics.get('formal_pilot_people_count')}`
+ ` projects=${metrics.get('formal_pilot_project_count')}`
+ ` ten_sessions=${metrics.get('completion_people_count')}/${metrics.get('completion_people_total')}`
+ ` calibration=${metrics.get('calibration_sample_count')}`
+ ` sop=${metrics.get('sop_review_sample_count')}`
+ ` ready=${metrics.get('pilot_metric_ready')}`
);
process.exit(0);
}
if (orgDryRun) {
const response = await request('POST', '/api/aihr/org/sync', {
dryRun: true,
replaceExisting: true,
pageSize: 500,
maxPages: 200
}, token);
const data = response.data?.data;
if (response.status !== 200 || response.data?.code !== 200 || !data?.dryRun) {
console.error(`FAIL: org dry-run failed: ${response.data?.msg || response.status}`);
process.exit(1);
}
console.log(`ORG_DRY_RUN employees=${data.employeeCount} phone_linked=${data.phoneLinked} masked_phone=${data.maskedPhone} suspect_text=${data.suspectText} synced=${data.syncedCount}`);
for (const warning of data.warnings || []) {
console.log(`WARN ${warning}`);
}
process.exit(0);
}
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
if (!questions.length) {
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
process.exit(1);
}
const token = await login();
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
const vectorStatus = vector.data?.data;
if (!vectorStatus?.matched) {