Merge branch 'codex/brd-release-20260715' into codex/multi-tenant-knowledge-platform
# Conflicts: # AGENTS.md # README.md # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java # backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java # backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java # backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java # backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java # docs/20260708/uni-app用户侧前端重建计划.md # docs/API_INTEGRATION.md # docs/BRD_IMPLEMENTATION_AUDIT.md # docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md # docs/DEMO_ACCEPTANCE.md # docs/DEV_SETUP.md # docs/FIGMA_IMPLEMENTATION_MATRIX.md # docs/README.md # frontend/src/views/knowledge/processing.vue # mobile-uni/src/pages/supervisor/assign/index.vue # mobile-uni/src/pages/supervisor/index/index.vue # mobile-uni/src/pages/supervisor/profile/index.vue # mobile-uni/src/pages/supervisor/review/index.vue # mobile-uni/src/pages/supervisor/reviews/index.vue # mobile-uni/src/pages/supervisor/team/index.vue # mobile-uni/src/pages/user/cases/index.vue # mobile-uni/src/pages/user/practice/index.vue # mobile-uni/src/pages/user/profile/index.vue # mobile-uni/src/pages/user/sop/index.vue # mobile-uni/src/pages/user/today/index.vue # mobile-uni/src/types/api.ts # scripts/tests/aihr-schema-migrations.test.sh
This commit is contained in:
+2
-2
@@ -38,13 +38,13 @@ public final class AihrSopDto {
|
||||
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 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) {
|
||||
|
||||
+63
-9
@@ -72,11 +72,15 @@ public class AihrOrgSyncService {
|
||||
@Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
|
||||
private boolean storeDisplayFields;
|
||||
|
||||
@Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}")
|
||||
private String configuredPositionMapJson;
|
||||
|
||||
public SyncResponse sync(SyncRequest request) {
|
||||
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request;
|
||||
if (req.dryRun() == null) {
|
||||
throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入");
|
||||
}
|
||||
Map<String, PositionMapping> positionMappings = positionMappings();
|
||||
String baseUrl = normalizeBaseUrl(configuredBaseUrl);
|
||||
if (baseUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
|
||||
@@ -108,7 +112,7 @@ public class AihrOrgSyncService {
|
||||
int maskedPhone = 0;
|
||||
int phoneLinked = 0;
|
||||
for (JsonNode employee : employeeItems) {
|
||||
List<OrgRow> employeeRows = orgRows(employee, companies, departments, projects, assignmentGroups);
|
||||
List<OrgRow> employeeRows = orgRows(employee, companies, departments, projects, assignmentGroups, positionMappings);
|
||||
if (employeeRows.isEmpty()) {
|
||||
skipped++;
|
||||
} else {
|
||||
@@ -124,6 +128,13 @@ public class AihrOrgSyncService {
|
||||
int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count();
|
||||
int duplicateMemberships = countDuplicateMemberships(rows);
|
||||
int mappedEmployees = employeeItems.size() - skipped;
|
||||
if (!positionMappings.isEmpty()) {
|
||||
int mappedPositions = (int) rows.stream().filter(OrgRow::positionMapped).count();
|
||||
int supervisors = (int) rows.stream()
|
||||
.filter(row -> "主管".equals(row.positionLevel()) || "项目经理".equals(row.positionLevel()))
|
||||
.count();
|
||||
warnings.add("HR 岗位映射命中 " + mappedPositions + " 人,解析主管/项目经理 " + supervisors + " 人,请核对后再写入");
|
||||
}
|
||||
if (mappedEmployees > 0 && phoneLinked < mappedEmployees) {
|
||||
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + mappedEmployees + ",移动端正式登录仅可映射已带手机号人员");
|
||||
}
|
||||
@@ -394,7 +405,8 @@ public class AihrOrgSyncService {
|
||||
}
|
||||
|
||||
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments,
|
||||
Map<String, ProjectInfo> projects, Map<String, AssignmentInfo> assignments) {
|
||||
Map<String, ProjectInfo> projects, Map<String, AssignmentInfo> assignments,
|
||||
Map<String, PositionMapping> positionMappings) {
|
||||
String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo"));
|
||||
String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId"));
|
||||
String extPartyId = firstNonBlank(employeeNumber, employeeId);
|
||||
@@ -447,13 +459,18 @@ public class AihrOrgSyncService {
|
||||
"组织架构"
|
||||
);
|
||||
String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-");
|
||||
String positionName = firstNonBlank(
|
||||
assignment == null ? "" : assignment.positionName(),
|
||||
text(employee, "project_position_name", "projectPositionName", "position_name", "positionName", "job_title", "jobTitle",
|
||||
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工");
|
||||
String assignmentPositionName = assignment == null ? "" : assignment.positionName();
|
||||
String externalPositionName = text(employee, "project_position_name", "projectPositionName", "position_name", "positionName",
|
||||
"job_title", "jobTitle", "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title");
|
||||
PositionMapping mappedPosition = positionMappings.get(firstNonBlank(
|
||||
text(employee, "position_id", "positionId", "job_id", "jobId", "post_id", "postId")));
|
||||
boolean positionMapped = assignmentPositionName.isBlank() && externalPositionName.isBlank() && mappedPosition != null;
|
||||
String positionName = firstNonBlank(assignmentPositionName, externalPositionName,
|
||||
mappedPosition == null ? "" : mappedPosition.name(), "员工");
|
||||
String positionLevel = firstNonBlank(
|
||||
assignment == null ? "" : assignment.positionLevel(),
|
||||
text(employee, "project_position_level", "projectPositionLevel", "position_level", "positionLevel", "job_level", "jobLevel"),
|
||||
positionMapped ? mappedPosition.level() : "",
|
||||
level(positionName)
|
||||
);
|
||||
LocalDate hireDate = parseDate(firstNonBlank(
|
||||
@@ -471,6 +488,7 @@ public class AihrOrgSyncService {
|
||||
storeDisplayFields ? personName : "",
|
||||
positionName,
|
||||
positionLevel(positionLevel, positionName),
|
||||
positionMapped,
|
||||
status(text(employee, "status", "employment_status", "employmentStatus")),
|
||||
hireDate,
|
||||
LocalDate.now(),
|
||||
@@ -480,7 +498,8 @@ public class AihrOrgSyncService {
|
||||
|
||||
private List<OrgRow> orgRows(JsonNode employee, Map<String, CompanyInfo> companies,
|
||||
Map<String, DepartmentInfo> departments, Map<String, ProjectInfo> projects,
|
||||
Map<String, List<AssignmentInfo>> assignmentGroups) {
|
||||
Map<String, List<AssignmentInfo>> assignmentGroups,
|
||||
Map<String, PositionMapping> positionMappings) {
|
||||
String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo"));
|
||||
String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId"));
|
||||
String extPartyId = firstNonBlank(employeeNumber, employeeId);
|
||||
@@ -489,7 +508,7 @@ public class AihrOrgSyncService {
|
||||
}
|
||||
List<AssignmentInfo> assignments = assignmentList(assignmentGroups, employeeId, employeeNumber, extPartyId);
|
||||
if (assignments.isEmpty()) {
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, Map.of());
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, Map.of(), positionMappings);
|
||||
return row == null ? List.of() : List.of(row);
|
||||
}
|
||||
List<OrgRow> rows = new ArrayList<>();
|
||||
@@ -498,7 +517,7 @@ public class AihrOrgSyncService {
|
||||
putAssignment(selected, employeeId, assignment);
|
||||
putAssignment(selected, employeeNumber, assignment);
|
||||
putAssignment(selected, extPartyId, assignment);
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, selected);
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, selected, positionMappings);
|
||||
if (row != null) {
|
||||
rows.add(row);
|
||||
}
|
||||
@@ -822,6 +841,37 @@ public class AihrOrgSyncService {
|
||||
return level;
|
||||
}
|
||||
|
||||
private Map<String, PositionMapping> positionMappings() {
|
||||
String json = clean(configuredPositionMapJson);
|
||||
if (json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
if (!root.isObject()) {
|
||||
throw new IllegalArgumentException("岗位映射必须是 JSON 对象");
|
||||
}
|
||||
Map<String, PositionMapping> mappings = new TreeMap<>();
|
||||
var fields = root.fields();
|
||||
while (fields.hasNext()) {
|
||||
var entry = fields.next();
|
||||
String positionId = clean(entry.getKey());
|
||||
String name = text(entry.getValue(), "name", "positionName", "position_name");
|
||||
String level = text(entry.getValue(), "level", "positionLevel", "position_level");
|
||||
if (positionId.isBlank() || name.isBlank() || name.length() > 100
|
||||
|| !("一线".equals(level) || "主管".equals(level) || "项目经理".equals(level))) {
|
||||
throw new IllegalArgumentException("岗位映射必须包含 position_id、岗位名称及合法层级(一线/主管/项目经理)");
|
||||
}
|
||||
mappings.put(positionId, new PositionMapping(name, level));
|
||||
}
|
||||
return Map.copyOf(mappings);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("岗位映射 JSON 配置无效", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String phone(JsonNode employee) {
|
||||
for (String field : PHONE_FIELDS) {
|
||||
String normalized = normalizeMobilePhone(text(employee, field));
|
||||
@@ -1003,6 +1053,9 @@ public class AihrOrgSyncService {
|
||||
) {
|
||||
}
|
||||
|
||||
private record PositionMapping(String name, String level) {
|
||||
}
|
||||
|
||||
private record OrgRow(
|
||||
String projectCode,
|
||||
String projectName,
|
||||
@@ -1012,6 +1065,7 @@ public class AihrOrgSyncService {
|
||||
String personName,
|
||||
String positionName,
|
||||
String positionLevel,
|
||||
boolean positionMapped,
|
||||
String employmentStatus,
|
||||
LocalDate hireDate,
|
||||
LocalDate snapshotDate,
|
||||
|
||||
+36
-3
@@ -111,6 +111,9 @@ public class AihrSopSeedService {
|
||||
private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
|
||||
private static final int MAX_EVIDENCE_SNIPPETS = 3;
|
||||
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_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
|
||||
+ "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;"
|
||||
@@ -353,6 +356,14 @@ public class AihrSopSeedService {
|
||||
if (isBlank(verdict)) {
|
||||
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);
|
||||
if (fragmentIds.isEmpty()) {
|
||||
throw new ServiceException("缺少片段ID");
|
||||
@@ -366,11 +377,13 @@ public class AihrSopSeedService {
|
||||
for (Long fragmentId : fragmentIds) {
|
||||
saved += jdbcTemplate.update("""
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
query_text = VALUES(query_text),
|
||||
verdict = VALUES(verdict),
|
||||
feedback_reason_codes = VALUES(feedback_reason_codes),
|
||||
feedback_comment = VALUES(feedback_comment),
|
||||
category = VALUES(category),
|
||||
position = VALUES(position),
|
||||
source = VALUES(source),
|
||||
@@ -383,6 +396,8 @@ public class AihrSopSeedService {
|
||||
normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
|
||||
fragmentId,
|
||||
verdict,
|
||||
feedbackReasonCodes,
|
||||
feedbackComment,
|
||||
firstNonBlank(request.category(), "sop"),
|
||||
firstNonBlank(request.position(), "生活顾问"),
|
||||
normalizeSearchSource(request.source()),
|
||||
@@ -400,7 +415,7 @@ public class AihrSopSeedService {
|
||||
String reviewStatus = firstNonBlank(status, "待复核");
|
||||
int limitValue = Math.max(1, Math.min(limit, 200));
|
||||
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
|
||||
WHERE tenant_id = ?
|
||||
AND (? = '' OR review_status = ?)
|
||||
@@ -412,6 +427,8 @@ public class AihrSopSeedService {
|
||||
rs.getString("query_norm"),
|
||||
rs.getLong("fragment_id"),
|
||||
rs.getString("verdict"),
|
||||
rs.getString("feedback_reason_codes"),
|
||||
rs.getString("feedback_comment"),
|
||||
rs.getString("category"),
|
||||
rs.getString("position"),
|
||||
rs.getString("source"),
|
||||
@@ -2570,6 +2587,8 @@ public class AihrSopSeedService {
|
||||
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
|
||||
`fragment_id` bigint NOT NULL COMMENT '知识片段ID',
|
||||
`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 '知识分类',
|
||||
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
@@ -2586,6 +2605,8 @@ public class AihrSopSeedService {
|
||||
) 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", "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", "reviewed_time", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间' AFTER `reviewer`");
|
||||
answerFeedbackTableReady = true;
|
||||
@@ -2653,6 +2674,18 @@ public class AihrSopSeedService {
|
||||
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) {
|
||||
LinkedHashSet<Long> ids = new LinkedHashSet<>();
|
||||
if (request != null && request.fragmentIds() != null) {
|
||||
|
||||
+32
-6
@@ -79,13 +79,13 @@ public class AihrOrgSyncServiceTest {
|
||||
""");
|
||||
|
||||
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
|
||||
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
|
||||
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of(), Map.of());
|
||||
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
|
||||
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
|
||||
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate"));
|
||||
|
||||
ReflectionTestUtils.setField(service, "storeDisplayFields", true);
|
||||
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
|
||||
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of(), Map.of());
|
||||
assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName"));
|
||||
assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName"));
|
||||
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate"));
|
||||
@@ -112,7 +112,7 @@ public class AihrOrgSyncServiceTest {
|
||||
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(),
|
||||
ReflectionTestUtils.invokeMethod(service, "departmentMap", departments),
|
||||
ReflectionTestUtils.invokeMethod(service, "projectMap", projects),
|
||||
ReflectionTestUtils.invokeMethod(service, "assignmentMap", assignments));
|
||||
ReflectionTestUtils.invokeMethod(service, "assignmentMap", assignments), Map.of());
|
||||
|
||||
assertEquals("FW001", ReflectionTestUtils.getField(row, "projectCode"));
|
||||
assertEquals("翡翠湾", ReflectionTestUtils.getField(row, "projectName"));
|
||||
@@ -143,7 +143,7 @@ public class AihrOrgSyncServiceTest {
|
||||
|
||||
Object groups = ReflectionTestUtils.invokeMethod(service, "assignmentGroups", assignments);
|
||||
List<?> rows = ReflectionTestUtils.invokeMethod(service, "orgRows", employee,
|
||||
Map.of(), Map.of(), Map.of(), groups);
|
||||
Map.of(), Map.of(), Map.of(), groups, Map.of());
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(List.of("FW001", "YSF001"), rows.stream()
|
||||
@@ -159,11 +159,37 @@ public class AihrOrgSyncServiceTest {
|
||||
{"id":"EMP-1","project_name":"翡翠湾","position_name":"项目经理","position_level":2,"phone":"13900001111"}
|
||||
""");
|
||||
|
||||
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
|
||||
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of(), Map.of());
|
||||
|
||||
assertEquals("项目经理", ReflectionTestUtils.getField(row, "positionLevel"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredPositionMappingFillsMissingPositionWithoutOverridingAssignments() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
AihrOrgSyncService service = new AihrOrgSyncService(mapper, mock(JdbcTemplate.class), mock(TransactionTemplate.class));
|
||||
ReflectionTestUtils.setField(service, "configuredPositionMapJson",
|
||||
"{\"POS-1\":{\"name\":\"项目经理\",\"level\":\"项目经理\"}}");
|
||||
Object mappings = ReflectionTestUtils.invokeMethod(service, "positionMappings");
|
||||
Object row = ReflectionTestUtils.invokeMethod(service, "orgRow",
|
||||
mapper.readTree("{\"id\":\"EMP-1\",\"position_id\":\"POS-1\"}"),
|
||||
Map.of(), Map.of(), Map.of(), Map.of(), mappings);
|
||||
|
||||
assertEquals("项目经理", ReflectionTestUtils.getField(row, "positionName"));
|
||||
assertEquals("项目经理", ReflectionTestUtils.getField(row, "positionLevel"));
|
||||
assertEquals(true, ReflectionTestUtils.getField(row, "positionMapped"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredPositionMappingRejectsPrivilegeLevelsOutsideTheContract() {
|
||||
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
|
||||
ReflectionTestUtils.setField(service, "configuredPositionMapJson",
|
||||
"{\"POS-1\":{\"name\":\"项目经理\",\"level\":\"超级管理员\"}}");
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ReflectionTestUtils.invokeMethod(service, "positionMappings"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void suspectTextIsCheckedBeforeDisplayFieldsAreMasked() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
@@ -173,7 +199,7 @@ public class AihrOrgSyncServiceTest {
|
||||
""");
|
||||
|
||||
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
|
||||
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of());
|
||||
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of(), Map.of(), Map.of());
|
||||
|
||||
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
|
||||
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
|
||||
|
||||
+18
@@ -369,6 +369,24 @@ public class AihrSopSeedServiceTest {
|
||||
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
|
||||
@Tag("dev")
|
||||
public void ossMetadataQueriesKeepTenantScope() throws Exception {
|
||||
|
||||
@@ -118,6 +118,9 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
|
||||
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
|
||||
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
|
||||
`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 '状态',
|
||||
`due_date` date DEFAULT NULL COMMENT '训练截止日期',
|
||||
`request_key` varchar(100) DEFAULT NULL COMMENT '客户端幂等请求ID',
|
||||
|
||||
@@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` (
|
||||
`query_norm` varchar(500) NOT NULL COMMENT '规范化问题',
|
||||
`fragment_id` bigint NOT NULL COMMENT '知识片段ID',
|
||||
`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 '知识分类',
|
||||
`position` varchar(80) DEFAULT NULL 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;
|
||||
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 := (
|
||||
SELECT COUNT(*)
|
||||
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;
|
||||
@@ -0,0 +1,91 @@
|
||||
position_id,员工总数,在职人数,覆盖公司数,岗位名称,岗位层级,是否主管,HR签认人,签认日期,备注
|
||||
316,2437,2001,1,,,,,,
|
||||
319,487,479,1,,,,,,
|
||||
318,110,103,1,,,,,,
|
||||
317,111,102,1,,,,,,
|
||||
1685,30,30,1,,,,,,
|
||||
1683,19,19,6,,,,,,
|
||||
296,17,17,1,,,,,,
|
||||
1680,16,15,1,,,,,,
|
||||
1686,15,15,1,,,,,,
|
||||
1684,11,11,1,,,,,,
|
||||
1687,10,10,1,,,,,,
|
||||
4,9,9,1,,,,,,
|
||||
303,7,7,1,,,,,,
|
||||
307,11,6,1,,,,,,
|
||||
321,6,6,1,,,,,,
|
||||
310,7,5,1,,,,,,
|
||||
309,5,5,1,,,,,,
|
||||
314,5,5,1,,,,,,
|
||||
323,5,5,1,,,,,,
|
||||
1682,4,4,1,,,,,,
|
||||
304,5,3,1,,,,,,
|
||||
335,4,3,1,,,,,,
|
||||
1,3,3,1,,,,,,
|
||||
324,3,3,1,,,,,,
|
||||
332,3,3,1,,,,,,
|
||||
337,3,2,1,,,,,,
|
||||
1722,2,2,1,,,,,,
|
||||
299,2,2,1,,,,,,
|
||||
301,2,2,1,,,,,,
|
||||
320,2,2,1,,,,,,
|
||||
326,2,2,1,,,,,,
|
||||
328,2,2,1,,,,,,
|
||||
329,2,2,1,,,,,,
|
||||
331,2,2,1,,,,,,
|
||||
343,2,2,1,,,,,,
|
||||
344,2,2,1,,,,,,
|
||||
1681,1,1,1,,,,,,
|
||||
1710,1,1,1,,,,,,
|
||||
1711,1,1,1,,,,,,
|
||||
1712,1,1,1,,,,,,
|
||||
1713,1,1,1,,,,,,
|
||||
1714,1,1,1,,,,,,
|
||||
1715,1,1,1,,,,,,
|
||||
1716,1,1,1,,,,,,
|
||||
1717,1,1,1,,,,,,
|
||||
1718,1,1,1,,,,,,
|
||||
1719,1,1,1,,,,,,
|
||||
1720,1,1,1,,,,,,
|
||||
1721,1,1,1,,,,,,
|
||||
1723,1,1,1,,,,,,
|
||||
1724,1,1,1,,,,,,
|
||||
1725,1,1,1,,,,,,
|
||||
1726,1,1,1,,,,,,
|
||||
1727,1,1,1,,,,,,
|
||||
1728,1,1,1,,,,,,
|
||||
1729,1,1,1,,,,,,
|
||||
1730,1,1,1,,,,,,
|
||||
1731,1,1,1,,,,,,
|
||||
1732,1,1,1,,,,,,
|
||||
1733,1,1,1,,,,,,
|
||||
1734,1,1,1,,,,,,
|
||||
1735,1,1,1,,,,,,
|
||||
1736,1,1,1,,,,,,
|
||||
1737,1,1,1,,,,,,
|
||||
1738,1,1,1,,,,,,
|
||||
1739,1,1,1,,,,,,
|
||||
1740,1,1,1,,,,,,
|
||||
1741,1,1,1,,,,,,
|
||||
2,1,1,1,,,,,,
|
||||
297,1,1,1,,,,,,
|
||||
298,1,1,1,,,,,,
|
||||
3,1,1,1,,,,,,
|
||||
300,1,1,1,,,,,,
|
||||
302,1,1,1,,,,,,
|
||||
306,1,1,1,,,,,,
|
||||
308,1,1,1,,,,,,
|
||||
311,1,1,1,,,,,,
|
||||
313,1,1,1,,,,,,
|
||||
322,1,1,1,,,,,,
|
||||
325,1,1,1,,,,,,
|
||||
327,1,1,1,,,,,,
|
||||
330,1,1,1,,,,,,
|
||||
333,1,1,1,,,,,,
|
||||
334,1,1,1,,,,,,
|
||||
338,1,1,1,,,,,,
|
||||
340,1,1,1,,,,,,
|
||||
341,1,1,1,,,,,,
|
||||
6,1,1,1,,,,,,
|
||||
312,1,0,1,,,,,,
|
||||
395,1,0,1,,,,,,
|
||||
|
@@ -0,0 +1,28 @@
|
||||
# BRD 对齐批次发布执行记录
|
||||
|
||||
- 发布窗口:2026-07-15 16:00–17:00 CST
|
||||
- 执行人:Codex 当前任务(负责备份、同步、重启与发布后复检)
|
||||
- 回滚人:Codex 当前任务(任一步失败时使用本次备份恢复,不在当天重复发布)
|
||||
- 发布分支:`codex/brd-release-20260715`
|
||||
- 组成提交:`a42f4209`、`55ca4fa7`、`c861977e`、`09492db9`
|
||||
- 发布范围:管理端静态资源、移动端 H5、`ruoyi-admin.jar`
|
||||
- 数据库范围:本批次不包含 SQL 迁移,不执行生产数据库结构或业务数据写入
|
||||
|
||||
## 发布门禁
|
||||
|
||||
1. 从本文件所在的最终提交重新构建管理端、移动端 H5 和后端 JAR。
|
||||
2. 干净工作区执行不带 remote 开关的 `./scripts/release-preflight.sh`。
|
||||
3. 先备份 `/opt/wygj/www`、`/opt/wygj/www/h5` 和 `/opt/wygj/app/ruoyi-admin.jar`,再同步产物并重启 `wygj-aihr.service`。
|
||||
4. 发布后执行 remote match、后端模块 hash、schema 三项只读复检;任一失败立即按本次备份回滚。
|
||||
5. 认证态员工、主管、管理端回归与非 strict `demo-check` 结果回写 `BRD_IMPLEMENTATION_AUDIT.md`;不得把发布成功写成正式试点验收完成。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 最终发布提交:`f0175253`
|
||||
- 生产备份:`/opt/wygj/backups/brd-align-20260715160117`
|
||||
- 回滚保留目录:`/opt/wygj/www.pre-f0175253`
|
||||
- 远端复检:管理端/H5 资源匹配、后端整包与 `ruoyi-aihr` 模块匹配、schema `16/16`、服务 active
|
||||
- 管理端认证态:通过;资料处理页只保留批量导入/刷新并显示 97 页任务
|
||||
- 员工认证态:使用生产现有固定码配置通过;未达到“正式短信 OTP”口径
|
||||
- 主管认证态:未通过;外部快照缺岗位名称/字典,生产主管数为 0,转入计划 B11
|
||||
- 非 strict 检查:新增 `FRONTEND_API_PREFIX=prod-api` 后通过生产根站、H5、租户、验证码和源码契约检查;样本统计来自本机 Docker,生产数据缺口以审计记录中的生产只读查询为准
|
||||
@@ -0,0 +1,103 @@
|
||||
# 生产 H5 UI 回归与 Figma 识图对比报告(2026-07-17,最终复验)
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本轮使用同一项目的 1 名一线员工(`PILOT-03`)和 1 名项目主管(`PILOT-01`)登录生产 H5,完成员工端 10 个页面/状态、主管端 8 个页面/状态的真实浏览器截图。每张生产截图生成后均读取对应 Figma 正式画板并进行识图比较。
|
||||
|
||||
最终结论:**本报告列出的 P0、P1 和设计覆盖问题均已关闭,生产 H5 高保真 UI 回归验收通过。**
|
||||
|
||||
通过口径包括:
|
||||
|
||||
- 页面结构、信息层级、主操作、底部安全区、字体、空状态和隐私展示符合对应 Figma 设计意图。
|
||||
- 训练、SOP 反馈和主管专项指派均通过真实业务写入验证,不是静态页面演示。
|
||||
- 真实组织数据、动态文案长度、评分数量和无音频记录与 Figma 示例数据不同,不要求逐字或逐像素一致。
|
||||
- 本结论是本轮 UI 与交互回归通过,不替代正式试点窗口、短信 OTP、业务 KPI 等生产试点验收。
|
||||
|
||||
## 2. 测试范围与证据
|
||||
|
||||
- 生产入口:`https://peilian.njzhmj.top/h5/`
|
||||
- 浏览器视口:员工端 `400×886`,主管端 `400×927`
|
||||
- 测试身份:报告仅记录测试编号,不保存或展示完整手机号
|
||||
- Figma 文件:`30hFiKtieGYraOZvJPJcPd`
|
||||
- 生产证据目录:`.data/private/ui-regression-20260717-fixed/`
|
||||
- 隐私说明:证据目录被 Git 忽略,包含真实组织数据,不上传公开工单或外部图床
|
||||
- 最终前端提交:`b3dd2c2c`
|
||||
- 核心修复提交:`e93421ca`、`0745d51e`、`5d82ff7b`、`b3dd2c2c`
|
||||
|
||||
本轮真实写入:
|
||||
|
||||
1. 员工完成 1 次“客服应对投诉”训练,生成 71 分结果和主管待复盘记录。
|
||||
2. 员工提交 1 次 SOP“没解决”结构化反馈,原因为 `not_specific`,补充说明长度 21,数据库状态为“待复核”。
|
||||
3. 主管给该员工新增 1 个专项训练,目标、任务要求、截止时间和指派原因均真实落库;状态为“待训练”,截止时间为 `2026-07-17 23:59:59`。
|
||||
|
||||
## 3. 关键门槛复验
|
||||
|
||||
| 验收门槛 | 生产实测 | 结论 |
|
||||
|---|---:|---|
|
||||
| 训练结束自动回到结果顶部 | `scrollY=0`,首屏同时出现总分和 5 项维度分 | 通过 |
|
||||
| SOP 生成后进入回答区 | `scrollY=677`,`.answer-panel` 距视口顶部约 `16px` | 通过 |
|
||||
| 团队画像禁止一次渲染 2316 人 | 总数 2316,首屏 DOM 仅 20 个 `.team-row`,页面高度 2677px | 通过 |
|
||||
| 点击成员后立即显示能力详情 | 详情面板 1 个,列表行 0 个,`scrollY=0` | 通过 |
|
||||
| 员工/主管页面不得出现完整手机号 | 两个个人页完整手机号匹配数均为 0 | 通过 |
|
||||
| SOP 负反馈必须可诊断 | 原因多选、补充说明、提交按钮和数据库字段均有效 | 通过 |
|
||||
| 主管指派契约必须真实落库 | 截止时间、目标、要求、原因和进度页均可读 | 通过 |
|
||||
| 主按钮不得被底部导航遮挡 | 训练提交、SOP 反馈提交、专项确认按钮均完整位于安全区上方 | 通过 |
|
||||
|
||||
## 4. 员工端逐页识图结果
|
||||
|
||||
| 页面/状态 | 生产截图 | Figma 节点与截图 | 复验结论 |
|
||||
|---|---|---|---|
|
||||
| 今日 | `employee-today.png` | `23:3` / `figma-employee-today.png` | 师傅 Hero、问答卡、今日安排和我的地图均进入首屏;信息密度与底栏安全区通过。 |
|
||||
| 训练前预习 | `employee-practice-prep.png` | `53:2` / `figma-employee-practice-prep.png` | 场景、角色、要点、红线、话术和主按钮顺序一致;字体已统一。 |
|
||||
| 场景对练 | `employee-practice-active.png` | `54:2` / `figma-employee-practice-active.png` | 情绪/信任、对话上下文、回复区、求助和主操作层级清楚;主按钮不再被底栏遮挡。 |
|
||||
| 训练结果 | `employee-practice-result.png` | `55:2` / `figma-employee-practice-result.png` | 首屏直接显示 71 分、5 项维度和导师建议;旧版滚动位置残留问题关闭。 |
|
||||
| 问师傅答案 | `employee-sop-result-fixed.png` | `79:11` / `figma-employee-sop-result-fixed.png` | 结果生成后自动定位到“师傅回答”正文,旧版停留输入区问题关闭。 |
|
||||
| SOP 负反馈 | `employee-sop-feedback.png`、`employee-sop-feedback-submit.png` | `79:674` / `figma-employee-sop-feedback-fixed.png` | 5 类原因、多选、补充说明和提交操作完整;真实写入已核对。 |
|
||||
| 我的成长 | `employee-profile.png` | `178:959` / `figma-employee-profile.png` | 手机号脱敏、岗位切换、成长摘要、训练历史与证据入口完整。 |
|
||||
| 训练历史详情 | `employee-history-detail.png` | `56:2` / `figma-employee-history-detail.png` | 已改为训练摘要、维度分、录音/逐句复盘、SOP 与行动建议分层;文本训练无音频时不伪造播放器。 |
|
||||
| 案例素材空状态 | `employee-cases.png` | `178:958` / `figma-employee-cases-status.png` | 权限说明和“暂无可学习案例”空状态有正式画板,设计覆盖问题关闭。 |
|
||||
| 学习中心 | `employee-learning.png` | `178:2` / `figma-employee-learning.png` | 今日任务、错题本和岗前任务使用独立学习首页画板,旧版与案例详情错配问题关闭。 |
|
||||
|
||||
## 5. 主管端逐页识图结果
|
||||
|
||||
| 页面/状态 | 生产截图 | Figma 节点与截图 | 复验结论 |
|
||||
|---|---|---|---|
|
||||
| 主管今日工作台 | `supervisor-home.png` | `57:2` / `figma-supervisor-home.png` | 概览、低分预警、待复盘和最近训练趋势同屏可见;信息密度通过。 |
|
||||
| 待复盘列表 | `supervisor-reviews.png` | `58:2` / `figma-supervisor-reviews.png` | “全部/连续低分/待复盘”筛选和低分优先语义均存在。 |
|
||||
| 主管复盘详情 | `supervisor-review-detail.png` | `59:2` / `figma-supervisor-review-detail.png` | 摘要、维度、录音/逐句复盘、建议和行动按层级展示;文本记录不伪造音频。 |
|
||||
| 团队画像列表 | `supervisor-team-list.png` | 列表功能态;详情设计见 `61:2` | 搜索、20 人分页和风险排序有效,旧版 2316 行全量渲染问题关闭。 |
|
||||
| 员工能力详情 | `supervisor-team-detail.png` | `61:2` / `figma-supervisor-team-detail.png` | 点击搜索结果后立即切换为独立详情视图;趋势、成长证据和派发入口完整。 |
|
||||
| 训练任务派发 | `supervisor-assign.png`、`supervisor-assign-filled.png` | `61:530` / `figma-supervisor-assign.png` | 员工搜索、场景检索、目标、要求、截止时间、原因和安全区主按钮均有效。 |
|
||||
| 派发进度与预警 | `supervisor-assign-progress.png` | `62:2` / `figma-supervisor-assign-progress.png` | 与新建表单拆为独立视图;进度条、来源、截止/逾期和预警信息可读。 |
|
||||
| 我的主管工作台 | `supervisor-profile-fixed.png` | `178:960` / `figma-supervisor-profile.png` | 真实显示 2316 名成员、2 条待复盘、2 条风险预警和 3 个业务入口;手机号脱敏且大面积空白问题关闭。 |
|
||||
|
||||
## 6. Figma 与实现同步
|
||||
|
||||
本轮补齐并复验 4 个此前缺失的正式画板:
|
||||
|
||||
- `178:958`:员工端案例权限/空状态
|
||||
- `178:2`:员工端学习首页
|
||||
- `178:959`:员工端我的成长
|
||||
- `178:960`:主管端我的工作台
|
||||
|
||||
设计文件和 H5 统一使用 `LXGW WenKai TC`,代码端保留系统字体降级链;字体许可证随 H5 产物发布。
|
||||
|
||||
## 7. 工程与生产验证
|
||||
|
||||
- `vue-tsc --noEmit`:通过。
|
||||
- 移动端单测:68/68 通过。
|
||||
- H5 正式构建:通过。
|
||||
- 管理端正式构建:通过。
|
||||
- 后端 AIHR 全量 Maven 测试:通过。
|
||||
- 后端生产 JAR 构建:通过。
|
||||
- 数据库迁移验证:通过,生产 schema 预检 `16/16`。
|
||||
- 远端资源:管理端和移动端入口/主资源 SHA-256 与本地产物一致。
|
||||
- 后端:服务 active,公开移动端接口返回 200;已部署业务模块哈希与发布产物一致。
|
||||
|
||||
构建过程中仅出现第三方 Sass 旧 API 弃用提示,不影响本轮功能和 UI 验收;后续可单独作为依赖升级技术债处理。
|
||||
|
||||
## 8. 最终判定
|
||||
|
||||
**高保真 UI 回归验收:通过。**
|
||||
|
||||
后续若进入正式试点,仍需按正式窗口、唯一在职组织身份、正式短信 OTP、训练次数和业务 KPI 独立验收,不得用本报告替代试点验收结论。
|
||||
@@ -0,0 +1,130 @@
|
||||
# 下一步工作计划(BRD 对齐版)
|
||||
|
||||
> 版本:v2.6(2026-07-16 生产复核:开放平台岗位结构已接入,主管认证态已打通;B3 仍是圈定正式试点范围)
|
||||
> 基准:《物业AI人力资源系统业务需求文档BRD.md》v1.2(唯一事实源)
|
||||
> 阶段边界:《银城员工端APP分阶段实施总纲.md》阶段一(陪练)+ 2026-07-08《数字师傅学练问报整合方案》定案
|
||||
> 现状证据:《BRD_IMPLEMENTATION_AUDIT.md》2026-07-16 最新条目 + 生产 dry-run、增量同步、数据库与认证态复核
|
||||
> 时间锚点:半年会产品发布(须带一线真实使用数据)→ 2026-09 收费季前试点可用
|
||||
|
||||
---
|
||||
|
||||
## 0. 当前基线(2026-07-16 生产实测)
|
||||
|
||||
**已完成**(不再列入待办):
|
||||
- 生产已发布 `f0175253` 批次,线上管理端 = `index-CJmvDHQd.js`,H5 = `index-Bu6EO0TO.js`;发布预检通过资源/后端哈希、schema 与健康检查。
|
||||
- 生产 schema **16/16 齐备**,内置 Prompt **6/6**,生活顾问启用场景 **14 条**(含服务推介/增值 1、日常服务 1)。
|
||||
- **当前组织身份已线上闭环**:开放平台拉取公司 17 / 部门 960 / 员工 3417;`aihr_org_snapshot` 3417 行,手机号仍可映射 **3392**,在职 2938、离职 479。新快照已返回岗位名称与岗位层级;兼容数值层级后,在职一线 2743、主管 175、项目经理 20,默认“员工”岗位 0。真实在职主管手机号完成生产登录后,`/api/aihr/mobile/me` 返回主管端。
|
||||
|
||||
**真实缺口**(本计划的全部工作对象):
|
||||
- 正式试点证据全部为 0:评分校准 0/20、SOP 人工评审 0/14、案例 0/20、满意度 0、候选资料 0(L1-B/C/D)。
|
||||
- 主管身份认证已打通,但 C2 的“本项目可见、跨项目不可见”仍需结合 B3 正式试点项目做认证态验收。
|
||||
- SOP 文档仅 3/5,缺服务推介/增值与日常服务两类的真实业务内容确认(4.3.4、L1-B)。
|
||||
- 方言 ASR 零实测(9.1 MVP 阻塞项、L1-C ≤5s)。
|
||||
- 上游快照现对 3391 个有效手机号返回脱敏值,另有 25 人无有效手机号;本次按稳定 `id` 增量更新并保留了生产已有 3392 个手机号。当前登录不受影响,但新员工/换号无法仅靠脱敏快照补齐,需开放平台提供获授权的安全身份映射通道。
|
||||
- PII 出境条款、成本计量契约、HR 学分规则等外部输入未闭合(G3/G6、4.5)。
|
||||
|
||||
**结论:发布和主管身份打通已完成;当前最大阻塞是“试点圈定(B3)+内容/评审样本”。** 所有新增页面/功能默认冻结(守 MVP 切割线,BRD 附录 C 头号风险 = 范围失控)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主线 A:锁定发布批次,原子发布(已完成;A4 两项外部门禁除外)
|
||||
|
||||
目标:把 `eacf1a0a` 之后累积的修复(G3 脱敏补强、案例音频收紧、岗位-SOP 收口等)作为一个命名 release batch 发布,线上 = release commit。
|
||||
|
||||
| # | 任务 | 依据 | 完成定义 | 期限 |
|
||||
|---|---|---|---|---|
|
||||
| A1 | **已完成**:在隔离 worktree 收拢并形成 release commit `f0175253` | `release-preflight.sh` 拒绝任何脏工作区 | 发布 worktree 干净;release commit 落字 | 07-15 |
|
||||
| A2 | **已完成**:发布窗口、执行人、回滚人已写入 `RELEASE_EXECUTION_20260715.md` | 12.1、Runbook 前置条件 1 | 三项落字 | 07-15 |
|
||||
| A3 | **已完成**:干净构建、预检、DB/静态/后端备份、原子切换与服务重启 | 6.3、L1-C | 生产备份 `/opt/wygj/backups/brd-align-20260715160117`;服务 active | 07-15 |
|
||||
| A4 | **结构与主管认证态已完成,外部门禁待补**:远端资源/后端/schema 匹配,管理端、员工和主管认证态通过;主管本次仍使用生产试点固定码,不能替代正式 OTP 验收 | 10.2、`demo-check.sh` 参数约束 | 结构及主管身份有生产证据;关闭固定码后补正式 OTP,项目范围权限转 C2 | B3+1 天 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 主线 B:外部输入催办清单(今天发出,B3 为最大阻塞)
|
||||
|
||||
每项需在发出时填负责人与回复期限;到兜底触发日仍无答复即升级 HR 负责人(BRD 第 13 章"集成负责人指派"至今未闭合)。
|
||||
|
||||
| # | 需要的输入 | 提供方 | 负责人 | 回复期限 | 兜底触发日 | 阻塞的 BRD 条款 | 不给的后果 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| B3 | **确认正式试点范围(不是补录人员)**:从生产已同步的在职、手机号可登录人员中圈定 1–2 个住宅项目、≥20 名参与员工、每项目至少 1 名主管,并确认窗口起止日期;主管需与已同步岗位身份一致 | HR 负责人 | 待指派 | 07-18 | 07-22 | 10.2-D | 人员数据已就位,但未圈定参与范围和时间窗时,M5 指标仍无正式分母 |
|
||||
| B11 | **已完成(07-16):补齐岗位契约**。开放快照已返回 `position_id/position_name/position_level`;系统以稳定资源 `id` 作为人员主键,对数值层级按岗位名称归一为“一线/主管/项目经理”。生产同步后在职主管 175、项目经理 20,主管认证态通过;`AIHR_ORG_POSITION_MAP_JSON` 仅保留为上游再次缺字段时的签认兜底 | 开放平台+本项目 | 已完成 | 07-16 | — | 5.4、G3、L1-B | 岗位身份阻塞已解除;项目范围权限仍按 C2 验收 |
|
||||
| B2 | 补 2 份真实 SOP 文档(现 3/5),并由业务确认全部五类核心流程的成功条件、价格/取消规则、回访口径;随后完成 14 条场景/SOP 的人工评审(现 0/14) | 内容负责人/HR | 待指派 | 07-20 | 07-24 | 4.3.4、L1-B(SOP≥5) | SOP 覆盖与 RAG 可用率(≥80%)无法验收 |
|
||||
| B9 | **历史案例批量提供**:试点前 **≥20 条**历史案例语音/材料入库并脱敏审核,不依赖试点新增量补齐验收底线;试点期继续按"每周每项目 1 条"采集新案例 | 各项目负责人 | 待指派 | 07-22 | 07-25 | 4.6、L1-B(案例≥20) | 案例库验收必然失败 |
|
||||
| B4 | 方言 ASR 厂商选型(**阶段一只选 1 种方言**,按试点区域确认;BRD 9.1 只要求 1 条路径)+ 费用授权 + **同时确认 CER 达标阈值**(BRD 只定了 ≤5s 响应,识别准确率的验收线需业务拍板,否则 C3 实测无法判定"达标") | 技术组+HR | 待指派 | 07-20 | 07-24 | 4.3.3、9.1、L1-C | MVP 阻塞项零实测,且测了也无法判定通过 |
|
||||
| B5 | 案例讲解视频预渲染样片 1 条(半年会素材;**不阻塞案例上传闭环验收**——L0-4 只要求"语音上传→AI 整理") | 业务 | 待指派 | 07-31 | 08-05 | 4.6、9.1 | 半年会演示缺 1 条示范视频(止损线内最低配置) |
|
||||
| B6 | 大模型/ASR/TTS 供应商 PII 留存与跨境条款确认(音频本体、图片本体外发部分) | 法务/安全 | 待指派 | 07-24 | 07-31 | G3、G6、F3 | 生产前闸门无法关闭 |
|
||||
| B7 | 组织快照最小字段清单与保留期:生产实测姓名 0、部门 0(展示字段默认关已生效),但 `project_name` 仍保留 3417 行;需法务/安全核对 `project_name`、`person_phone` 等仍保留字段是否在最小清单内,及历史清理范围 | 法务/安全 | 待指派 | 07-24 | 07-31 | 5.4(只存 party_id 硬约束) | P0 存储边界无法关闭 |
|
||||
| B8 | 认证等级/学分规则契约(初/中/高级条件、学分来源与分值、防刷) | HR | 待指派 | 07-31 | 08-07 | 4.5、10.2-A | 成长页只能继续挂"规则待配置、不生效" |
|
||||
| B10 | 25 人无有效手机号 + 有效手机号在开放快照中被脱敏:反馈开放平台修数,并明确获授权的新员工/换号身份映射通道;现有 3392 个生产手机号已由增量同步保留 | 开放平台 | 待指派 | 07-31 | — | 5.4 | 当前 25 人无法登录;未来新增/换号人员无法只靠脱敏快照自动映射 |
|
||||
|
||||
追办顺序:**B3 → B9 → B2 → B4**,其余并行;B11 已完成。
|
||||
|
||||
---
|
||||
|
||||
## 3. 主线 C:工程收尾(小、快、不开新战线)
|
||||
|
||||
| # | 任务 | 依据 | 完成定义 | 期限 |
|
||||
|---|---|---|---|---|
|
||||
| C1 | "问师傅"文案对齐:`pages/user/today/index.vue` 的"语音提问,快速解答"**直接改为符合现状的文字**(语音问答入 backlog,届时复用 `speech.ts` + `/api/ai/asr`,不新增接口) | 4.7、2026-07-15 审计 | 首页承诺与 SOP 页能力一致 | 07-16(随 A1 批次发布) |
|
||||
| C2 | **主管身份认证已完成,项目数据权限待验收**:生产已有在职主管 175、项目经理 20,真实主管手机号登录返回主管端;待 B3 圈定项目后继续验证本项目团队可见、跨项目不可见、训练记录/待复盘按项目过滤 | 5.4、G3、L1-B | 主管身份已有生产证据;项目范围正反例留证写入审计记录 | B3+1 天 |
|
||||
| C3 | 方言实测(B4 选型后):**1 种方言**,固定样本集(≥20 条真实短音频),记录字错率(CER)与单轮响应 P95(≤5s,L1-C);CER 与 B4 确认的阈值比对——**阈值未确认前只记录数据、不判定达标** | 4.3.3、9.1、L1-C | 样本数、CER、P95 三项数据落审计记录;CER ≤ B4 阈值且 P95 ≤5s 才在验收表标"通过" | B4+3 天 |
|
||||
| C4 | LLM/ASR/TTS 调用计量:待 B6/财务给出计费字段与单价后,新增租户范围 `llm_call_log`(模块/模型/token/延迟/费用)+ 月度上限告警与自动降级;此前维持手动断路器,不硬编码价格 | 6.3、G6 | 有持久化计量与可配置上限 | B6 后一周内 |
|
||||
| C5 | 案例入库流水线支援 B9:批量导入入口复用现有上传/整理/筛选链路,脱敏门禁逐条通过;不为批量导入新做页面 | 4.6、L1-B | ≥20 条历史案例状态"已入库"且脱敏扫描 0 命中 | 07-25 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 主线 D:正式试点与 L1 验收(B3 就位后启动,目标 8 月中出证据包)
|
||||
|
||||
按 BRD 10.2 四组验收逐项闭合,全部以严格门禁 + 正式窗口数据为准,**禁止 seed/烟测/演示账号冒充**(审计第 5 节完成定义)。
|
||||
|
||||
| 周次 | 动作 | 闭合的验收项 |
|
||||
|---|---|---|
|
||||
| 试点第 0 周(预备) | 试点名单在组织快照中逐一核对(手机号可登录);SOP 5 流程入库 + 14 条评审完成(B2);**历史案例 ≥20 条入库审核完成(B9/C5)**;员工开通与培训动员(配 G4 激励沟通) | L1-B:SOP≥5、组织导入、项目级权限 |
|
||||
| 试点第 1–2 周 | ≥20 名员工真实对练(人均目标 10 次);每日一练推送;主管完成复盘与派发闭环;同步采集满意度(1–5 分);案例按"每周每项目 1 条"继续新增 | L1-A 育闭环;L1-D 人均次数/完训率/满意度 |
|
||||
| 试点第 2 周起 | 人工校准 ≥20 条,计算 AI/人工分档一致率(目标 ≥70%);统计 RAG 可用回答率(目标 ≥80%);抽查人工复核可否决 AI 分的证据(G1/G2) | L1-C 一致率/可用率;G1/G2 |
|
||||
| 试点收口 | 案例库 ≥20 条(存量+增量,脱敏门禁通过);`GET /api/train/practice/export` 按窗口导出正式 CSV;以试点窗口日期跑 `AIHR_PILOT_STRICT=true AIHR_PILOT_START_DATE=<起> AIHR_PILOT_END_DATE=<止> ./scripts/demo-check.sh` 全绿;形成 L1 验收证据包 | L1 全项;半年会"真实使用数据" |
|
||||
|
||||
**半年会演示止损线**(07-08 定案):只保「生活顾问的一天」主线,实操**问 + 练**两个模块;陪练 + 总结卡 + 1 条示范视频为最低配置,其余全部可砍。
|
||||
|
||||
---
|
||||
|
||||
## 5. 明确不做(守边界,防范围失控)
|
||||
|
||||
| 项 | 依据 |
|
||||
|---|---|
|
||||
| 个人 AI 助理/个人知识空间(文字/图片/文件/链接收藏、混合问答、PPT) | BRD 4.8 明确阶段二;有独立 TechSpec 与推进计划 |
|
||||
| 员工端图片/视频训练输入(4.3.3 终态缺口) | 《AI陪练二期开发推进计划》约定文本场景稳定后独立评估;届时只落有业务目的的场景(拍照报修、品检) |
|
||||
| 开放问题榜、积分/排行榜/荣誉墙运营化 | 总纲阶段二;07-08 定案排行榜默认关 |
|
||||
| 师徒关系/拜师池/带徒福利 | 业务已否决(07-08) |
|
||||
| G5 情绪树洞 | 业务/法务/安全决策未定,在匿名边界与零落库合同明确前保持未实现,不用陪练情绪数据冒充 |
|
||||
| 企微/钉钉/工单集成、SSO、知识图谱、多项目类型 | D2/D4/D8 既定决策不变 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 里程碑总览
|
||||
|
||||
```
|
||||
07-15(已完成) A1-A3 发布 + A4 结构回归 + C1 文案修改
|
||||
07-16(已完成) B11 上游岗位结构接入 + 增量同步 + 主管身份认证
|
||||
07-17 跟进 B3 负责人和回复期限落字
|
||||
07-18 起 C2 项目权限正反例验收(依赖 B3 圈定试点项目)
|
||||
07-18~07-25 B2 SOP 补齐与评审 / B9+C5 历史案例 ≥20 条入库 / C3 方言实测(依赖 B4)
|
||||
07-28~08-08 D 试点窗口(1–2 项目、≥20 人、2 周;以 B3 实际答复为准)
|
||||
08-11~08-15 L1 证据包收口:strict 门禁全绿(带试点窗口日期)+ CSV 导出
|
||||
半年会前 「生活顾问的一天」演示彩排 ×2 + 录屏兜底(沿用 BRD 11.2 彩排纪律)
|
||||
2026-09 前 收费季试点可用状态确认
|
||||
```
|
||||
|
||||
## 7. 风险与止损
|
||||
|
||||
1. **B3 试点圈定拖延是当前最大单点风险**(组织数据已就位,决策成本已最低):07-22 仍无答复,升级 HR 负责人;试点每晚一周,半年会"真实使用数据"窗口就少一周。
|
||||
2. **手机号增量维护**:上游快照已补岗位,但对 3391 个有效手机号返回脱敏值。现有 3392 个映射已通过稳定 `id` 增量同步保留;新增/换号人员在安全身份映射通道就位前不能自动补齐,禁止强制全量覆盖。
|
||||
3. **案例 20 条**:依赖 B9 存量导入;若试点前不足 20 条,立即提高采集频率(每项目每周 2–3 条)并如实下调收口预期,不用未脱敏或演示数据凑数。
|
||||
4. **发布失败**:A3 原子流程任一步失败即按备份回滚,当天不重试第二次,先归因。
|
||||
5. **范围失控**(BRD 附录 C 头号风险):任何新页面/新功能请求一律进 `docs/BACKLOG.md`;每周对照本计划复盘一次偏差。
|
||||
6. **方言不达标**:若 CER/P95 不达标,按 BRD 9.2 止损——保"1 条方言路径可演示",验收表如实标注差距,不虚标通过。
|
||||
7. **计划保鲜**:《BRD_IMPLEMENTATION_AUDIT.md》条目非严格时序,引用前须以"生产只读复核 + 线上实测"确认基线;本计划每次执行前先校验第 0 节事实是否仍成立。
|
||||
|
||||
---
|
||||
|
||||
*本计划由工程侧起草,v2.6 已按 2026-07-16 组织结构增量同步与主管认证结果修订;B 表负责人/期限需业务方确认后生效。执行进展回写《BRD_IMPLEMENTATION_AUDIT.md》,本文件只保留当前计划。*
|
||||
@@ -86,6 +86,8 @@ export type AnswerFeedbackItem = {
|
||||
queryNorm: string;
|
||||
fragmentId: number;
|
||||
verdict: string;
|
||||
feedbackReasonCodes?: string | null;
|
||||
feedbackComment?: string | null;
|
||||
category: string;
|
||||
position: string;
|
||||
source: string;
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
<span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span>
|
||||
</template>
|
||||
</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="createTime" label="提交时间" width="170" />
|
||||
<el-table-column prop="reviewer" label="复核人" width="120" />
|
||||
|
||||
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
|
||||
"@dcloudio/uni-ui": "^1.5.12",
|
||||
"lxgw-wenkai-tc-webfont": "^1.0.0",
|
||||
"vue": "3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -5816,6 +5817,12 @@
|
||||
"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": {
|
||||
"version": "0.30.11",
|
||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
|
||||
"@dcloudio/uni-ui": "^1.5.12",
|
||||
"lxgw-wenkai-tc-webfont": "^1.0.0",
|
||||
"vue": "3.5.30"
|
||||
},
|
||||
"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.
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="panel summary-panel">
|
||||
<text class="section-kicker">训练摘要</text>
|
||||
<view class="summary-head">
|
||||
<view>
|
||||
<text class="detail-title">{{ detail.scene }}</text>
|
||||
@@ -33,17 +34,17 @@
|
||||
|
||||
<view v-if="detail.scoreItems?.length" class="panel">
|
||||
<text class="panel-title">评分明细</text>
|
||||
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
|
||||
<view>
|
||||
<view class="score-summary-grid">
|
||||
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
|
||||
<text class="score-label">{{ item.label }}</text>
|
||||
<text class="score-value">{{ item.value }}</text>
|
||||
<text class="score-note">{{ item.note }}</text>
|
||||
</view>
|
||||
<text class="score-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detail.dialogue?.length" class="panel">
|
||||
<text class="panel-title">对话记录</text>
|
||||
<text class="panel-title">录音与逐句复盘</text>
|
||||
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
|
||||
<text class="dialogue-label">{{ turn.label }}</text>
|
||||
<text class="dialogue-text">{{ turn.text }}</text>
|
||||
@@ -58,7 +59,7 @@
|
||||
</view>
|
||||
|
||||
<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">
|
||||
<text class="annotation-issue">{{ item.issue }}</text>
|
||||
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
|
||||
@@ -264,11 +265,24 @@ onUnload(releaseAudio);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
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-note,
|
||||
@@ -346,4 +360,13 @@ onUnload(releaseAudio);
|
||||
.login-button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -234,3 +234,6 @@ export const consumeLoginRedirect = () => {
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -214,6 +214,8 @@ export const submitAnswerFeedback = (params: {
|
||||
fragmentIds: number[];
|
||||
verdict: AnswerFeedbackVerdict;
|
||||
reviewId?: number | null;
|
||||
reasonCodes?: string[];
|
||||
comment?: string;
|
||||
}) =>
|
||||
apiRequest<{ saved: number }>({
|
||||
url: '/api/knowledge/answer-feedback',
|
||||
@@ -225,7 +227,9 @@ export const submitAnswerFeedback = (params: {
|
||||
category: 'sop',
|
||||
position: getSelectedPosition(),
|
||||
source: 'mobile_uni_sop',
|
||||
reviewId: params.reviewId ?? null
|
||||
reviewId: params.reviewId ?? null,
|
||||
reasonCodes: params.reasonCodes || [],
|
||||
comment: params.comment || ''
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
@@ -14,6 +14,37 @@ export const resetPageScroll = () => {
|
||||
setTimeout(scrollTop, 240);
|
||||
};
|
||||
|
||||
export const scrollPageToSelector = (selector: string, offset = 16) => {
|
||||
const scrollToTarget = () => {
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
const scrollTop = Math.max(0, window.scrollY + element.getBoundingClientRect().top - offset);
|
||||
uni.pageScrollTo({ scrollTop, duration: 0 });
|
||||
window.scrollTo({ top: scrollTop, left: 0, behavior: 'auto' });
|
||||
document.documentElement.scrollTop = scrollTop;
|
||||
document.body.scrollTop = scrollTop;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uni.createSelectorQuery()
|
||||
.select(selector)
|
||||
.boundingClientRect((rect) => {
|
||||
const target = Array.isArray(rect) ? rect[0] : rect;
|
||||
if (target && typeof target.top === 'number') {
|
||||
uni.pageScrollTo({ scrollTop: Math.max(0, target.top - offset), duration: 0 });
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
};
|
||||
|
||||
scrollToTarget();
|
||||
setTimeout(scrollToTarget, 0);
|
||||
setTimeout(scrollToTarget, 80);
|
||||
setTimeout(scrollToTarget, 240);
|
||||
};
|
||||
|
||||
const switchToToday = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface SupervisorMetric {
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface TrainingTrendPoint {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TeamProfile {
|
||||
key: string;
|
||||
extPartyId: string;
|
||||
@@ -98,8 +104,28 @@ export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
|
||||
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
|
||||
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 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) => {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
@@ -131,14 +157,14 @@ export const buildSupervisorMetrics = (
|
||||
return [
|
||||
{
|
||||
key: 'pendingReviews',
|
||||
title: '当前待复盘',
|
||||
title: '待复盘',
|
||||
value: pendingReviews,
|
||||
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
key: 'pendingAssignments',
|
||||
title: '当前待训练',
|
||||
title: '待训练',
|
||||
value: pendingAssignments,
|
||||
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
|
||||
},
|
||||
@@ -271,12 +297,42 @@ export const buildTeamProfiles = (
|
||||
|| (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) {
|
||||
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
|
||||
if (value.length > 8) return `末尾${value.slice(-6)}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function trainingTrendLabel(value: string, index: number) {
|
||||
const parts = value.match(/\d+/g) || [];
|
||||
const [first = '', second = '', third = ''] = parts;
|
||||
if (parts.length >= 3 && first.length === 4) return `${second.padStart(2, '0')}-${third.padStart(2, '0')}`;
|
||||
if (parts.length >= 2) return `${first.padStart(2, '0')}-${second.padStart(2, '0')}`;
|
||||
return `第${index + 1}次`;
|
||||
}
|
||||
|
||||
function practiceTimeValue(value: string) {
|
||||
const parts = value.match(/\d+/g)?.map(Number) || [];
|
||||
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
|
||||
@@ -284,6 +340,6 @@ function practiceTimeValue(value: string) {
|
||||
}
|
||||
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
|
||||
while (normalized.length < 6) normalized.push(0);
|
||||
const [year, month, day, hour, minute, second] = normalized;
|
||||
const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = normalized;
|
||||
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-regular.css';
|
||||
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-bold.css';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: #f3f5f7;
|
||||
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 {
|
||||
@@ -32,6 +35,7 @@ page {
|
||||
--employee-card-radius: 24px;
|
||||
--employee-chip-radius: 18px;
|
||||
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
|
||||
--app-bottom-nav-space: calc(82px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
html[data-aihr-font-size='standard'] {
|
||||
@@ -303,6 +307,13 @@ textarea:focus-visible {
|
||||
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 {
|
||||
border-top: 1px solid var(--line) !important;
|
||||
|
||||
@@ -40,6 +40,36 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
|
||||
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
|
||||
});
|
||||
|
||||
test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => {
|
||||
const [practiceSource, sopSource, navigationSource] = await Promise.all([
|
||||
pageSource('../src/pages/user/practice/index.vue'),
|
||||
pageSource('../src/pages/user/sop/index.vue'),
|
||||
pageSource('../src/services/navigation.ts')
|
||||
]);
|
||||
|
||||
assert.match(practiceSource, /result\.value = await finishPractice\([\s\S]*?resetPageScroll\(\)/);
|
||||
assert.match(sopSource, /import \{ resetPageScroll, scrollPageToSelector \} from '@\/services\/navigation';/);
|
||||
assert.match(sopSource, /result\.value = await searchKnowledge\(value\);\s*scrollPageToSelector\('\.answer-panel'/);
|
||||
assert.match(navigationSource, /export const scrollPageToSelector = \(selector: string/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const source = await pageSource('../src/manifest.json');
|
||||
|
||||
@@ -69,6 +99,8 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
|
||||
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
|
||||
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-card-radius:\s*24px/);
|
||||
assert.match(source, /\.employee-card\s*\{/);
|
||||
@@ -190,6 +222,21 @@ test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
|
||||
assert.match(source, /TEAM_PRACTICE_SUMMARY/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
@@ -203,6 +250,23 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
|
||||
assert.match(source, /practiceView === 'prep'/);
|
||||
assert.match(source, /practiceView === 'active'/);
|
||||
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 () => {
|
||||
|
||||
@@ -21,13 +21,18 @@ const {
|
||||
actionablePracticeAlerts,
|
||||
buildSupervisorMetrics,
|
||||
buildTeamProfiles,
|
||||
buildTrainingTrend,
|
||||
createAsyncGenerationGuard,
|
||||
filterReviewRecords,
|
||||
filterTeamProfiles,
|
||||
inferSupervisorScenarioId,
|
||||
isReviewEditable,
|
||||
isSupervisorAccessDenied,
|
||||
paginateTeamProfiles,
|
||||
reviewCompletionAction,
|
||||
supervisorScenarioOptions
|
||||
} = supervisorView;
|
||||
const { maskPhone } = await vite.ssrLoadModule('/src/services/auth.ts');
|
||||
|
||||
const assignPageSource = await readFile(
|
||||
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');
|
||||
});
|
||||
|
||||
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('主管页面共享三个业务场景及文本推荐规则', () => {
|
||||
assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [
|
||||
'fee-parking',
|
||||
@@ -159,7 +178,8 @@ test('主管页面共享三个业务场景及文本推荐规则', () => {
|
||||
'repair-elevator-delay'
|
||||
]);
|
||||
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(reviewPageSource, /scenarioOptions/);
|
||||
assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/);
|
||||
@@ -207,6 +227,20 @@ test('stable 提示不计入团队预警指标', () => {
|
||||
]).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('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => {
|
||||
const profiles = buildTeamProfiles([
|
||||
review({
|
||||
@@ -245,6 +279,51 @@ test('组织快照中的稳定成员即使没有训练记录也保留在画像
|
||||
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('主管个人工作台展示真实指标和三项业务入口', async () => {
|
||||
const source = await readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /\{\{ loading \? '--' : teamCount \}\}/);
|
||||
assert.match(source, /\{\{ loading \? '--' : pendingReviewCount \}\}/);
|
||||
assert.match(source, /\{\{ loading \? '--' : alertCount \}\}/);
|
||||
assert.match(source, /主管业务入口/);
|
||||
assert.match(source, /\/pages\/supervisor\/team\/index/);
|
||||
assert.match(source, /\/pages\/supervisor\/reviews\/index/);
|
||||
assert.match(source, /\/pages\/supervisor\/assign\/index/);
|
||||
assert.match(source, /getPracticeTeamSnapshot/);
|
||||
});
|
||||
|
||||
test('组织快照姓名不可读时用稳定身份标识展示', () => {
|
||||
const profiles = buildTeamProfiles([], [], [
|
||||
{ extPartyId: '13900001111', name: '???????' },
|
||||
@@ -297,6 +376,10 @@ test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
|
||||
assert.match(reviewPageSource, /v-else class="panel review-complete"/);
|
||||
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/);
|
||||
assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/);
|
||||
assert.match(reviewPageSource, /训练摘要/);
|
||||
assert.match(reviewPageSource, /录音与逐句复盘/);
|
||||
assert.match(reviewPageSource, /SOP 依据与改进建议/);
|
||||
assert.match(reviewPageSource, /下一步行动/);
|
||||
});
|
||||
|
||||
test('复盘详情登录和完成导航不堆叠页面', () => {
|
||||
@@ -361,6 +444,20 @@ test('指派页通过团队快照在进入时校验主管权限', () => {
|
||||
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 查询参数二次解码', () => {
|
||||
assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FRONTEND_URL="${FRONTEND_URL:-https://wygj-admin.localhost}"
|
||||
MOBILE_URL="${MOBILE_URL:-https://wygj-mobile-uni.localhost}"
|
||||
FRONTEND_API_PREFIX="${FRONTEND_API_PREFIX:-dev-api}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
@@ -363,6 +364,7 @@ if [[ "${AIHR_DEMO_CHECK_LIBRARY_ONLY:-false}" == "true" ]]; then
|
||||
fi
|
||||
|
||||
need curl
|
||||
[[ "$FRONTEND_API_PREFIX" =~ ^[A-Za-z0-9_-]+$ ]] || fail "invalid FRONTEND_API_PREFIX: $FRONTEND_API_PREFIX"
|
||||
|
||||
check_url "frontend" "$FRONTEND_URL/"
|
||||
check_url "mobile-uni" "$MOBILE_URL/h5/"
|
||||
@@ -371,11 +373,11 @@ contains docs/DEMO_ACCEPTANCE.md "capture-demo-screenshots.mjs"
|
||||
contains scripts/capture-demo-screenshots.mjs "Page.captureScreenshot"
|
||||
contains scripts/capture-demo-screenshots.mjs "wygj-ai-interview-flow.png"
|
||||
|
||||
tenant_payload="$(curl -k -fsS "$FRONTEND_URL/dev-api/auth/tenant/list")"
|
||||
tenant_payload="$(curl -k -fsS "$FRONTEND_URL/$FRONTEND_API_PREFIX/auth/tenant/list")"
|
||||
[[ "$tenant_payload" == *'"tenantId":"000000"'* ]] || fail "tenant list missing tenant 000000"
|
||||
echo "OK: tenant list"
|
||||
|
||||
code_payload="$(curl -k -fsS "$FRONTEND_URL/dev-api/auth/code")"
|
||||
code_payload="$(curl -k -fsS "$FRONTEND_URL/$FRONTEND_API_PREFIX/auth/code")"
|
||||
[[ "$code_payload" == *'"uuid"'* ]] || fail "captcha response missing uuid"
|
||||
echo "OK: captcha"
|
||||
|
||||
@@ -721,7 +723,7 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "AihrMultipartFiles.withOriginalFilename"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteUploadedOssQuietly"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "AihrMultipartFiles.withOriginalFilename"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "o.tenant_id = a.tenant_id"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "binary o.tenant_id = binary a.tenant_id"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "where tenant_id = ? and oss_id = ? limit 1"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteQdrantTenantPoints()"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/points/count"
|
||||
@@ -735,7 +737,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "!modelService.visionAllowed()"
|
||||
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java '!environment.acceptsProfiles(Profiles.of("prod"))'
|
||||
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java "shouldUseFixedSmsCode"
|
||||
contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java "smsProdFixedCodeEnabled"
|
||||
contains backend/ruoyi-admin/src/test/java/org/dromara/web/controller/CaptchaControllerSecurityTest.java "CaptchaControllerSecurityTest"
|
||||
contains backend/ruoyi-admin/src/main/resources/application-dev.yml "AIHR_MYSQL_PORT:13306"
|
||||
contains backend/ruoyi-admin/src/main/resources/application-dev.yml "AIHR_REDIS_PORT:16379"
|
||||
|
||||
@@ -24,6 +24,7 @@ assert_contains 'AIHR_PILOT_START_DATE'
|
||||
assert_contains 'AIHR_PILOT_END_DATE'
|
||||
assert_contains 'AIHR_PILOT_TENANT_ID'
|
||||
assert_contains 'AIHR_DEMO_CHECK_LIBRARY_ONLY'
|
||||
assert_contains 'FRONTEND_API_PREFIX'
|
||||
assert_contains 'safe_org AS'
|
||||
assert_contains 'HAVING SUM(s.session_count) >= 10'
|
||||
assert_contains 'HAVING COUNT(DISTINCT ext_party_id) = 1'
|
||||
|
||||
Reference in New Issue
Block a user