fix(aihr): close Figma UI regression gaps

This commit is contained in:
2026-07-17 04:17:14 +08:00
parent a822b22116
commit e93421ca1e
35 changed files with 1452 additions and 107 deletions
@@ -252,10 +252,46 @@ public final class AihrPracticeDto {
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) { public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
} }
public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) { public record PracticeAssignmentRequest(
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String taskGoal,
String taskRequirements,
String dueTime
) {
public PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) {
this(extPartyId, traineeName, scenarioId, scenarioName, source, reason, null, null, null);
}
} }
public record PracticeAssignmentResponse( public record PracticeAssignmentResponse(
Long id,
String extPartyId,
String traineeName,
String scenarioId,
String scenarioName,
String source,
String reason,
String taskGoal,
String taskRequirements,
String dueTime,
String status,
String createTime,
String question,
String referenceAnswer,
String userAnswer,
String feedback,
Boolean correct,
Integer score,
String scoreMode,
String audioUrl,
Long audioOssId
) {
public PracticeAssignmentResponse(
Long id, Long id,
String extPartyId, String extPartyId,
String traineeName, String traineeName,
@@ -275,6 +311,9 @@ public final class AihrPracticeDto {
String audioUrl, String audioUrl,
Long audioOssId Long audioOssId
) { ) {
this(id, extPartyId, traineeName, scenarioId, scenarioName, source, reason, null, null, null,
status, createTime, question, referenceAnswer, userAnswer, feedback, correct, score, scoreMode, audioUrl, audioOssId);
}
} }
public record OnboardTaskResponse( public record OnboardTaskResponse(
@@ -38,13 +38,13 @@ public final class AihrSopDto {
public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt, String promptVersion) { public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt, String promptVersion) {
} }
public record AnswerFeedbackRequest(String queryText, Long fragmentId, List<Long> fragmentIds, String verdict, String category, String position, String source, Long reviewId) { public record AnswerFeedbackRequest(String queryText, Long fragmentId, List<Long> fragmentIds, String verdict, String category, String position, String source, Long reviewId, List<String> reasonCodes, String comment) {
} }
public record AnswerFeedbackResponse(Integer saved, String verdict, String reviewStatus) { public record AnswerFeedbackResponse(Integer saved, String verdict, String reviewStatus) {
} }
public record AnswerFeedbackItemResponse(Long id, String queryText, String queryNorm, Long fragmentId, String verdict, String category, String position, String source, Long reviewId, String reviewStatus, String reviewer, String reviewedTime, String createTime, String updateTime) { public record AnswerFeedbackItemResponse(Long id, String queryText, String queryNorm, Long fragmentId, String verdict, String feedbackReasonCodes, String feedbackComment, String category, String position, String source, Long reviewId, String reviewStatus, String reviewer, String reviewedTime, String createTime, String updateTime) {
} }
public record AnswerFeedbackReviewResponse(Long id, String reviewStatus, String reviewer, String reviewedTime) { public record AnswerFeedbackReviewResponse(Long id, String reviewStatus, String reviewer, String reviewedTime) {
@@ -100,6 +100,7 @@ public class AihrPracticeSeedService {
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。" "professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
); );
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final DateTimeFormatter ASSIGNMENT_DUE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。 // 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
private static final long RESPONSE_FAST_MS = 5_000L; private static final long RESPONSE_FAST_MS = 5_000L;
private static final long RESPONSE_ACCEPTABLE_MS = 10_000L; private static final long RESPONSE_ACCEPTABLE_MS = 10_000L;
@@ -1108,7 +1109,8 @@ public class AihrPracticeSeedService {
: "a.ext_party_id"; : "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
String sql = """ String sql = """
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason,
task_goal, task_requirements, due_time, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment a FROM aihr_practice_assignment a
%s %s
@@ -2024,7 +2026,8 @@ public class AihrPracticeSeedService {
ensureDailyDrills(party); ensureDailyDrills(party);
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name"; String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
return jdbcTemplate.query(""" return jdbcTemplate.query("""
SELECT id, ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason,
task_goal, task_requirements, due_time, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? WHERE tenant_id = ?
@@ -2190,7 +2193,8 @@ public class AihrPracticeSeedService {
: "a.ext_party_id"; : "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name"; String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
return jdbcTemplate.query(""" return jdbcTemplate.query("""
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason,
task_goal, task_requirements, due_time, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment a FROM aihr_practice_assignment a
WHERE a.tenant_id = ? WHERE a.tenant_id = ?
@@ -2314,14 +2318,20 @@ public class AihrPracticeSeedService {
// must not turn a manual supervisor assignment into a daily/retry/camp record. // must not turn a manual supervisor assignment into a daily/retry/camp record.
String source = "manual"; String source = "manual";
String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练"); String reason = firstNonBlank(request == null ? null : request.reason(), "主管派发专项训练");
String taskGoal = limitedAssignmentText(request == null ? null : request.taskGoal(), 200);
String taskRequirements = limitedAssignmentText(request == null ? null : request.taskRequirements(), 500);
Timestamp dueTime = assignmentDueTimestamp(request == null ? null : request.dueTime());
Timestamp createdAt = Timestamp.valueOf(now); Timestamp createdAt = Timestamp.valueOf(now);
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO aihr_practice_assignment INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason,
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?) task_goal, task_requirements, due_time, status, create_time, update_time)
""", tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?)
""", tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason,
taskGoal, taskRequirements, dueTime, createdAt, createdAt);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query(""" List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason,
task_goal, task_requirements, due_time, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ? WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
@@ -2330,14 +2340,45 @@ public class AihrPracticeSeedService {
""", this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason); """, this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason);
if (rows.isEmpty()) { if (rows.isEmpty()) {
return new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工", return new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工",
scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null); scenario.id(), scenario.name(), source, reason, taskGoal, taskRequirements, formatAssignmentDueTime(dueTime),
"待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null);
} }
PracticeAssignmentResponse row = rows.get(0); PracticeAssignmentResponse row = rows.get(0);
return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工", return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工",
row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(), row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.taskGoal(), row.taskRequirements(), row.dueTime(),
row.status(), row.createTime(), row.question(),
row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), row.audioOssId()); row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), row.audioOssId());
} }
private String limitedAssignmentText(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
}
private Timestamp assignmentDueTimestamp(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim().replace('T', ' ');
if (normalized.matches("\\d{4}-\\d{2}-\\d{2}")) {
normalized += " 23:59:59";
} else if (normalized.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}")) {
normalized += ":00";
}
try {
return Timestamp.valueOf(LocalDateTime.parse(normalized, ASSIGNMENT_DUE_FORMATTER));
} catch (RuntimeException error) {
throw new ServiceException("截止时间格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm");
}
}
private String formatAssignmentDueTime(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(ASSIGNMENT_DUE_FORMATTER);
}
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) { public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), ""); String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), "");
if (!inTeamScope(supervisorExtPartyId, extPartyId)) { if (!inTeamScope(supervisorExtPartyId, extPartyId)) {
@@ -3049,6 +3090,9 @@ public class AihrPracticeSeedService {
rs.getString("scenario_name"), rs.getString("scenario_name"),
source, source,
"daily".equals(source) ? daily.question() : reason, "daily".equals(source) ? daily.question() : reason,
rs.getString("task_goal"),
rs.getString("task_requirements"),
formatAssignmentDueTime(rs.getTimestamp("due_time")),
status, status,
createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER), createTime == null ? "" : createTime.toLocalDateTime().format(TIME_FORMATTER),
"daily".equals(source) ? daily.question() : null, "daily".equals(source) ? daily.question() : null,
@@ -4020,6 +4064,9 @@ public class AihrPracticeSeedService {
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称', `scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
`task_goal` varchar(200) DEFAULT NULL COMMENT '训练目标',
`task_requirements` varchar(500) DEFAULT NULL COMMENT '任务要求',
`due_time` datetime DEFAULT NULL COMMENT '截止时间',
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
@@ -4037,6 +4084,9 @@ public class AihrPracticeSeedService {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务';
"""); """);
ensureColumn("aihr_practice_assignment", "answer_text", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答' AFTER `status`"); ensureColumn("aihr_practice_assignment", "answer_text", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答' AFTER `status`");
ensureColumn("aihr_practice_assignment", "task_goal", "ALTER TABLE aihr_practice_assignment ADD COLUMN `task_goal` varchar(200) DEFAULT NULL COMMENT '训练目标' AFTER `reason`");
ensureColumn("aihr_practice_assignment", "task_requirements", "ALTER TABLE aihr_practice_assignment ADD COLUMN `task_requirements` varchar(500) DEFAULT NULL COMMENT '任务要求' AFTER `task_goal`");
ensureColumn("aihr_practice_assignment", "due_time", "ALTER TABLE aihr_practice_assignment ADD COLUMN `due_time` datetime DEFAULT NULL COMMENT '截止时间' AFTER `task_requirements`");
ensureColumn("aihr_practice_assignment", "answer_audio_url", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址' AFTER `answer_text`"); ensureColumn("aihr_practice_assignment", "answer_audio_url", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址' AFTER `answer_text`");
ensureColumn("aihr_practice_assignment", "answer_audio_oss_id", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号' AFTER `answer_audio_url`"); ensureColumn("aihr_practice_assignment", "answer_audio_oss_id", "ALTER TABLE aihr_practice_assignment ADD COLUMN `answer_audio_oss_id` bigint DEFAULT NULL COMMENT '每日三题语音OSS编号' AFTER `answer_audio_url`");
ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`"); ensureColumn("aihr_practice_assignment", "feedback", "ALTER TABLE aihr_practice_assignment ADD COLUMN `feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈' AFTER `answer_audio_oss_id`");
@@ -103,6 +103,9 @@ public class AihrSopSeedService {
private static final int LOCAL_EMBEDDING_DIMENSION = 1536; private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
private static final int MAX_EVIDENCE_SNIPPETS = 3; private static final int MAX_EVIDENCE_SNIPPETS = 3;
private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据"; private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据";
private static final Set<String> FEEDBACK_REASON_CODES = Set.of(
"no_answer", "not_specific", "wrong_reference", "outdated", "not_applicable"
);
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1"; private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1";
private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。" private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;" + "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;"
@@ -308,6 +311,14 @@ public class AihrSopSeedService {
if (isBlank(verdict)) { if (isBlank(verdict)) {
throw new ServiceException("反馈类型只支持 up/down"); throw new ServiceException("反馈类型只支持 up/down");
} }
List<String> reasonCodes = normalizeFeedbackReasons(request == null ? null : request.reasonCodes());
if ("down".equals(verdict) && reasonCodes.isEmpty()) {
throw new ServiceException("请选择未解决原因");
}
String feedbackReasonCodes = "down".equals(verdict) ? String.join(",", reasonCodes) : "";
String feedbackComment = "down".equals(verdict)
? truncate(AihrSensitiveText.forModel(request.comment()), 500)
: "";
List<Long> fragmentIds = feedbackFragmentIds(request); List<Long> fragmentIds = feedbackFragmentIds(request);
if (fragmentIds.isEmpty()) { if (fragmentIds.isEmpty()) {
throw new ServiceException("缺少片段ID"); throw new ServiceException("缺少片段ID");
@@ -321,11 +332,13 @@ public class AihrSopSeedService {
for (Long fragmentId : fragmentIds) { for (Long fragmentId : fragmentIds) {
saved += jdbcTemplate.update(""" saved += jdbcTemplate.update("""
INSERT INTO aihr_knowledge_answer_feedback INSERT INTO aihr_knowledge_answer_feedback
(tenant_id, query_text, query_norm, fragment_id, verdict, category, position, source, review_id, review_status, create_time, update_time) (tenant_id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment, category, position, source, review_id, review_status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
query_text = VALUES(query_text), query_text = VALUES(query_text),
verdict = VALUES(verdict), verdict = VALUES(verdict),
feedback_reason_codes = VALUES(feedback_reason_codes),
feedback_comment = VALUES(feedback_comment),
category = VALUES(category), category = VALUES(category),
position = VALUES(position), position = VALUES(position),
source = VALUES(source), source = VALUES(source),
@@ -338,6 +351,8 @@ public class AihrSopSeedService {
normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)), normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
fragmentId, fragmentId,
verdict, verdict,
feedbackReasonCodes,
feedbackComment,
firstNonBlank(request.category(), "sop"), firstNonBlank(request.category(), "sop"),
firstNonBlank(request.position(), "生活顾问"), firstNonBlank(request.position(), "生活顾问"),
normalizeSearchSource(request.source()), normalizeSearchSource(request.source()),
@@ -355,7 +370,7 @@ public class AihrSopSeedService {
String reviewStatus = firstNonBlank(status, "待复核"); String reviewStatus = firstNonBlank(status, "待复核");
int limitValue = Math.max(1, Math.min(limit, 200)); int limitValue = Math.max(1, Math.min(limit, 200));
return jdbcTemplate.query(""" return jdbcTemplate.query("""
SELECT id, query_text, query_norm, fragment_id, verdict, category, position, source, review_id, review_status, reviewer, reviewed_time, create_time, update_time SELECT id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment, category, position, source, review_id, review_status, reviewer, reviewed_time, create_time, update_time
FROM aihr_knowledge_answer_feedback FROM aihr_knowledge_answer_feedback
WHERE tenant_id = ? WHERE tenant_id = ?
AND (? = '' OR review_status = ?) AND (? = '' OR review_status = ?)
@@ -367,6 +382,8 @@ public class AihrSopSeedService {
rs.getString("query_norm"), rs.getString("query_norm"),
rs.getLong("fragment_id"), rs.getLong("fragment_id"),
rs.getString("verdict"), rs.getString("verdict"),
rs.getString("feedback_reason_codes"),
rs.getString("feedback_comment"),
rs.getString("category"), rs.getString("category"),
rs.getString("position"), rs.getString("position"),
rs.getString("source"), rs.getString("source"),
@@ -2125,6 +2142,8 @@ public class AihrSopSeedService {
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题', `query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
`fragment_id` bigint NOT NULL COMMENT '知识片段ID', `fragment_id` bigint NOT NULL COMMENT '知识片段ID',
`verdict` varchar(10) NOT NULL COMMENT '反馈: up/down', `verdict` varchar(10) NOT NULL COMMENT '反馈: up/down',
`feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT '未解决原因代码',
`feedback_comment` varchar(500) DEFAULT NULL COMMENT '未解决补充说明',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类', `category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位', `position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
@@ -2141,6 +2160,8 @@ public class AihrSopSeedService {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈';
"""); """);
ensureColumn("aihr_knowledge_answer_feedback", "review_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_id` bigint DEFAULT NULL COMMENT '对应SOP问答评审ID' AFTER `source`"); ensureColumn("aihr_knowledge_answer_feedback", "review_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_id` bigint DEFAULT NULL COMMENT '对应SOP问答评审ID' AFTER `source`");
ensureColumn("aihr_knowledge_answer_feedback", "feedback_reason_codes", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT '未解决原因代码' AFTER `verdict`");
ensureColumn("aihr_knowledge_answer_feedback", "feedback_comment", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_comment` varchar(500) DEFAULT NULL COMMENT '未解决补充说明' AFTER `feedback_reason_codes`");
ensureColumn("aihr_knowledge_answer_feedback", "reviewer", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人' AFTER `review_status`"); ensureColumn("aihr_knowledge_answer_feedback", "reviewer", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人' AFTER `review_status`");
ensureColumn("aihr_knowledge_answer_feedback", "reviewed_time", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间' AFTER `reviewer`"); ensureColumn("aihr_knowledge_answer_feedback", "reviewed_time", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间' AFTER `reviewer`");
answerFeedbackTableReady = true; answerFeedbackTableReady = true;
@@ -2208,6 +2229,18 @@ public class AihrSopSeedService {
return "up".equals(verdict) || "down".equals(verdict) ? verdict : ""; return "up".equals(verdict) || "down".equals(verdict) ? verdict : "";
} }
private static List<String> normalizeFeedbackReasons(List<String> values) {
if (values == null) {
return List.of();
}
return values.stream()
.map(value -> Optional.ofNullable(value).orElse("").trim().toLowerCase(Locale.ROOT))
.filter(FEEDBACK_REASON_CODES::contains)
.distinct()
.limit(FEEDBACK_REASON_CODES.size())
.toList();
}
private static List<Long> feedbackFragmentIds(AnswerFeedbackRequest request) { private static List<Long> feedbackFragmentIds(AnswerFeedbackRequest request) {
LinkedHashSet<Long> ids = new LinkedHashSet<>(); LinkedHashSet<Long> ids = new LinkedHashSet<>();
if (request != null && request.fragmentIds() != null) { if (request != null && request.fragmentIds() != null) {
@@ -113,6 +113,26 @@ public class AihrPracticeSeedServiceTest {
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage()); assertEquals("缺少员工ID,无法派发专项训练", error.getMessage());
} }
@Test
public void assignmentContractPersistsDeadlineGoalAndRequirements() throws Exception {
Path dto = Path.of("src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java");
Path service = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
if (!Files.exists(dto)) {
dto = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java");
service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
}
String dtoCode = Files.readString(dto);
String serviceCode = Files.readString(service);
assertTrue(dtoCode.contains("String taskGoal"));
assertTrue(dtoCode.contains("String taskRequirements"));
assertTrue(dtoCode.contains("String dueTime"));
assertTrue(serviceCode.contains("task_goal"));
assertTrue(serviceCode.contains("task_requirements"));
assertTrue(serviceCode.contains("due_time"));
}
@Test @Test
public void historyAndReviewDetailUseDisplayFieldPrivacyContract() throws Exception { public void historyAndReviewDetailUseDisplayFieldPrivacyContract() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"); Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
@@ -342,6 +342,24 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("WHERE tenant_id = ? AND id = ? AND query_text = ?")); assertTrue(code.contains("WHERE tenant_id = ? AND id = ? AND query_text = ?"));
} }
@Test
@Tag("dev")
public void answerFeedbackPersistsStructuredNegativeReasons() throws Exception {
Path dto = Path.of("src/main/java/org/dromara/aihr/domain/AihrSopDto.java");
Path service = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(dto)) {
dto = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java");
service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String dtoCode = Files.readString(dto);
String serviceCode = Files.readString(service);
assertTrue(dtoCode.contains("List<String> reasonCodes, String comment"));
assertTrue(serviceCode.contains("feedback_reason_codes"));
assertTrue(serviceCode.contains("feedback_comment"));
assertTrue(serviceCode.contains("normalizeFeedbackReasons(request == null ? null : request.reasonCodes())"));
}
@Test @Test
@Tag("dev") @Tag("dev")
public void ossMetadataQueriesKeepTenantScope() throws Exception { public void ossMetadataQueriesKeepTenantScope() throws Exception {
@@ -118,6 +118,9 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称', `scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual', `source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因', `reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
`task_goal` varchar(200) DEFAULT NULL COMMENT '训练目标',
`task_requirements` varchar(500) DEFAULT NULL COMMENT '任务要求',
`due_time` datetime DEFAULT NULL COMMENT '截止时间',
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态', `status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答', `answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
`answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址', `answer_audio_url` varchar(500) DEFAULT NULL COMMENT '每日三题语音回放地址',
@@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题', `query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
`fragment_id` bigint NOT NULL COMMENT '知识片段ID', `fragment_id` bigint NOT NULL COMMENT '知识片段ID',
`verdict` varchar(10) NOT NULL COMMENT '反馈: up/down', `verdict` varchar(10) NOT NULL COMMENT '反馈: up/down',
`feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT '未解决原因代码',
`feedback_comment` varchar(500) DEFAULT NULL COMMENT '未解决补充说明',
`category` varchar(100) DEFAULT NULL COMMENT '知识分类', `category` varchar(100) DEFAULT NULL COMMENT '知识分类',
`position` varchar(80) DEFAULT NULL COMMENT '岗位', `position` varchar(80) DEFAULT NULL COMMENT '岗位',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源', `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
@@ -59,6 +61,38 @@ PREPARE aihr_feedback_review_id_stmt FROM @aihr_feedback_review_id_ddl;
EXECUTE aihr_feedback_review_id_stmt; EXECUTE aihr_feedback_review_id_stmt;
DEALLOCATE PREPARE aihr_feedback_review_id_stmt; DEALLOCATE PREPARE aihr_feedback_review_id_stmt;
SET @aihr_feedback_reason_codes_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_knowledge_answer_feedback'
AND COLUMN_NAME = 'feedback_reason_codes'
);
SET @aihr_feedback_reason_codes_ddl := IF(
@aihr_feedback_table_exists = 1 AND @aihr_feedback_reason_codes_exists = 0,
'ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT ''未解决原因代码'' AFTER `verdict`',
'SELECT 1'
);
PREPARE aihr_feedback_reason_codes_stmt FROM @aihr_feedback_reason_codes_ddl;
EXECUTE aihr_feedback_reason_codes_stmt;
DEALLOCATE PREPARE aihr_feedback_reason_codes_stmt;
SET @aihr_feedback_comment_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_knowledge_answer_feedback'
AND COLUMN_NAME = 'feedback_comment'
);
SET @aihr_feedback_comment_ddl := IF(
@aihr_feedback_table_exists = 1 AND @aihr_feedback_comment_exists = 0,
'ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_comment` varchar(500) DEFAULT NULL COMMENT ''未解决补充说明'' AFTER `feedback_reason_codes`',
'SELECT 1'
);
PREPARE aihr_feedback_comment_stmt FROM @aihr_feedback_comment_ddl;
EXECUTE aihr_feedback_comment_stmt;
DEALLOCATE PREPARE aihr_feedback_comment_stmt;
SET @aihr_feedback_reviewer_exists := ( SET @aihr_feedback_reviewer_exists := (
SELECT COUNT(*) SELECT COUNT(*)
FROM information_schema.COLUMNS FROM information_schema.COLUMNS
@@ -0,0 +1,57 @@
-- AIHR 主管训练指派字段契约(MySQL 8.x)
-- 补齐训练目标、任务要求和截止时间;不写入业务数据,可重复执行。
SET @aihr_assignment_table_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_assignment'
);
SET @aihr_assignment_goal_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_assignment'
AND COLUMN_NAME = 'task_goal'
);
SET @aihr_assignment_goal_ddl := IF(
@aihr_assignment_table_exists = 1 AND @aihr_assignment_goal_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN `task_goal` varchar(200) DEFAULT NULL COMMENT ''训练目标'' AFTER `reason`',
'SELECT 1'
);
PREPARE aihr_assignment_goal_stmt FROM @aihr_assignment_goal_ddl;
EXECUTE aihr_assignment_goal_stmt;
DEALLOCATE PREPARE aihr_assignment_goal_stmt;
SET @aihr_assignment_requirements_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_assignment'
AND COLUMN_NAME = 'task_requirements'
);
SET @aihr_assignment_requirements_ddl := IF(
@aihr_assignment_table_exists = 1 AND @aihr_assignment_requirements_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN `task_requirements` varchar(500) DEFAULT NULL COMMENT ''任务要求'' AFTER `task_goal`',
'SELECT 1'
);
PREPARE aihr_assignment_requirements_stmt FROM @aihr_assignment_requirements_ddl;
EXECUTE aihr_assignment_requirements_stmt;
DEALLOCATE PREPARE aihr_assignment_requirements_stmt;
SET @aihr_assignment_due_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_practice_assignment'
AND COLUMN_NAME = 'due_time'
);
SET @aihr_assignment_due_ddl := IF(
@aihr_assignment_table_exists = 1 AND @aihr_assignment_due_exists = 0,
'ALTER TABLE aihr_practice_assignment ADD COLUMN `due_time` datetime DEFAULT NULL COMMENT ''截止时间'' AFTER `task_requirements`',
'SELECT 1'
);
PREPARE aihr_assignment_due_stmt FROM @aihr_assignment_due_ddl;
EXECUTE aihr_assignment_due_stmt;
DEALLOCATE PREPARE aihr_assignment_due_stmt;
+2 -1
View File
@@ -35,6 +35,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_assignment_contract_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_daily_drill_score_mode_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_sop_requester_identity_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql
@@ -68,7 +69,7 @@ WHERE table_schema = DATABASE()
AND ( AND (
(table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment'))
OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment')) OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment'))
OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode')) OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode', 'task_goal', 'task_requirements', 'due_time'))
OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id')) OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id'))
OR (table_name = 'aihr_org_snapshot' AND column_name = 'hire_date') OR (table_name = 'aihr_org_snapshot' AND column_name = 'hire_date')
OR (table_name = 'aihr_candidate_material' AND column_name IN ('reviewer', 'reviewed_time')) OR (table_name = 'aihr_candidate_material' AND column_name IN ('reviewer', 'reviewed_time'))
+2
View File
@@ -57,6 +57,8 @@ export type AnswerFeedbackItem = {
queryNorm: string; queryNorm: string;
fragmentId: number; fragmentId: number;
verdict: string; verdict: string;
feedbackReasonCodes?: string | null;
feedbackComment?: string | null;
category: string; category: string;
position: string; position: string;
source: string; source: string;
+2
View File
@@ -113,6 +113,8 @@
<span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span> <span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="feedbackReasonCodes" label="未解决原因" min-width="180" />
<el-table-column prop="feedbackComment" label="补充说明" min-width="180" show-overflow-tooltip />
<el-table-column prop="position" label="岗位" width="120" /> <el-table-column prop="position" label="岗位" width="120" />
<el-table-column prop="createTime" label="提交时间" width="170" /> <el-table-column prop="createTime" label="提交时间" width="170" />
<el-table-column prop="reviewer" label="复核人" width="120" /> <el-table-column prop="reviewer" label="复核人" width="120" />
+7
View File
@@ -11,6 +11,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001", "@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001", "@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12", "@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30" "vue": "3.5.30"
}, },
"devDependencies": { "devDependencies": {
@@ -5816,6 +5817,12 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lxgw-wenkai-tc-webfont": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/lxgw-wenkai-tc-webfont/-/lxgw-wenkai-tc-webfont-1.0.0.tgz",
"integrity": "sha512-LOGglSMNw6Nufn+ULaSjliwNwtkCE1cwXuayN09pg+KMDGD5XxxGrAbca1Ap+YdP4NLotdBDKi5wmUXbgqTxHg==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.11", "version": "0.30.11",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001", "@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001", "@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12", "@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30" "vue": "3.5.30"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -28,7 +28,28 @@
</view> </view>
<template v-else> <template v-else>
<view class="panel assignment-form"> <view class="assign-tabs" role="tablist" aria-label="指派专项视图">
<button
class="assign-tab"
:class="{ active: assignView === 'create' }"
role="tab"
:aria-selected="assignView === 'create'"
@click="assignView = 'create'"
>
新建指派
</button>
<button
class="assign-tab"
:class="{ active: assignView === 'progress' }"
role="tab"
:aria-selected="assignView === 'progress'"
@click="assignView = 'progress'"
>
派发进度
</button>
</view>
<view v-if="assignView === 'create'" class="panel assignment-form">
<text class="field-label">训练对象</text> <text class="field-label">训练对象</text>
<input <input
v-model="targetExtPartyId" v-model="targetExtPartyId"
@@ -37,6 +58,17 @@
placeholder="输入手机号或外部ID" placeholder="输入手机号或外部ID"
@input="targetName = ''" @input="targetName = ''"
/> />
<view v-if="memberSuggestions.length" class="target-suggestions">
<button
v-for="member in memberSuggestions"
:key="member.extPartyId"
class="target-suggestion"
@click="selectTarget(member)"
>
<text>{{ member.name || '未命名员工' }}</text>
<text>{{ maskPhone(member.extPartyId) }}</text>
</button>
</view>
<input <input
v-model="targetName" v-model="targetName"
class="input field-input" class="input field-input"
@@ -45,9 +77,15 @@
/> />
<text class="field-label section-label">训练场景</text> <text class="field-label section-label">训练场景</text>
<input
v-model="scenarioSearch"
class="input field-input"
maxlength="30"
placeholder="搜索训练场景"
/>
<view v-if="scenarioOptions.length" class="scenario-row"> <view v-if="scenarioOptions.length" class="scenario-row">
<button <button
v-for="item in scenarioOptions" v-for="item in filteredScenarioOptions"
:key="item.id" :key="item.id"
class="chip scenario-chip" class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }" :class="{ active: assignmentScenarioId === item.id }"
@@ -59,6 +97,29 @@
</view> </view>
<text v-else class="muted block">暂无可派发的已启用场景</text> <text v-else class="muted block">暂无可派发的已启用场景</text>
<text class="field-label section-label">训练目标</text>
<input
v-model="assignmentGoal"
class="input field-input"
maxlength="200"
placeholder="例如:稳定完成投诉首轮承接与时限承诺"
/>
<text class="field-label section-label">任务要求</text>
<textarea
v-model="assignmentRequirements"
class="textarea requirements-input"
maxlength="500"
placeholder="填写练习次数、重点动作或合格标准"
/>
<text class="field-label section-label">截止时间</text>
<picker mode="date" :value="assignmentDueTime" @change="selectDueDate">
<view class="input due-date-field" :class="{ placeholder: !assignmentDueTime }">
{{ assignmentDueTime || '选择截止日期(可选)' }}
</view>
</picker>
<text class="field-label section-label">指派原因</text> <text class="field-label section-label">指派原因</text>
<textarea <textarea
v-model="assignmentReason" v-model="assignmentReason"
@@ -66,12 +127,12 @@
maxlength="180" maxlength="180"
placeholder="填写本次专项训练原因" placeholder="填写本次专项训练原因"
/> />
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice"> <button class="primary-button submit-button tabbar-safe-action" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }} {{ assigning ? '指派中' : '确认指派' }}
</button> </button>
</view> </view>
<view class="panel history-panel"> <view v-if="assignView === 'progress'" class="panel history-panel">
<view class="section-head"> <view class="section-head">
<text class="panel-title">最近专项</text> <text class="panel-title">最近专项</text>
<button <button
@@ -87,14 +148,23 @@
<view class="assignment-copy"> <view class="assignment-copy">
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text> <text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
<text class="assignment-desc">{{ assignmentSummary(item) }}</text> <text class="assignment-desc">{{ assignmentSummary(item) }}</text>
<text v-if="item.taskGoal" class="assignment-goal">目标:{{ item.taskGoal }}</text>
<view class="assignment-progress-track" aria-hidden="true">
<view class="assignment-progress-value" :style="{ width: `${assignmentProgress(item)}%` }" />
</view> </view>
</view>
<view class="assignment-status-copy">
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text> <text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
<text v-if="item.dueTime" class="assignment-due" :class="{ overdue: isAssignmentOverdue(item) }">
{{ isAssignmentOverdue(item) ? '已逾期' : `截止 ${item.dueTime.slice(0, 10)}` }}
</text>
</view>
</view> </view>
</view> </view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view> <view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view> </view>
<view class="panel progress-panel"> <view v-if="assignView === 'progress'" class="panel progress-panel">
<view class="section-head"> <view class="section-head">
<text class="panel-title">派发进度与预警</text> <text class="panel-title">派发进度与预警</text>
<text class="muted">{{ progressSummary }}</text> <text class="muted">{{ progressSummary }}</text>
@@ -120,8 +190,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeAssignment } from '@/types/api'; import type { PracticeAlert, PracticeAssignment, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, isLoggedIn, maskPhone, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice'; import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
@@ -136,12 +206,18 @@ import {
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
const basePath = '/pages/supervisor/assign/index'; const basePath = '/pages/supervisor/assign/index';
const assignView = ref<'create' | 'progress'>('create');
const targetExtPartyId = ref(''); const targetExtPartyId = ref('');
const targetName = ref(''); const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking'); const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const scenarioSearch = ref('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]); const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const assignmentGoal = ref('');
const assignmentRequirements = ref('');
const assignmentDueTime = ref('');
const assignmentReason = ref(''); const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const alerts = ref<PracticeAlert[]>([]); const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false); const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
@@ -157,6 +233,25 @@ const selectedTargetLabel = computed(() => {
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target; return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
}); });
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10)); const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const memberSuggestions = computed(() => {
const keyword = targetExtPartyId.value.trim().toLowerCase();
if (!keyword) return [];
return members.value
.filter((member) => {
const name = member.name?.trim().toLowerCase() || '';
return member.extPartyId.toLowerCase().includes(keyword) || name.includes(keyword);
})
.slice(0, 6);
});
const filteredScenarioOptions = computed(() => {
const keyword = scenarioSearch.value.trim().toLowerCase();
const options = keyword
? scenarioOptions.value.filter((item) => item.name.toLowerCase().includes(keyword))
: scenarioOptions.value;
const selected = scenarioOptions.value.find((item) => item.id === assignmentScenarioId.value);
const visible = selected && !options.some((item) => item.id === selected.id) ? [selected, ...options] : options;
return visible.slice(0, 6);
});
const progressCounts = computed(() => { const progressCounts = computed(() => {
const items = recentAssignments.value; const items = recentAssignments.value;
return { return {
@@ -187,12 +282,18 @@ const resetDraft = () => {
targetExtPartyId.value = ''; targetExtPartyId.value = '';
targetName.value = ''; targetName.value = '';
assignmentScenarioId.value = 'fee-parking'; assignmentScenarioId.value = 'fee-parking';
scenarioSearch.value = '';
assignmentGoal.value = '';
assignmentRequirements.value = '';
assignmentDueTime.value = '';
assignmentReason.value = ''; assignmentReason.value = '';
}; };
const clearPrivateState = () => { const clearPrivateState = () => {
resetDraft(); resetDraft();
assignView.value = 'create';
assignments.value = []; assignments.value = [];
members.value = [];
alerts.value = []; alerts.value = [];
historyLoading.value = false; historyLoading.value = false;
historyRequests.invalidate(); historyRequests.invalidate();
@@ -230,6 +331,7 @@ const loadAssignments = async (force = false) => {
if (historyRequests.isCurrent(generation)) { if (historyRequests.isCurrent(generation)) {
accessDenied.value = false; accessDenied.value = false;
assignments.value = snapshot.assignments; assignments.value = snapshot.assignments;
members.value = snapshot.members;
alerts.value = nextAlerts; alerts.value = nextAlerts;
} }
} catch (error) { } catch (error) {
@@ -243,6 +345,15 @@ const loadAssignments = async (force = false) => {
} }
}; };
const selectTarget = (member: PracticeTeamMember) => {
targetExtPartyId.value = member.extPartyId;
targetName.value = member.name;
};
const selectDueDate = (event: { detail?: { value?: string } }) => {
assignmentDueTime.value = event.detail?.value || '';
};
const loadScenarioOptions = async () => { const loadScenarioOptions = async () => {
try { try {
const rows = await getPracticeScenarios('生活顾问'); const rows = await getPracticeScenarios('生活顾问');
@@ -293,11 +404,16 @@ const assignPractice = async () => {
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`, traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
scenarioId: assignmentScenarioId.value, scenarioId: assignmentScenarioId.value,
scenarioName: scenario?.name, scenarioName: scenario?.name,
reason: assignmentReason.value.trim() || '主管派发专项训练' reason: assignmentReason.value.trim() || '主管派发专项训练',
taskGoal: assignmentGoal.value.trim() || undefined,
taskRequirements: assignmentRequirements.value.trim() || undefined,
dueTime: assignmentDueTime.value || undefined
}); });
resetDraft(); resetDraft();
uni.showToast({ title: '已指派', icon: 'none' }); uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true); await loadAssignments(true);
assignView.value = 'progress';
resetPageScroll();
} catch (error) { } catch (error) {
handleRequestError(error, '指派失败'); handleRequestError(error, '指派失败');
} finally { } finally {
@@ -316,6 +432,14 @@ const assignmentSummary = (item: PracticeAssignment) => {
return `${item.createTime} · ${item.status} · ${reason}`; return `${item.createTime} · ${item.status} · ${reason}`;
}; };
const assignmentProgress = (item: PracticeAssignment) => item.status === '已完成' ? 100 : 0;
const isAssignmentOverdue = (item: PracticeAssignment) => {
if (!item.dueTime || item.status === '已完成') return false;
const deadline = Date.parse(item.dueTime.replace(' ', 'T'));
return Number.isFinite(deadline) && deadline < Date.now();
};
const goLogin = () => { const goLogin = () => {
rememberLoginRedirect(basePath); rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' }); uni.redirectTo({ url: '/pages/auth/login/index' });
@@ -379,6 +503,38 @@ onBeforeUnmount(() => {
min-width: 104px; min-width: 104px;
} }
.assign-tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-bottom: 14px;
padding: 4px;
border: 1px solid #dbe4ee;
border-radius: 14px;
background: #eef2f6;
}
.assign-tab {
min-height: 42px;
border: 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 800;
line-height: 42px;
}
.assign-tab::after {
border: 0;
}
.assign-tab.active {
background: #fff;
color: var(--employee-primary);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.refresh-button { .refresh-button {
min-width: 82px; min-width: 82px;
padding: 0 10px; padding: 0 10px;
@@ -405,6 +561,7 @@ onBeforeUnmount(() => {
.field-input, .field-input,
.reason-input, .reason-input,
.requirements-input,
.scenario-row, .scenario-row,
.submit-button { .submit-button {
margin-top: 9px; margin-top: 9px;
@@ -414,12 +571,61 @@ onBeforeUnmount(() => {
margin-top: 10px; margin-top: 10px;
} }
.target-suggestions {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
margin-top: 6px;
border: 1px solid #dbe4ee;
border-radius: 12px;
background: #fff;
}
.target-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 0;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 44px;
text-align: left;
}
.target-suggestion + .target-suggestion {
border-top: 1px solid #eef2f6;
}
.target-suggestion::after {
border: 0;
}
.target-suggestion text:last-child {
color: #64748b;
font-size: 12px;
}
.scenario-row { .scenario-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.due-date-field {
display: flex;
align-items: center;
margin-top: 9px;
}
.due-date-field.placeholder {
color: #94a3b8;
}
.scenario-chip { .scenario-chip {
border-radius: 8px; border-radius: 8px;
} }
@@ -460,6 +666,36 @@ onBeforeUnmount(() => {
display: block; display: block;
} }
.assignment-goal {
display: block;
margin-top: 5px;
color: #475569;
font-size: 12px;
line-height: 1.4;
}
.assignment-progress-track {
overflow: hidden;
height: 5px;
margin-top: 9px;
border-radius: 999px;
background: #e2e8f0;
}
.assignment-progress-value {
height: 100%;
border-radius: inherit;
background: var(--employee-primary);
}
.assignment-status-copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.assignment-title { .assignment-title {
color: #0f172a; color: #0f172a;
font-size: 14px; font-size: 14px;
@@ -483,6 +719,16 @@ onBeforeUnmount(() => {
font-weight: 800; font-weight: 800;
} }
.assignment-due {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.assignment-due.overdue {
color: #b91c1c;
}
.compact-empty { .compact-empty {
padding: 18px 8px 4px; padding: 18px 8px 4px;
} }
+110 -3
View File
@@ -44,7 +44,6 @@
> >
<text class="metric-value">{{ item.value }}</text> <text class="metric-value">{{ item.value }}</text>
<text class="metric-label">{{ item.title }}</text> <text class="metric-label">{{ item.title }}</text>
<text class="metric-desc">{{ item.desc }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -54,9 +53,9 @@
<text class="panel-title">连续低分预警</text> <text class="panel-title">连续低分预警</text>
<text class="muted">{{ actionableAlerts.length }} 条</text> <text class="muted">{{ actionableAlerts.length }} 条</text>
</view> </view>
<view v-if="alerts.length" class="alert-list"> <view v-if="visibleAlerts.length" class="alert-list">
<button <button
v-for="item in alerts" v-for="item in visibleAlerts"
:key="`${item.type}-${item.title}`" :key="`${item.type}-${item.title}`"
class="alert-row" class="alert-row"
:class="item.severity" :class="item.severity"
@@ -93,6 +92,23 @@
</view> </view>
</view> </view>
<view class="panel training-trend-panel">
<view class="section-head">
<text class="panel-title">近期训练趋势</text>
<text class="muted">最近 {{ trainingTrend.length }} 次</text>
</view>
<view v-if="trainingTrend.length" class="training-trend-list">
<view v-for="item in trainingTrend" :key="item.key" class="training-trend-row">
<text class="training-trend-label">{{ item.label }}</text>
<view class="training-trend-track">
<view class="training-trend-value" :style="{ width: `${item.value}%` }" />
</view>
<text class="training-trend-score">{{ item.value }}</text>
</view>
</view>
<view v-else class="empty compact-empty">完成训练后展示趋势</view>
</view>
<view class="panel task-panel"> <view class="panel task-panel">
<view class="section-head"> <view class="section-head">
<text class="panel-title">快捷入口</text> <text class="panel-title">快捷入口</text>
@@ -144,6 +160,7 @@ import {
actionablePracticeAlerts, actionablePracticeAlerts,
buildSupervisorMetrics, buildSupervisorMetrics,
buildTeamProfiles, buildTeamProfiles,
buildTrainingTrend,
isSupervisorAccessDenied isSupervisorAccessDenied
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
@@ -167,6 +184,10 @@ const pendingAssignmentCount = computed(() =>
supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0); supervisorMetrics.value.find((item) => item.key === 'pendingAssignments')?.value || 0);
const pendingPreview = computed(() => pendingReviews.value.slice(0, 3)); const pendingPreview = computed(() => pendingReviews.value.slice(0, 3));
const actionableAlerts = computed(() => actionablePracticeAlerts(alerts.value)); const actionableAlerts = computed(() => actionablePracticeAlerts(alerts.value));
const visibleAlerts = computed(() => [...actionableAlerts.value]
.sort((a, b) => (a.severity === 'high' ? 0 : 1) - (b.severity === 'high' ? 0 : 1))
.slice(0, 1));
const trainingTrend = computed(() => buildTrainingTrend(records.value));
const clearData = () => { const clearData = () => {
pendingReviews.value = []; pendingReviews.value = [];
@@ -601,4 +622,90 @@ onShow(() => {
.text-button { .text-button {
color: #e60012; color: #e60012;
} }
.metric-item {
min-height: 76px;
padding: 13px 16px;
}
.metric-label {
margin-top: 6px;
}
.training-trend-panel {
padding: 18px;
}
.training-trend-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.training-trend-row {
display: grid;
grid-template-columns: 42px 1fr 30px;
gap: 9px;
align-items: center;
}
.training-trend-label,
.training-trend-score {
color: #8a8d93;
font-size: 12px;
}
.training-trend-score {
color: #25282d;
font-weight: 800;
text-align: right;
}
.training-trend-track {
overflow: hidden;
height: 7px;
border-radius: 999px;
background: #e8eaed;
}
.training-trend-value {
height: 100%;
border-radius: inherit;
background: #e60012;
}
.task-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.task-row {
min-height: 82px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
padding: 10px;
border: 1px solid #f0f1f3;
border-radius: 14px;
}
.task-row:last-child {
border-bottom: 1px solid #f0f1f3;
}
.task-title {
font-size: 13px;
}
.task-meta {
margin-top: 3px;
font-size: 11px;
}
.task-arrow {
display: none;
}
</style> </style>
@@ -9,7 +9,7 @@
<view class="panel account-card"> <view class="panel account-card">
<text class="panel-title">当前账号</text> <text class="panel-title">当前账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text> <text class="account-phone">{{ displayPhone || '未登录' }}</text>
<text class="muted account-note">主管端团队数据仅对当前项目范围可见。</text> <text class="muted account-note">主管端团队数据仅对当前项目范围可见。</text>
</view> </view>
@@ -31,13 +31,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { clearAuth, getAuth, isLoggedIn, maskPhone, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
const phone = ref(''); const phone = ref('');
const displayPhone = computed(() => maskPhone(phone.value));
const refreshAuth = () => { const refreshAuth = () => {
phone.value = getAuth().phone; phone.value = getAuth().phone;
@@ -32,6 +32,7 @@
<template v-else-if="detail"> <template v-else-if="detail">
<view class="panel summary-panel"> <view class="panel summary-panel">
<text class="section-kicker">训练摘要</text>
<view class="summary-head"> <view class="summary-head">
<view class="summary-copy"> <view class="summary-copy">
<text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text> <text class="detail-title">{{ detail.trainee }} · {{ detail.scene }}</text>
@@ -56,7 +57,7 @@
</view> </view>
<view v-if="detail.dialogue?.length" class="panel"> <view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text> <text class="panel-title">录音与逐句复盘</text>
<view class="dialogue-list"> <view class="dialogue-list">
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row"> <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-label">{{ turn.label }}</text>
@@ -78,7 +79,7 @@
</view> </view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel"> <view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">标注与建议</text> <text class="panel-title">SOP 依据与改进建议</text>
<view v-if="detail.annotations?.length" class="annotation-list"> <view v-if="detail.annotations?.length" class="annotation-list">
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row"> <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-issue">{{ item.issue }}</text>
@@ -93,7 +94,7 @@
</view> </view>
<view v-if="editable" class="panel review-form"> <view v-if="editable" class="panel review-form">
<text class="panel-title">主管复盘</text> <text class="panel-title">下一步行动</text>
<text class="field-label">复盘建议</text> <text class="field-label">复盘建议</text>
<textarea <textarea
v-model="advice" v-model="advice"
@@ -136,7 +137,7 @@
/> />
</view> </view>
<button class="primary-button submit-button" :disabled="marking" @click="markReviewed"> <button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中' : '标记已复盘' }} {{ marking ? '提交中' : '标记已复盘' }}
</button> </button>
</view> </view>
@@ -549,6 +550,29 @@ onBeforeUnmount(() => {
margin-top: 10px; margin-top: 10px;
} }
.score-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-list .score-row {
min-height: 96px;
align-items: flex-start;
justify-content: center;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 0;
border-radius: 12px;
background: #f7f8fa;
}
.score-list .score-note {
margin-top: 0;
font-size: 11px;
}
.score-row, .score-row,
.dialogue-row, .dialogue-row,
.annotation-row { .annotation-row {
@@ -659,4 +683,13 @@ onBeforeUnmount(() => {
.empty-panel { .empty-panel {
padding-bottom: 18px; padding-bottom: 18px;
} }
.section-kicker {
display: block;
margin-bottom: 10px;
color: #e60012;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
</style> </style>
@@ -35,9 +35,20 @@
<text class="panel-title">复盘队列</text> <text class="panel-title">复盘队列</text>
<text class="muted">低分优先</text> <text class="muted">低分优先</text>
</view> </view>
<view v-if="reviews.length" class="review-list"> <view class="review-filters">
<button <button
v-for="item in reviews" v-for="item in reviewFilters"
:key="item.value"
class="chip review-filter"
:class="{ active: reviewFilter === item.value }"
@click="reviewFilter = item.value"
>
{{ item.label }}
</button>
</view>
<view v-if="visibleReviews.length" class="review-list">
<button
v-for="item in visibleReviews"
:key="item.id || item.sessionId || item.time" :key="item.id || item.sessionId || item.time"
class="review-row" class="review-row"
@click="openReview(item)" @click="openReview(item)"
@@ -53,7 +64,7 @@
</view> </view>
</button> </button>
</view> </view>
<view v-else-if="!loading" class="empty">暂无待复盘记录</view> <view v-else-if="!loading" class="empty">当前筛选暂无复盘记录</view>
</view> </view>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" /> <SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view> </view>
@@ -67,7 +78,12 @@ import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice'; import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view'; import {
buildSupervisorMetrics,
filterReviewRecords,
isSupervisorAccessDenied,
type ReviewFilter
} from '@/services/supervisor-view';
const currentPath = '/pages/supervisor/reviews/index'; const currentPath = '/pages/supervisor/reviews/index';
const reviews = ref<PracticeRecord[]>([]); const reviews = ref<PracticeRecord[]>([]);
@@ -76,6 +92,13 @@ const loggedIn = ref(false);
const accessDenied = ref(false); const accessDenied = ref(false);
const loading = ref(false); const loading = ref(false);
const message = ref(''); const message = ref('');
const reviewFilter = ref<ReviewFilter>('all');
const reviewFilters: Array<{ value: ReviewFilter; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'low', label: '连续低分' },
{ value: 'pending', label: '待复盘' }
];
const visibleReviews = computed(() => filterReviewRecords(reviews.value, reviewFilter.value));
const pendingReviewTotal = computed(() => const pendingReviewTotal = computed(() =>
buildSupervisorMetrics(reviews.value, [], alerts.value) buildSupervisorMetrics(reviews.value, [], alerts.value)
@@ -215,6 +238,23 @@ onShow(() => {
margin-top: 8px; margin-top: 8px;
} }
.review-filters {
display: flex;
gap: 8px;
margin-top: 12px;
}
.review-filter {
border-radius: 999px;
background: #ffffff;
}
.review-filter.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.review-row { .review-row {
display: flex; display: flex;
width: 100%; width: 100%;
+110 -40
View File
@@ -30,6 +30,47 @@
<button class="primary-button login-button" @click="goLogin">去登录</button> <button class="primary-button login-button" @click="goLogin">去登录</button>
</view> </view>
<template v-else>
<view v-if="selectedMember" class="panel member-detail-panel">
<view class="section-head">
<view>
<text class="panel-title">{{ selectedMember.displayName }} · 能力详情</text>
<text class="muted member-detail-subtitle">训练画像与成长证据</text>
</view>
<button class="text-button" @click="closeMember">返回列表</button>
</view>
<view class="member-summary">
<view>
<text class="member-score">{{ selectedMember.latestScoreText }}</text>
<text class="member-summary-label">最近训练评分</text>
</view>
<view>
<text class="member-score">{{ memberRecords.length }}</text>
<text class="member-summary-label">训练次数</text>
</view>
<view>
<text class="member-score">{{ selectedMember.pendingReview }}</text>
<text class="member-summary-label">待复盘</text>
</view>
</view>
<text class="subsection-title">训练趋势</text>
<view v-if="memberRecords.length" class="trend-list">
<view v-for="item in memberRecords" :key="item.id || item.time" class="trend-row">
<text class="trend-scene">{{ item.scene }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ width: `${Math.max(0, Math.min(100, item.score))}%` }" /></view>
<text class="trend-score">{{ item.score }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无训练评分,先派发一次专项训练</view>
<text class="subsection-title">成长证据</text>
<view class="evidence-list">
<text>已完成 {{ memberRecords.filter((item) => item.status === '已完成').length }} 次训练</text>
<text>当前 {{ selectedMember.pendingAssignment }} 项专项待训练</text>
<text>{{ selectedMember.reason }}</text>
</view>
<button class="primary-button member-assign-button" @click="assignMember(selectedMember)">派发专项</button>
</view>
<template v-else> <template v-else>
<view class="risk-summary"> <view class="risk-summary">
<view class="risk-item high"> <view class="risk-item high">
@@ -69,12 +110,22 @@
<view class="panel team-panel"> <view class="panel team-panel">
<view class="section-head"> <view class="section-head">
<view>
<text class="panel-title">成员列表</text> <text class="panel-title">成员列表</text>
<text class="muted">按风险排序</text> <text class="muted team-result-count">{{ filteredTeamProfiles.length }} 人 · 按风险排序</text>
</view> </view>
<view v-if="teamProfiles.length" class="team-list"> <text class="muted">第 {{ teamPagination.page }}/{{ teamPagination.pageCount }} 页</text>
</view>
<input
v-model="teamQuery"
class="input team-search"
maxlength="40"
placeholder="搜索姓名、手机号或外部ID"
@input="teamPage = 1"
/>
<view v-if="visibleTeamProfiles.length" class="team-list">
<view <view
v-for="member in teamProfiles" v-for="member in visibleTeamProfiles"
:key="member.key" :key="member.key"
class="team-row" class="team-row"
:class="member.risk" :class="member.risk"
@@ -95,45 +146,14 @@
<button class="secondary-button team-action" @click.stop="assignMember(member)">派专项</button> <button class="secondary-button team-action" @click.stop="assignMember(member)">派专项</button>
</view> </view>
</view> </view>
<view v-else-if="!loading" class="empty">暂无团队画像数据</view> <view v-else-if="!loading" class="empty">没有匹配的团队成员</view>
</view> <view v-if="teamPagination.pageCount > 1" class="team-pagination">
<button class="secondary-button" :disabled="teamPagination.page <= 1" @click="changeTeamPage(-1)">上一页</button>
<view v-if="selectedMember" class="panel member-detail-panel"> <button class="secondary-button" :disabled="teamPagination.page >= teamPagination.pageCount" @click="changeTeamPage(1)">下一页</button>
<view class="section-head">
<text class="panel-title">{{ selectedMember.displayName }} · 能力详情</text>
<button class="text-button" @click="selectedMember = null">收起</button>
</view>
<view class="member-summary">
<view>
<text class="member-score">{{ selectedMember.latestScoreText }}</text>
<text class="member-summary-label">最近训练评分</text>
</view>
<view>
<text class="member-score">{{ memberRecords.length }}</text>
<text class="member-summary-label">训练次数</text>
</view>
<view>
<text class="member-score">{{ selectedMember.pendingReview }}</text>
<text class="member-summary-label">待复盘</text>
</view>
</view>
<text class="subsection-title">训练趋势</text>
<view v-if="memberRecords.length" class="trend-list">
<view v-for="item in memberRecords" :key="item.id || item.time" class="trend-row">
<text class="trend-scene">{{ item.scene }}</text>
<view class="trend-track"><view class="trend-fill" :style="{ width: `${Math.max(0, Math.min(100, item.score))}%` }" /></view>
<text class="trend-score">{{ item.score }}</text>
</view>
</view>
<view v-else class="detail-empty">暂无训练评分,先派发一次专项训练</view>
<text class="subsection-title">成长证据</text>
<view class="evidence-list">
<text>已完成 {{ memberRecords.filter((item) => item.status === '已完成').length }} 次训练</text>
<text>当前 {{ selectedMember.pendingAssignment }} 项专项待训练</text>
<text>{{ selectedMember.reason }}</text>
</view> </view>
</view> </view>
</template> </template>
</template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" /> <SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
</view> </view>
</template> </template>
@@ -148,8 +168,10 @@ import { getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import { import {
buildTeamProfiles, buildTeamProfiles,
filterTeamProfiles,
inferSupervisorScenarioId, inferSupervisorScenarioId,
isSupervisorAccessDenied, isSupervisorAccessDenied,
paginateTeamProfiles,
type TeamProfile type TeamProfile
} from '@/services/supervisor-view'; } from '@/services/supervisor-view';
@@ -163,8 +185,14 @@ const accessDenied = ref(false);
const loading = ref(false); const loading = ref(false);
const message = ref(''); const message = ref('');
const selectedMember = ref<TeamProfile | null>(null); const selectedMember = ref<TeamProfile | null>(null);
const teamQuery = ref('');
const teamPage = ref(1);
const teamPageSize = 20;
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value)); const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
const filteredTeamProfiles = computed(() => filterTeamProfiles(teamProfiles.value, teamQuery.value));
const teamPagination = computed(() => paginateTeamProfiles(filteredTeamProfiles.value, teamPage.value, teamPageSize));
const visibleTeamProfiles = computed(() => teamPagination.value.items);
const riskCounts = computed(() => teamProfiles.value.reduce( const riskCounts = computed(() => teamProfiles.value.reduce(
(counts, item) => { (counts, item) => {
counts[item.risk] += 1; counts[item.risk] += 1;
@@ -185,6 +213,7 @@ const clearData = () => {
members.value = []; members.value = [];
mistakes.value = []; mistakes.value = [];
selectedMember.value = null; selectedMember.value = null;
teamPage.value = 1;
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -244,7 +273,21 @@ const assignMember = (member: TeamProfile) => {
}; };
const selectMember = (member: TeamProfile) => { const selectMember = (member: TeamProfile) => {
selectedMember.value = selectedMember.value?.key === member.key ? null : member; selectedMember.value = member;
resetPageScroll();
};
const closeMember = () => {
selectedMember.value = null;
resetPageScroll();
};
const changeTeamPage = (delta: number) => {
teamPage.value = Math.min(
teamPagination.value.pageCount,
Math.max(1, teamPagination.value.page + delta)
);
resetPageScroll();
}; };
const openReviews = () => { const openReviews = () => {
@@ -443,6 +486,17 @@ onShow(() => {
margin-top: 8px; margin-top: 8px;
} }
.team-result-count,
.member-detail-subtitle {
display: block;
margin-top: 4px;
font-size: 11px;
}
.team-search {
margin-top: 10px;
}
.team-row { .team-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -521,6 +575,22 @@ onShow(() => {
font-size: 12px; font-size: 12px;
} }
.team-pagination {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.team-pagination button,
.member-assign-button {
width: 100%;
}
.member-assign-button {
margin-top: 18px;
}
.member-detail-panel { .member-detail-panel {
margin-top: 14px; margin-top: 14px;
} }
+41 -1
View File
@@ -101,7 +101,11 @@
</view> </view>
</view> </view>
</view> </view>
<view v-else class="empty">暂无入库案例</view> <view v-else class="case-empty-state">
<view class="case-empty-icon">案</view>
<text class="case-empty-title">暂时还没有可学习案例</text>
<text class="case-empty-desc">案例经主管整理并入库后会出现在这里;提交权限由组织角色决定。</text>
</view>
</view> </view>
<view v-if="caseDetail" class="panel case-detail-panel"> <view v-if="caseDetail" class="panel case-detail-panel">
@@ -535,4 +539,40 @@ onUnload(clearCaseMediaUrl);
font-weight: 900; font-weight: 900;
white-space: nowrap; white-space: nowrap;
} }
.case-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 16px 20px;
text-align: center;
}
.case-empty-icon {
display: flex;
width: 52px;
height: 52px;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #fff0f1;
color: #e60012;
font-size: 20px;
font-weight: 900;
}
.case-empty-title {
margin-top: 14px;
color: #1f2937;
font-size: 15px;
font-weight: 900;
}
.case-empty-desc {
max-width: 280px;
margin-top: 7px;
color: #6b7280;
font-size: 13px;
line-height: 1.55;
}
</style> </style>
+30 -7
View File
@@ -21,6 +21,7 @@
<template v-else-if="detail"> <template v-else-if="detail">
<view class="panel summary-panel"> <view class="panel summary-panel">
<text class="section-kicker">训练摘要</text>
<view class="summary-head"> <view class="summary-head">
<view> <view>
<text class="detail-title">{{ detail.scene }}</text> <text class="detail-title">{{ detail.scene }}</text>
@@ -33,17 +34,17 @@
<view v-if="detail.scoreItems?.length" class="panel"> <view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text> <text class="panel-title">评分明细</text>
<view class="score-summary-grid">
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row"> <view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view>
<text class="score-label">{{ item.label }}</text> <text class="score-label">{{ item.label }}</text>
<text class="score-value">{{ item.value }}</text>
<text class="score-note">{{ item.note }}</text> <text class="score-note">{{ item.note }}</text>
</view> </view>
<text class="score-value">{{ item.value }}</text>
</view> </view>
</view> </view>
<view v-if="detail.dialogue?.length" class="panel"> <view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text> <text class="panel-title">录音与逐句复盘</text>
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row"> <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-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text> <text class="dialogue-text">{{ turn.text }}</text>
@@ -58,7 +59,7 @@
</view> </view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel"> <view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">复盘建议</text> <text class="panel-title">SOP 依据与改进建议</text>
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row"> <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-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text> <text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
@@ -258,11 +259,24 @@ onUnload(releaseAudio);
border-top: 1px solid #ececee; border-top: 1px solid #ececee;
} }
.score-summary-grid {
display: grid;
margin-top: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-row { .score-row {
display: flex; display: flex;
align-items: center; min-height: 96px;
justify-content: space-between; align-items: flex-start;
gap: 12px; justify-content: center;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 0;
border-radius: 12px;
background: #f7f8fa;
} }
.score-note, .score-note,
@@ -342,4 +356,13 @@ onUnload(releaseAudio);
.empty-panel button { .empty-panel button {
margin-top: 14px; margin-top: 14px;
} }
.section-kicker {
display: block;
margin-bottom: 10px;
color: #e60012;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
</style> </style>
+37 -1
View File
@@ -190,7 +190,7 @@
{{ helpLoading ? '求助中' : '求助' }} {{ helpLoading ? '求助中' : '求助' }}
</button> </button>
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button> <button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
<button class="primary-button" :disabled="busy" @click="submit"> <button class="primary-button tabbar-safe-action" :disabled="busy" @click="submit">
{{ status === 'submitting' ? '提交中' : '提交回应' }} {{ status === 'submitting' ? '提交中' : '提交回应' }}
</button> </button>
</view> </view>
@@ -209,6 +209,12 @@
<text class="result-label">本次评分</text> <text class="result-label">本次评分</text>
<text class="result-score">{{ result.totalScore }}</text> <text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text> <text class="result-comment">{{ result.aiComment }}</text>
<view v-if="result.score?.length" class="result-dimensions">
<view v-for="item in result.score" :key="item.label" class="result-dimension">
<text>{{ item.value }}</text>
<text>{{ item.label }}</text>
</view>
</view>
<view v-if="result.mentorRewrite" class="mentor-rewrite"> <view v-if="result.mentorRewrite" class="mentor-rewrite">
<text>老师傅建议</text> <text>老师傅建议</text>
<text>{{ result.mentorRewrite }}</text> <text>{{ result.mentorRewrite }}</text>
@@ -911,6 +917,7 @@ const finish = async (force = false) => {
message.value = ''; message.value = '';
try { try {
result.value = await finishPractice(sessionId.value, activeAssignmentId.value); result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
resetPageScroll();
status.value = 'finished'; status.value = 'finished';
message.value = '训练完成,主管端待复盘已更新'; message.value = '训练完成,主管端待复盘已更新';
activeAssignmentId.value = undefined; activeAssignmentId.value = undefined;
@@ -1464,6 +1471,35 @@ onShow(() => {
line-height: 1.55; line-height: 1.55;
} }
.result-dimensions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.result-dimension {
display: flex;
min-height: 66px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
border-radius: 14px;
background: #f7f8fa;
text-align: center;
}
.result-dimension text:first-child {
color: var(--employee-primary);
font-size: 22px;
font-weight: 900;
}
.result-dimension text:last-child {
color: var(--employee-muted);
font-size: 12px;
}
.mentor-rewrite { .mentor-rewrite {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+5 -3
View File
@@ -3,7 +3,7 @@
<view class="profile-head"> <view class="profile-head">
<text class="profile-eyebrow">银城物业 · 员工成长</text> <text class="profile-eyebrow">银城物业 · 员工成长</text>
<text class="page-title">我的成长</text> <text class="page-title">我的成长</text>
<text class="page-subtitle">{{ phone || '登录后查看训练记录和能力画像' }}</text> <text class="page-subtitle">{{ displayPhone || '登录后查看训练记录和能力画像' }}</text>
</view> </view>
<view v-if="message" class="status-message">{{ message }}</view> <view v-if="message" class="status-message">{{ message }}</view>
@@ -11,7 +11,7 @@
<view class="panel account-card"> <view class="panel account-card">
<view> <view>
<text class="panel-title">账号</text> <text class="panel-title">账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text> <text class="account-phone">{{ displayPhone || '未登录' }}</text>
</view> </view>
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button> <button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
<button v-else class="secondary-button account-action" @click="logout">退出</button> <button v-else class="secondary-button account-action" @click="logout">退出</button>
@@ -153,7 +153,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api'; import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import { import {
@@ -162,6 +162,7 @@ import {
getPositionProfile, getPositionProfile,
getSelectedPositionProfile, getSelectedPositionProfile,
isLoggedIn, isLoggedIn,
maskPhone,
rememberLoginRedirect, rememberLoginRedirect,
saveSelectedPosition, saveSelectedPosition,
userPositionOptions, userPositionOptions,
@@ -173,6 +174,7 @@ import { ensureEmployeePosition } from '@/services/position';
const loggedIn = ref(false); const loggedIn = ref(false);
const phone = ref(''); const phone = ref('');
const displayPhone = computed(() => maskPhone(phone.value));
const loading = ref(false); const loading = ref(false);
const message = ref(''); const message = ref('');
const profile = ref<CompetencyProfile | null>(null); const profile = ref<CompetencyProfile | null>(null);
+130 -5
View File
@@ -84,20 +84,51 @@
<button <button
class="secondary-button" class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }" :class="{ active: feedbackVerdict === 'up' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)" :disabled="feedbackSubmitting || Boolean(feedbackVerdict) || showNegativeFeedback"
@click="feedback('up')" @click="feedback('up')"
> >
有帮助 有帮助
</button> </button>
<button <button
class="secondary-button" class="secondary-button"
:class="{ active: feedbackVerdict === 'down', negative: feedbackVerdict === 'down' }" :class="{ active: feedbackVerdict === 'down' || showNegativeFeedback, negative: feedbackVerdict === 'down' || showNegativeFeedback }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)" :disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')" @click="openNegativeFeedback"
> >
没解决 没解决
</button> </button>
</view> </view>
<view v-if="showNegativeFeedback && !feedbackVerdict" class="negative-feedback-panel">
<text class="negative-feedback-title">哪里没有解决?(可多选)</text>
<view class="negative-reason-list">
<button
v-for="item in feedbackReasonOptions"
:key="item.code"
class="negative-reason"
:class="{ active: selectedFeedbackReasons.includes(item.code) }"
:disabled="feedbackSubmitting"
@click="toggleFeedbackReason(item.code)"
>
{{ item.label }}
</button>
</view>
<textarea
v-model="feedbackComment"
class="textarea negative-feedback-comment"
maxlength="500"
placeholder="补充说明(可选)"
/>
<view class="negative-feedback-actions">
<button class="secondary-button" :disabled="feedbackSubmitting" @click="closeNegativeFeedback">取消</button>
<button
class="primary-button"
:disabled="feedbackSubmitting || !selectedFeedbackReasons.length"
@click="submitNegativeFeedback"
>
{{ feedbackSubmitting ? '提交中' : '提交反馈' }}
</button>
</view>
</view>
<text v-if="feedbackVerdict" class="feedback-status"> <text v-if="feedbackVerdict" class="feedback-status">
{{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }} {{ feedbackVerdict === 'up' ? '已记录为有帮助' : '已提交未解决反馈,后续会继续优化这条答案' }}
</text> </text>
@@ -220,15 +251,28 @@ const loading = ref(false);
const summaryLoading = ref(false); const summaryLoading = ref(false);
const feedbackSubmitting = ref(false); const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>(''); const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const showNegativeFeedback = ref(false);
const selectedFeedbackReasons = ref<string[]>([]);
const feedbackComment = ref('');
const message = ref(''); const message = ref('');
const loggedIn = ref(false); const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query'; const pendingSopQueryKey = 'aihr_sop_pending_query';
const feedbackReasonOptions = [
{ code: 'no_answer', label: '没有回答问题' },
{ code: 'not_specific', label: '步骤不够具体' },
{ code: 'wrong_reference', label: '引用依据不准确' },
{ code: 'outdated', label: '内容已经过期' },
{ code: 'not_applicable', label: '话术不适合现场' }
];
const resetResultState = () => { const resetResultState = () => {
result.value = null; result.value = null;
summaryCard.value = null; summaryCard.value = null;
feedbackVerdict.value = ''; feedbackVerdict.value = '';
feedbackSubmitting.value = false; feedbackSubmitting.value = false;
showNegativeFeedback.value = false;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
}; };
const refreshAuthState = () => { const refreshAuthState = () => {
@@ -271,6 +315,7 @@ const search = async () => {
resetResultState(); resetResultState();
try { try {
result.value = await searchKnowledge(value); result.value = await searchKnowledge(value);
resetPageScroll();
if (!result.value.answer && !result.value.snippets?.length) { if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容'; message.value = '未检索到可引用内容';
} }
@@ -297,7 +342,10 @@ const cleanOrderedText = (text = '') =>
.replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '') .replace(/^(?:[一二三四五六七八九十]+[、..]|[0-9]+[.)、..])\s*/, '')
.trim(); .trim();
const feedback = async (verdict: AnswerFeedbackVerdict) => { const feedback = async (
verdict: AnswerFeedbackVerdict,
details: { reasonCodes?: string[]; comment?: string } = {}
) => {
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return; if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
const fragmentIds = Array.from( const fragmentIds = Array.from(
new Set( new Set(
@@ -317,9 +365,12 @@ const feedback = async (verdict: AnswerFeedbackVerdict) => {
queryText: result.value.queryText || query.value, queryText: result.value.queryText || query.value,
fragmentIds, fragmentIds,
verdict, verdict,
reviewId: result.value.reviewId reviewId: result.value.reviewId,
reasonCodes: details.reasonCodes,
comment: details.comment
}); });
feedbackVerdict.value = verdict; feedbackVerdict.value = verdict;
showNegativeFeedback.value = false;
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' }); uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
} catch (error) { } catch (error) {
if (!handleRequestError(error, '反馈失败', true)) { if (!handleRequestError(error, '反馈失败', true)) {
@@ -333,6 +384,32 @@ const feedback = async (verdict: AnswerFeedbackVerdict) => {
} }
}; };
const openNegativeFeedback = () => {
if (feedbackSubmitting.value || feedbackVerdict.value) return;
showNegativeFeedback.value = true;
selectedFeedbackReasons.value = [];
feedbackComment.value = '';
};
const closeNegativeFeedback = () => {
if (feedbackSubmitting.value) return;
showNegativeFeedback.value = false;
};
const toggleFeedbackReason = (code: string) => {
selectedFeedbackReasons.value = selectedFeedbackReasons.value.includes(code)
? selectedFeedbackReasons.value.filter((item) => item !== code)
: [...selectedFeedbackReasons.value, code];
};
const submitNegativeFeedback = () => {
if (!selectedFeedbackReasons.value.length) return;
void feedback('down', {
reasonCodes: selectedFeedbackReasons.value,
comment: feedbackComment.value.trim()
});
};
const buildSummaryCard = async () => { const buildSummaryCard = async () => {
if (summaryLoading.value) return; if (summaryLoading.value) return;
const value = (result.value?.queryText || query.value).trim(); const value = (result.value?.queryText || query.value).trim();
@@ -624,6 +701,54 @@ onShow(() => {
color: #c34d45; color: #c34d45;
} }
.negative-feedback-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: #fff8f8;
}
.negative-feedback-title {
color: var(--employee-text);
font-size: 15px;
font-weight: 900;
}
.negative-reason-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.negative-reason {
min-height: 40px;
padding: 0 10px;
border: 1px solid #f1d7d9;
background: #ffffff;
color: #5c626c;
font-size: 13px;
text-align: left;
}
.negative-reason.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.negative-feedback-comment {
min-height: 84px;
border-color: #f1d7d9;
}
.negative-feedback-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.feedback-status { .feedback-status {
display: block; display: block;
padding: 10px 12px; padding: 10px 12px;
+27 -1
View File
@@ -115,7 +115,7 @@
</button> </button>
</view> </view>
<view class="schedule-row" v-if="home?.review"> <view class="schedule-row review-schedule-row" v-if="home?.review">
<view class="schedule-icon-wrap review-icon"> <view class="schedule-icon-wrap review-icon">
<uni-icons class="schedule-icon" type="calendar-filled" size="26" color="#d98200" /> <uni-icons class="schedule-icon" type="calendar-filled" size="26" color="#d98200" />
</view> </view>
@@ -917,9 +917,13 @@ onShow(() => {
} }
.schedule-desc { .schedule-desc {
display: -webkit-box;
overflow: hidden;
color: #8a8d93; color: #8a8d93;
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.schedule-action { .schedule-action {
@@ -942,6 +946,28 @@ onShow(() => {
box-shadow: 0 8px 16px rgba(217, 130, 0, 0.16); box-shadow: 0 8px 16px rgba(217, 130, 0, 0.16);
} }
.review-schedule-row {
min-height: 62px;
padding-top: 8px;
padding-bottom: 8px;
}
.review-schedule-row .schedule-icon-wrap {
width: 42px;
height: 42px;
}
.review-schedule-row .schedule-meta,
.review-schedule-row .schedule-desc {
display: none;
}
.review-schedule-row .schedule-action {
width: 70px;
min-height: 40px;
font-size: 14px;
}
.map-grid { .map-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
+3
View File
@@ -134,3 +134,6 @@ export const consumeLoginRedirect = () => {
}; };
export const validatePhone = (phone: string) => phonePattern.test(phone); export const validatePhone = (phone: string) => phonePattern.test(phone);
export const maskPhone = (phone: string) =>
/^\d{8,}$/.test(phone) ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone;
+5 -1
View File
@@ -49,6 +49,8 @@ export const submitAnswerFeedback = (params: {
fragmentIds: number[]; fragmentIds: number[];
verdict: AnswerFeedbackVerdict; verdict: AnswerFeedbackVerdict;
reviewId?: number | null; reviewId?: number | null;
reasonCodes?: string[];
comment?: string;
}) => }) =>
apiRequest<{ saved: number }>({ apiRequest<{ saved: number }>({
url: '/api/knowledge/answer-feedback', url: '/api/knowledge/answer-feedback',
@@ -60,7 +62,9 @@ export const submitAnswerFeedback = (params: {
category: 'sop', category: 'sop',
position: getSelectedPosition(), position: getSelectedPosition(),
source: 'mobile_uni_sop', source: 'mobile_uni_sop',
reviewId: params.reviewId ?? null reviewId: params.reviewId ?? null,
reasonCodes: params.reasonCodes || [],
comment: params.comment || ''
}, },
timeout: 15000 timeout: 15000
}); });
+57 -2
View File
@@ -15,6 +15,12 @@ export interface SupervisorMetric {
primary?: boolean; primary?: boolean;
} }
export interface TrainingTrendPoint {
key: string;
label: string;
value: number;
}
export interface TeamProfile { export interface TeamProfile {
key: string; key: string;
extPartyId: string; extPartyId: string;
@@ -98,8 +104,28 @@ export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) => export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable'); alerts.filter((item) => item.type !== 'stable');
export const buildTrainingTrend = (records: PracticeRecord[]): TrainingTrendPoint[] =>
records
.filter((item) => Number.isFinite(item.score))
.sort((a, b) => practiceTimeValue(b.time) - practiceTimeValue(a.time))
.slice(0, 5)
.reverse()
.map((item, index) => ({
key: `${item.id || item.sessionId || item.time}-${index}`,
label: trainingTrendLabel(item.time, index),
value: Math.max(0, Math.min(100, item.score))
}));
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘'; export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export type ReviewFilter = 'all' | 'low' | 'pending';
export const filterReviewRecords = (reviews: PracticeRecord[], filter: ReviewFilter) => {
if (filter === 'low') return reviews.filter((item) => item.score < 80);
if (filter === 'pending') return reviews.filter((item) => isReviewEditable(item.status));
return reviews;
};
export const isSupervisorAccessDenied = (error: unknown) => { export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error const message = error instanceof Error
? error.message ? error.message
@@ -131,14 +157,14 @@ export const buildSupervisorMetrics = (
return [ return [
{ {
key: 'pendingReviews', key: 'pendingReviews',
title: '当前待复盘', title: '待复盘',
value: pendingReviews, value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录', desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true primary: true
}, },
{ {
key: 'pendingAssignments', key: 'pendingAssignments',
title: '当前待训练', title: '待训练',
value: pendingAssignments, value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项' desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
}, },
@@ -271,12 +297,41 @@ export const buildTeamProfiles = (
|| (a.latestScore ?? 100) - (b.latestScore ?? 100)); || (a.latestScore ?? 100) - (b.latestScore ?? 100));
}; };
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
profiles: T[],
query: string
) => {
const keyword = query.trim().toLowerCase();
if (!keyword) return profiles;
return profiles.filter((item) =>
item.displayName.toLowerCase().includes(keyword)
|| item.extPartyId.toLowerCase().includes(keyword));
};
export const paginateTeamProfiles = <T>(profiles: T[], requestedPage: number, pageSize: number) => {
const safeSize = Math.max(1, Math.floor(pageSize));
const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize));
const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage)));
return {
items: profiles.slice((page - 1) * safeSize, page * safeSize),
page,
pageCount
};
};
function identitySuffix(value: string) { function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`; if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`; if (value.length > 8) return `末尾${value.slice(-6)}`;
return value; return value;
} }
function trainingTrendLabel(value: string, index: number) {
const parts = value.match(/\d+/g) || [];
if (parts.length >= 3 && parts[0].length === 4) return `${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;
if (parts.length >= 2) return `${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
return `第${index + 1}次`;
}
function practiceTimeValue(value: string) { function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || []; const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) { if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
+12 -1
View File
@@ -1,8 +1,11 @@
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-regular.css';
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-bold.css';
page { page {
min-height: 100%; min-height: 100%;
background: #f3f5f7; background: #f3f5f7;
color: #182230; color: #182230;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; font-family: "LXGW WenKai TC", -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
} }
:root { :root {
@@ -32,6 +35,7 @@ page {
--employee-card-radius: 24px; --employee-card-radius: 24px;
--employee-chip-radius: 18px; --employee-chip-radius: 18px;
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08); --employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
--app-bottom-nav-space: calc(82px + env(safe-area-inset-bottom));
} }
view, view,
@@ -298,6 +302,13 @@ textarea:focus-visible {
background: var(--brand-pressed); background: var(--brand-pressed);
} }
.tabbar-safe-action {
position: sticky;
bottom: var(--app-bottom-nav-space);
z-index: 12;
box-shadow: 0 10px 24px rgba(230, 0, 18, 0.2);
}
uni-tabbar.uni-tabbar-bottom, uni-tabbar.uni-tabbar-bottom,
uni-tabbar.uni-tabbar-bottom .uni-tabbar { uni-tabbar.uni-tabbar-bottom .uni-tabbar {
border-top: 1px solid var(--line) !important; border-top: 1px solid var(--line) !important;
+6
View File
@@ -302,6 +302,9 @@ export interface PracticeAssignment {
scenarioName: string; scenarioName: string;
source: string; source: string;
reason: string; reason: string;
taskGoal?: string;
taskRequirements?: string;
dueTime?: string;
status: string; status: string;
createTime: string; createTime: string;
question?: string; question?: string;
@@ -355,6 +358,9 @@ export interface PracticeAssignmentRequest {
scenarioName?: string; scenarioName?: string;
source?: string; source?: string;
reason?: string; reason?: string;
taskGoal?: string;
taskRequirements?: string;
dueTime?: string;
} }
export interface DailyDrillAnswerResponse { export interface DailyDrillAnswerResponse {
@@ -37,6 +37,33 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
assert.match(source, /turns\.value = previousTurns/); assert.match(source, /turns\.value = previousTurns/);
}); });
test('训练结果和 SOP 答案生成后重置到内容首屏', async () => {
const [practiceSource, sopSource] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/user/sop/index.vue')
]);
assert.match(practiceSource, /result\.value = await finishPractice\([\s\S]*?resetPageScroll\(\)/);
assert.match(sopSource, /result\.value = await searchKnowledge\(value\);\s*resetPageScroll\(\)/);
});
test('训练历史按摘要、逐句复盘、SOP 依据和行动建议分层', async () => {
const source = await pageSource('../src/pages/user/history/index.vue');
assert.match(source, /训练摘要/);
assert.match(source, /录音与逐句复盘/);
assert.match(source, /SOP 依据与改进建议/);
assert.match(source, /推荐话术/);
});
test('案例页为无权限和无案例提供明确空状态', async () => {
const source = await pageSource('../src/pages/user/cases/index.vue');
assert.match(source, /class="case-empty-state"/);
assert.match(source, /暂时还没有可学习案例/);
assert.match(source, /提交权限由组织角色决定/);
});
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => { test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
const source = await pageSource('../src/manifest.json'); const source = await pageSource('../src/manifest.json');
@@ -66,6 +93,8 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => { test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css'); const source = await pageSource('../src/styles/base.css');
assert.match(source, /lxgw-wenkai-tc-webfont/);
assert.match(source, /font-family:\s*"LXGW WenKai TC"/);
assert.match(source, /--employee-primary:\s*#e60012/); assert.match(source, /--employee-primary:\s*#e60012/);
assert.match(source, /--employee-card-radius:\s*24px/); assert.match(source, /--employee-card-radius:\s*24px/);
assert.match(source, /\.employee-card\s*\{/); assert.match(source, /\.employee-card\s*\{/);
@@ -143,6 +172,21 @@ test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
assert.match(source, /summary-panel/); assert.match(source, /summary-panel/);
}); });
test('SOP 没解决先收集结构化原因再提交', async () => {
const [source, service] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/services/knowledge.ts')
]);
assert.match(source, /feedbackReasonOptions/);
assert.match(source, /negative-feedback-panel/);
assert.match(source, /submitNegativeFeedback/);
assert.match(source, /reasonCodes:\s*selectedFeedbackReasons\.value/);
assert.match(source, /comment:\s*feedbackComment\.value\.trim\(\)/);
assert.match(service, /reasonCodes\?:\s*string\[\]/);
assert.match(service, /comment\?:\s*string/);
});
test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => { test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue'); const source = await pageSource('../src/pages/user/practice/index.vue');
@@ -156,6 +200,23 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /practiceView === 'prep'/); assert.match(source, /practiceView === 'prep'/);
assert.match(source, /practiceView === 'active'/); assert.match(source, /practiceView === 'active'/);
assert.match(source, /practiceView\.value === 'result'/); assert.match(source, /practiceView\.value === 'result'/);
assert.match(source, /result\.score\?\.length/);
assert.match(source, /class="result-dimensions"/);
});
test('底部固定导航页面复用安全操作区', async () => {
const [base, practice, assign, review] = await Promise.all([
pageSource('../src/styles/base.css'),
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/supervisor/assign/index.vue'),
pageSource('../src/pages/supervisor/review/index.vue')
]);
assert.match(base, /--app-bottom-nav-space/);
assert.match(base, /\.tabbar-safe-action\s*\{/);
assert.match(practice, /primary-button tabbar-safe-action/);
assert.match(assign, /primary-button submit-button tabbar-safe-action/);
assert.match(review, /primary-button submit-button tabbar-safe-action/);
}); });
test('员工练习页显示当前岗位而不是固定生活顾问', async () => { test('员工练习页显示当前岗位而不是固定生活顾问', async () => {
+85 -1
View File
@@ -21,13 +21,18 @@ const {
actionablePracticeAlerts, actionablePracticeAlerts,
buildSupervisorMetrics, buildSupervisorMetrics,
buildTeamProfiles, buildTeamProfiles,
buildTrainingTrend,
createAsyncGenerationGuard, createAsyncGenerationGuard,
filterReviewRecords,
filterTeamProfiles,
inferSupervisorScenarioId, inferSupervisorScenarioId,
isReviewEditable, isReviewEditable,
isSupervisorAccessDenied, isSupervisorAccessDenied,
paginateTeamProfiles,
reviewCompletionAction, reviewCompletionAction,
supervisorScenarioOptions supervisorScenarioOptions
} = supervisorView; } = supervisorView;
const { maskPhone } = await vite.ssrLoadModule('/src/services/auth.ts');
const assignPageSource = await readFile( const assignPageSource = await readFile(
new URL('../src/pages/supervisor/assign/index.vue', import.meta.url), new URL('../src/pages/supervisor/assign/index.vue', import.meta.url),
@@ -152,6 +157,20 @@ test('低于 80 分为高风险,有待复盘为中风险,其余为低风险'
assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low'); assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low');
}); });
test('待复盘列表支持全部、低分和待处理筛选', () => {
const rows = [
review({ id: 1, score: 68, status: '待复盘' }),
review({ id: 2, score: 88, status: '待复盘' }),
review({ id: 3, score: 92, status: '已复盘' })
];
assert.equal(filterReviewRecords(rows, 'all').length, 3);
assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]);
assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]);
assert.match(reviewsPageSource, /reviewFilters/);
assert.match(reviewsPageSource, /visibleReviews/);
});
test('主管页面共享三个业务场景及文本推荐规则', () => { test('主管页面共享三个业务场景及文本推荐规则', () => {
assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [ assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [
'fee-parking', 'fee-parking',
@@ -159,7 +178,8 @@ test('主管页面共享三个业务场景及文本推荐规则', () => {
'repair-elevator-delay' 'repair-elevator-delay'
]); ]);
assert.match(practiceServiceSource, /getPracticeScenarios/); assert.match(practiceServiceSource, /getPracticeScenarios/);
assert.match(assignPageSource, /v-for="item in scenarioOptions"/); assert.match(assignPageSource, /v-for="item in filteredScenarioOptions"/);
assert.match(assignPageSource, /scenarioSearch/);
assert.match(assignPageSource, /暂无可派发的已启用场景/); assert.match(assignPageSource, /暂无可派发的已启用场景/);
assert.match(reviewPageSource, /scenarioOptions/); assert.match(reviewPageSource, /scenarioOptions/);
assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/); assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/);
@@ -185,6 +205,20 @@ test('stable 提示不计入团队预警指标', () => {
]).find((item) => item.key === 'alerts')?.value, 0); ]).find((item) => item.key === 'alerts')?.value, 0);
}); });
test('主管工作台展示真实训练趋势并压缩预警首屏密度', () => {
const trend = buildTrainingTrend([
review({ id: 1, time: '2026-07-15 09:00:00', score: 70 }),
review({ id: 2, time: '2026-07-16 09:00:00', score: 82 }),
review({ id: 3, time: '2026-07-17 09:00:00', score: 91 })
]);
assert.deepEqual(trend.map((item) => item.value), [70, 82, 91]);
assert.match(dashboardPageSource, /class="panel training-trend-panel"/);
assert.match(dashboardPageSource, /trainingTrend/);
assert.match(dashboardPageSource, /visibleAlerts/);
assert.doesNotMatch(dashboardPageSource, /<text class="metric-desc"/);
});
test('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => { test('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => {
const profiles = buildTeamProfiles([ const profiles = buildTeamProfiles([
review({ review({
@@ -223,6 +257,38 @@ test('组织快照中的稳定成员即使没有训练记录也保留在画像
assert.equal(profiles[0].risk, 'low'); assert.equal(profiles[0].risk, 'low');
}); });
test('团队画像搜索和分页不把 2316 名成员一次性渲染', () => {
const profiles = Array.from({ length: 2316 }, (_, index) => ({
key: `member-${index}`,
extPartyId: `employee-${index}`,
displayName: `员工 ${index}`
}));
assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20);
assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116);
assert.deepEqual(
filterTeamProfiles(profiles, '员工 2315').map((item) => item.key),
['member-2315']
);
assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/);
assert.match(teamPageSource, /v-if="selectedMember" class="panel member-detail-panel"/);
assert.match(teamPageSource, /<template v-else>[\s\S]*class="panel team-panel"/);
});
test('员工和主管个人页只展示脱敏手机号', async () => {
const [employeeProfile, supervisorProfile] = await Promise.all([
readFile(new URL('../src/pages/user/profile/index.vue', import.meta.url), 'utf8'),
readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8')
]);
assert.equal(maskPhone('181368856126'), '181****6126');
assert.equal(maskPhone('invalid'), 'invalid');
assert.match(employeeProfile, /displayPhone/);
assert.match(supervisorProfile, /displayPhone/);
assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/);
assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/);
});
test('组织快照姓名不可读时用稳定身份标识展示', () => { test('组织快照姓名不可读时用稳定身份标识展示', () => {
const profiles = buildTeamProfiles([], [], [ const profiles = buildTeamProfiles([], [], [
{ extPartyId: '13900001111', name: '???????' }, { extPartyId: '13900001111', name: '???????' },
@@ -275,6 +341,10 @@ test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
assert.match(reviewPageSource, /v-else class="panel review-complete"/); assert.match(reviewPageSource, /v-else class="panel review-complete"/);
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/); 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\(\)/); 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\(\)/);
assert.match(reviewPageSource, /训练摘要/);
assert.match(reviewPageSource, /录音与逐句复盘/);
assert.match(reviewPageSource, /SOP 依据与改进建议/);
assert.match(reviewPageSource, /下一步行动/);
}); });
test('复盘详情登录和完成导航不堆叠页面', () => { test('复盘详情登录和完成导航不堆叠页面', () => {
@@ -339,6 +409,20 @@ test('指派页通过团队快照在进入时校验主管权限', () => {
assert.doesNotMatch(assignPageSource, /getPracticeAssignments/); assert.doesNotMatch(assignPageSource, /getPracticeAssignments/);
}); });
test('指派表单和派发进度使用同页分段视图避免堆成长页', () => {
assert.match(assignPageSource, /assignView/);
assert.match(assignPageSource, /v-if="assignView === 'create'" class="panel assignment-form"/);
assert.match(assignPageSource, /v-if="assignView === 'progress'" class="panel history-panel"/);
assert.match(assignPageSource, /class="assign-tabs"/);
assert.match(assignPageSource, /memberSuggestions/);
assert.match(assignPageSource, /selectTarget/);
assert.match(assignPageSource, /class="target-suggestions"/);
assert.match(assignPageSource, /assignmentDueTime/);
assert.match(assignPageSource, /assignmentGoal/);
assert.match(assignPageSource, /assignmentRequirements/);
assert.match(assignPageSource, /item\.dueTime/);
});
test('指派页保留 H5 查询参数二次解码', () => { test('指派页保留 H5 查询参数二次解码', () => {
assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/); assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/);
}); });
+17 -3
View File
@@ -102,8 +102,8 @@ CREATE TABLE aihr_knowledge_answer_feedback (
SQL SQL
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"
feedback_review_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_knowledge_answer_feedback' AND column_name IN ('review_id','reviewer','reviewed_time');")" feedback_review_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_knowledge_answer_feedback' AND column_name IN ('feedback_reason_codes','feedback_comment','review_id','reviewer','reviewed_time');")"
test "$feedback_review_columns" = 'review_id,reviewer,reviewed_time' test "$feedback_review_columns" = 'feedback_reason_codes,feedback_comment,review_id,reviewer,reviewed_time'
mysql "$DB" <<'SQL' mysql "$DB" <<'SQL'
CREATE TABLE aihr_practice_session ( CREATE TABLE aihr_practice_session (
@@ -119,6 +119,20 @@ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_practice_sessio
practice_session_evidence_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_session' AND column_name IN ('annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment');")" practice_session_evidence_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_session' AND column_name IN ('annotations_json','review_advice','incentive_point','satisfaction_score','satisfaction_comment');")"
test "$practice_session_evidence_columns" = '5' test "$practice_session_evidence_columns" = '5'
mysql "$DB" <<'SQL'
CREATE TABLE aihr_practice_assignment (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(20) DEFAULT '000000',
reason varchar(500) DEFAULT NULL,
status varchar(30) NOT NULL DEFAULT '待训练',
PRIMARY KEY (id)
) ENGINE=InnoDB;
SQL
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_assignment_contract_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_assignment_contract_mysql8.sql"
practice_assignment_contract_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_practice_assignment' AND column_name IN ('task_goal','task_requirements','due_time');")"
test "$practice_assignment_contract_columns" = 'task_goal,task_requirements,due_time'
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql"
mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql"
evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")" evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")"
@@ -185,4 +199,4 @@ test "$candidate_interview_tables" = 'aihr_candidate_employee_link,aihr_candidat
candidate_interview_columns="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$LEGACY_DB' AND ((table_name = 'aihr_candidate_material' AND column_name IN ('reviewer','reviewed_time')) OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json','reviewed_score','review_status','reviewer','review_note','reviewed_time')));")" candidate_interview_columns="$(mysql "$LEGACY_DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$LEGACY_DB' AND ((table_name = 'aihr_candidate_material' AND column_name IN ('reviewer','reviewed_time')) OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json','reviewed_score','review_status','reviewer','review_note','reviewed_time')));")"
test "$candidate_interview_columns" = '8' test "$candidate_interview_columns" = '8'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, and position/SOP qualification contracts" echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, assignment task fields, knowledge feedback, candidate interview review, and position/SOP qualification contracts"