feat(practice): complete five-position training operations

This commit is contained in:
2026-07-24 00:26:27 +08:00
parent 067cc30d80
commit faa2a77e9a
15 changed files with 1357 additions and 77 deletions
@@ -3,10 +3,12 @@ package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.AssignmentCancelRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
@@ -14,12 +16,18 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -66,8 +74,9 @@ public class AihrPracticeController {
@GetMapping("/scenarios")
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
@RequestParam(required = false) String scenarioType) {
return R.ok(practiceSeedService.scenarios(position, scenarioType));
@RequestParam(required = false) String scenarioType,
@RequestParam(required = false, defaultValue = "false") boolean includeDisabled) {
return R.ok(practiceSeedService.scenarios(position, scenarioType, includeDisabled && currentOperatorCanManageContent()));
}
@GetMapping("/scenarios/{id}")
@@ -87,6 +96,49 @@ public class AihrPracticeController {
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/questions")
public R<List<PracticeQuestionResponse>> questions(@RequestParam(required = false) String position,
@RequestParam(required = false) String type,
@RequestParam(required = false, defaultValue = "true") boolean includeDisabled) {
return R.ok(practiceSeedService.questions(position, type, includeDisabled));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/questions")
public R<PracticeQuestionResponse> saveQuestion(@RequestBody PracticeQuestionSaveRequest request) {
return R.ok(practiceSeedService.saveQuestion(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/questions/{code}/enabled")
public R<Void> updateQuestionEnabled(@PathVariable String code, @RequestBody PracticeQuestionEnabledRequest request) {
return practiceSeedService.updateQuestionEnabled(code, request) ? R.ok() : R.fail("训练题目不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/assignments/manage")
public R<List<PracticeAssignmentResponse>> manageAssignments(@RequestParam(required = false) String position,
@RequestParam(required = false) String status,
@RequestParam(required = false) String source,
@RequestParam(required = false) String keyword,
@RequestParam(required = false, defaultValue = "100") Integer limit) {
return R.ok(practiceSeedService.manageAssignments(position, status, source, keyword, limit == null ? 100 : limit));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/assignments/manage/batch")
public R<PracticeAssignmentBatchResponse> createManagedAssignments(@RequestBody PracticeAssignmentBatchRequest request) {
return R.ok(practiceSeedService.createAssignments(request, ""));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/assignments/manage/{id}/cancel")
public R<Void> cancelAssignment(@PathVariable Long id, @RequestBody(required = false) AssignmentCancelRequest request) {
return practiceSeedService.cancelAssignment(id, request == null ? null : request.reason())
? R.ok() : R.fail("任务不存在、已完成或不能撤回");
}
@GetMapping("/scenarios/{id}/prep-card")
public R<PrepCardResponse> prepCard(@PathVariable String id) {
return R.ok(practiceSeedService.prepCard(id));
@@ -229,4 +281,8 @@ public class AihrPracticeController {
}
return loginUser.getUsername().trim();
}
private static boolean currentOperatorCanManageContent() {
return StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE);
}
}
@@ -18,6 +18,7 @@ public final class AihrPracticeDto {
String customerText,
Integer emotion,
Integer trust,
Integer roundCount,
String customerAudioUrl,
PersonaResponse persona
) {
@@ -44,10 +45,21 @@ public final class AihrPracticeDto {
String strategy,
String goal,
Boolean enabled,
RubricResponse rubric
RubricResponse rubric,
List<ScenarioRoundResponse> rounds,
String contentVersion,
String contentHash
) {
}
/**
* A fixed, auditable training beat. The product only supports a small
* dialogue script today, so keeping the four runtime fields explicit is
* safer than accepting arbitrary JSON from the operations console.
*/
public record ScenarioRoundResponse(String customer, String coachHint, Integer emotion, Integer trust) {
}
public record ScenarioSaveRequest(
String id,
String name,
@@ -65,7 +77,8 @@ public final class AihrPracticeDto {
String successCriteria,
String strategy,
String goal,
Boolean enabled
Boolean enabled,
List<ScenarioRoundResponse> rounds
) {
}
@@ -104,6 +117,39 @@ public final class AihrPracticeDto {
public record RubricEnabledRequest(Boolean enabled) {
}
public record PracticeQuestionResponse(
String code,
String scenarioCode,
String scenarioName,
String positionCode,
String type,
String question,
String referenceAnswer,
String contentVersion,
String contentHash,
Boolean enabled,
Integer sortOrder
) {
}
public record PracticeQuestionSaveRequest(
String code,
String scenarioCode,
String positionCode,
String type,
String question,
String referenceAnswer,
Boolean enabled,
Integer sortOrder
) {
}
public record PracticeQuestionEnabledRequest(Boolean enabled) {
}
public record AssignmentCancelRequest(String reason) {
}
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -162,9 +163,21 @@ public class AihrPracticeLlmService {
* /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。
*/
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue) {
return score(scenarioName, goal, strategy, dialogue, List.of());
}
/**
* The model still returns the stable five machine-readable metrics, while
* operations can tune the descriptions and weights of those metrics per
* scenario. This preserves analytics compatibility and makes the rubric
* used for a session auditable by the caller.
*/
public Optional<PracticeScore> score(String scenarioName, String goal, String strategy, List<DialogueResponse> dialogue,
List<RubricDimensionResponse> rubric) {
if (dialogue.stream().noneMatch(turn -> "trainee".equals(turn.role()) && !turn.text().isBlank())) {
return Optional.empty();
}
String rubricText = renderRubric(rubric);
String system = """
你是物业行业培训考官(prompt版本 %s)。根据对练记录给员工话术评分。
评分锚点:60=及格线(有明显缺失);75=合格(覆盖主要要点);90=优秀(完整且超预期)。
@@ -172,7 +185,8 @@ public class AihrPracticeLlmService {
只输出一个 JSON 对象,不要输出任何其他文字、解释或代码块标记,字段如下:
{"total":0-100,"taskCompletion":0-100,"compliance":0-100,"communication":0-100,"emotion":0-100,"responseTimeliness":0-100或-1,"marketing":0-100,"rewrite":"给员工的示范话术改写,80字内","summary":"一句话点评,60字内"}
taskCompletion 评估问题是否真正被推进到可执行闭环;responseTimeliness 只有对话中存在明确响应时效证据时填写,否则填 -1。
""".formatted(PROMPT_VERSION);
本场评分规则:%s
""".formatted(PROMPT_VERSION, rubricText);
String user = """
训练场景:%s
训练目标:%s
@@ -183,6 +197,15 @@ public class AihrPracticeLlmService {
return modelService.tryChat(system, user, SCORE_TEMPERATURE).flatMap(this::parseScore);
}
private String renderRubric(List<RubricDimensionResponse> rubric) {
if (rubric == null || rubric.isEmpty()) {
return "任务完成40%、话术规范25%、情绪20%、响应时效10%、增值转化5%。";
}
return rubric.stream()
.map(item -> item.name() + Math.round((item.weight() == null ? 0D : item.weight()) * 100) + "%(" + item.description() + ")")
.collect(java.util.stream.Collectors.joining(";"));
}
/**
* 每日三题的语义评估。模型未配置、成本闸门关闭或结构化结果解析失败时由调用方回退规则评分。
*/
@@ -158,6 +158,42 @@ public class AihrPracticeSeedServiceTest {
assertEquals("截止日期格式应为YYYY-MM-DD", malformed.getMessage());
}
@Test
public void canonicalPracticePositionUsesOneFivePositionVocabulary() {
assertEquals("生活顾问", AihrPracticeSeedService.canonicalPracticePosition("客服管家"));
assertEquals("保安", AihrPracticeSeedService.canonicalPracticePosition("秩序维护员"));
assertEquals("保洁", AihrPracticeSeedService.canonicalPracticePosition("环境保洁"));
assertEquals("保修", AihrPracticeSeedService.canonicalPracticePosition("工程维修"));
assertEquals("客服", AihrPracticeSeedService.canonicalPracticePosition("前台客服"));
}
@Test
public void dailyDrillSeedCarriesAStableContentSnapshot() {
AihrPracticeSeedService.DailyDrillSeed drill = new AihrPracticeSeedService.DailyDrillSeed(
"repair-leak-followup", "漏水报修跟进要先确认什么?", "确认点位、责任人和反馈时间。"
);
assertEquals("daily-v1", drill.contentVersion());
assertNotNull(drill.contentHash());
assertEquals(64, drill.contentHash().length());
}
@Test
public void contentOperationsUseCatalogSelectionAndPersistRubricSnapshots() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
String controller = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"));
String migration = Files.readString(Path.of("../../script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql"));
assertTrue(source.contains("dailyDrillsFromCatalog(today, dailyDrillPosition(extPartyId), extPartyId)"));
assertTrue(source.contains("@Scheduled(cron = \"${aihr.practice.daily-drill-cron:0 5 0 * * *}\""));
assertTrue(source.contains("rubric_snapshot_json"));
assertTrue(source.contains("validateRubricDimensions"));
assertTrue(controller.contains("@GetMapping(\"/questions\")"));
assertTrue(controller.contains("@GetMapping(\"/assignments/manage\")"));
assertTrue(migration.contains("rubric_snapshot_json"));
assertTrue(migration.contains("repair-leak-followup"));
}
@Test
public void batchAssignmentUsesPerEmployeeTransactionsAndDatabaseIdempotency() throws Exception {
String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
@@ -167,6 +203,11 @@ public class AihrPracticeSeedServiceTest {
assertTrue(serviceSource.contains("() -> createAssignmentInternal(itemRequest, batchScenario, storedExtPartyId)"));
assertTrue(serviceSource.contains("TeamScope scope = teamScope(supervisorExtPartyId)"));
assertTrue(serviceSource.contains("AssignmentStorageIdentityResolution storageResolution = assignmentStorageIdentities(requestedExtPartyIds)"));
assertTrue(serviceSource.contains("AssignmentPositionPolicy positionPolicy = assignmentPositionPolicy(scenarioId)"));
assertTrue(serviceSource.contains("requireAssignmentTargetPosition(extPartyId, positionPolicy)"));
assertTrue(serviceSource.contains("assignmentScenarioPosition(scenarioId)"));
assertTrue(serviceSource.contains("与训练场景岗位("));
assertTrue(serviceSource.contains("requireAssignmentTargetPosition(extPartyId, assignmentPositionPolicy("));
assertTrue(serviceSource.contains("phone_owner_count"));
assertTrue(serviceSource.contains("ambiguousExtPartyIds().contains"));
assertTrue(serviceSource.contains("uk_aihr_assignment_request"));
@@ -628,6 +669,7 @@ public class AihrPracticeSeedServiceTest {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
assertEquals(2, session.roundCount());
ServiceException skipped = assertThrows(ServiceException.class,
() -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a"));
assertEquals("训练回合已变化,请查看最新对话后再继续", skipped.getMessage());
@@ -41,6 +41,9 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
`dim_emotion` int DEFAULT NULL COMMENT '情绪分',
`dim_communication` int DEFAULT NULL COMMENT '沟通分',
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
`rubric_code` varchar(100) DEFAULT NULL COMMENT '训练开始时评分规则编码',
`rubric_version` varchar(30) DEFAULT NULL COMMENT '训练开始时评分规则版本',
`rubric_snapshot_json` text DEFAULT NULL COMMENT '训练开始时评分规则快照',
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
@@ -0,0 +1,88 @@
-- 训练内容运营闭环:会话评分规则快照、五岗位 Rubric 补齐、旧每日任务场景修正。
-- 依赖:aihr_20260717_practice_five_position_catalog_mysql8.sql 已执行。
-- 可重复执行;仅补结构和内置规则,不生成员工业务任务。
SET @aihr_schema = DATABASE();
SET @rubric_code_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_code'
);
SET @rubric_code_ddl = IF(@rubric_code_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_code varchar(100) DEFAULT NULL COMMENT ''训练开始时评分规则编码'' AFTER dim_marketing',
'SELECT 1');
PREPARE aihr_rubric_code_stmt FROM @rubric_code_ddl;
EXECUTE aihr_rubric_code_stmt;
DEALLOCATE PREPARE aihr_rubric_code_stmt;
SET @rubric_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_version'
);
SET @rubric_version_ddl = IF(@rubric_version_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_version varchar(30) DEFAULT NULL COMMENT ''训练开始时评分规则版本'' AFTER rubric_code',
'SELECT 1');
PREPARE aihr_rubric_version_stmt FROM @rubric_version_ddl;
EXECUTE aihr_rubric_version_stmt;
DEALLOCATE PREPARE aihr_rubric_version_stmt;
SET @rubric_snapshot_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_session' AND column_name = 'rubric_snapshot_json'
);
SET @rubric_snapshot_ddl = IF(@rubric_snapshot_exists = 0,
'ALTER TABLE aihr_practice_session ADD COLUMN rubric_snapshot_json text DEFAULT NULL COMMENT ''训练开始时评分规则快照'' AFTER rubric_version',
'SELECT 1');
PREPARE aihr_rubric_snapshot_stmt FROM @rubric_snapshot_ddl;
EXECUTE aihr_rubric_snapshot_stmt;
DEALLOCATE PREPARE aihr_rubric_snapshot_stmt;
-- 对五岗位目录中没有 Rubric 的场景创建初始 v1.0 规则;已有版本不改写。
INSERT INTO aihr_practice_rubric
(tenant_id, rubric_code, scenario_code, rubric_name, version, enabled, description, create_time, update_time)
SELECT s.tenant_id, CONCAT('rubric-', s.scenario_code), s.scenario_code,
CONCAT(s.position, '基础评分规则'), 'v1.0', 1,
'五岗位内容目录补齐的初始版本;后续由训练运营维护。', NOW(), NOW()
FROM aihr_practice_scenario s
WHERE NOT EXISTS (
SELECT 1 FROM aihr_practice_rubric r
WHERE r.tenant_id = s.tenant_id AND r.scenario_code = s.scenario_code AND r.enabled = 1
)
ON DUPLICATE KEY UPDATE update_time = VALUES(update_time);
-- 一个场景仅保留最新的已启用版本,避免运行时随机取到历史 Rubric。
UPDATE aihr_practice_rubric r
JOIN (
SELECT tenant_id, scenario_code, MAX(id) AS keep_id
FROM aihr_practice_rubric
WHERE enabled = 1
GROUP BY tenant_id, scenario_code
) current_rule
ON current_rule.tenant_id = r.tenant_id AND current_rule.scenario_code = r.scenario_code
SET r.enabled = CASE WHEN r.id = current_rule.keep_id THEN 1 ELSE 0 END,
r.update_time = NOW()
WHERE r.enabled = 1;
-- 每个生效 Rubric 具备稳定的五个机器可评分维度,前端只能调整名称、描述和权重。
INSERT IGNORE INTO aihr_practice_rubric_dimension
(tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time)
SELECT r.tenant_id, r.rubric_code, d.dimension_code, d.dimension_name, d.weight, d.description, d.sort_order, NOW(), NOW()
FROM aihr_practice_rubric r
JOIN (
SELECT 'task_completion' AS dimension_code, '任务完成度' AS dimension_name, 0.40 AS weight,
'问题是否被推进到可执行闭环及 SOP 关键动作覆盖。' AS description, 10 AS sort_order
UNION ALL SELECT 'standardization', '话术规范性', 0.25, '流程完整、合规表达和专业沟通。', 20
UNION ALL SELECT 'emotion', '情绪管理能力', 0.20, '共情承接、情绪降级和信任恢复。', 30
UNION ALL SELECT 'response_timeliness', '响应时效', 0.10, '阶段一仅按陪练回合响应时长统计。', 40
UNION ALL SELECT 'marketing', '增值转化潜力', 0.05, '合规的服务引导和办理意识。', 50
) d
WHERE r.enabled = 1;
-- 旧硬编码每日题把 repair-leak 写成不存在编码;仅在目标场景已存在时修正历史未完成任务。
UPDATE aihr_practice_assignment a
JOIN aihr_practice_scenario s
ON s.tenant_id = a.tenant_id AND s.scenario_code = 'repair-leak-followup'
SET a.scenario_id = s.scenario_code,
a.scenario_name = s.scenario_name,
a.update_time = NOW()
WHERE a.source = 'daily' AND a.scenario_id = 'repair-leak' AND a.status = '待训练';
+25 -3
View File
@@ -67,6 +67,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_20260717_practice_assignment_contract_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql
@@ -85,7 +86,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql
```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验
@@ -128,7 +129,7 @@ FROM information_schema.columns
WHERE table_schema = DATABASE()
AND (
(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', 'rubric_code', 'rubric_version', 'rubric_snapshot_json', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment'))
OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash'))
OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode', 'due_date', 'request_key', 'content_version', 'question_snapshot', 'reference_answer_snapshot', 'content_hash'))
OR (table_name = 'aihr_sop_answer_review' AND column_name IN ('prompt_version', 'requester_ext_party_id'))
@@ -182,6 +183,27 @@ FROM aihr_learning_question
WHERE tenant_id = '000000'
GROUP BY question_type
ORDER BY question_type;
SELECT s.position,
COUNT(*) AS enabled_scenarios,
SUM(active_rubric.active_count = 1) AS scenarios_with_one_active_rubric
FROM aihr_practice_scenario s
LEFT JOIN (
SELECT scenario_code, COUNT(*) AS active_count
FROM aihr_practice_rubric
WHERE tenant_id = '000000' AND enabled = 1
GROUP BY scenario_code
) active_rubric ON active_rubric.scenario_code = s.scenario_code
WHERE s.tenant_id = '000000' AND s.enabled = 1
GROUP BY s.position
ORDER BY s.position;
SELECT d.dimension_code, COUNT(*) AS active_dimension_rows
FROM aihr_practice_rubric_dimension d
JOIN aihr_practice_rubric r ON r.tenant_id = d.tenant_id AND r.rubric_code = d.rubric_code
WHERE r.tenant_id = '000000' AND r.enabled = 1
GROUP BY d.dimension_code
ORDER BY d.dimension_code;
```
预期是三张场景/Rubric 表、岗位题库、训练反馈表与训练求助事件表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表和空间内分类表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表、平台组织目录与租户绑定表、四张记忆表、采集状态历史表、每日成果表、原工作上报表、三张大喇叭消息/阅读/v1快照表、两张 M1 定向规则/对象快照表及十张独立个人资料表均存在。`57/57` 只代表当时较窄的发布清单;后续线上只读复核已确认 M1 的目标表和必读字段,但当前线上不存在 `aihr_practice_help_event`,因此不得把包含该表的 M2 完整清单写成已发布。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
@@ -190,7 +212,7 @@ ORDER BY question_type;
1. 重启 `wygj-aihr.service`,确认状态为 `active`。
2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。
3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。
3. 用正式测试账号完成员工端训练、主管复盘和管理端五岗位场景、每日/专项题库、Rubric 与任务运营检查;确认停用内容不会被下发,任务创建保留题目/参考答案版本快照,训练完成记录保留开始时 Rubric 快照。不要使用开发固定验证码验证生产安全策略。
4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。
5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。
6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。
+86 -1
View File
@@ -30,6 +30,9 @@ export type PracticeScenarioResponse = {
strategy: string;
goal: string;
enabled: boolean;
rounds: PracticeScenarioRound[];
contentVersion: string;
contentHash: string;
rubric?: {
code: string;
name: string;
@@ -38,6 +41,13 @@ export type PracticeScenarioResponse = {
};
};
export type PracticeScenarioRound = {
customer: string;
coachHint: string;
emotion: number;
trust: number;
};
export type PracticeScenarioEnabledRequest = {
enabled: boolean;
};
@@ -60,6 +70,7 @@ export type PracticeScenarioSaveRequest = {
strategy: string;
goal: string;
enabled: boolean;
rounds: PracticeScenarioRound[];
};
export type PracticeRubricDimension = {
@@ -82,6 +93,56 @@ export type PracticeRubricAdmin = {
export type PracticeRubricSaveRequest = Omit<PracticeRubricAdmin, 'scenarioName'>;
export type PracticeQuestion = {
code: string;
scenarioCode: string;
scenarioName: string;
positionCode: string;
type: 'daily' | 'special';
question: string;
referenceAnswer: string;
contentVersion: string;
contentHash: string;
enabled: boolean;
sortOrder: number;
};
export type PracticeQuestionSaveRequest = Omit<PracticeQuestion, 'scenarioName' | 'contentVersion' | 'contentHash'>;
export type ManagedPracticeAssignment = {
id: number;
extPartyId: string;
traineeName: string;
scenarioId: string;
scenarioName: string;
source: string;
reason: string;
status: string;
createTime: string;
dueDate?: string;
contentVersion?: string;
contentHash?: string;
question?: string;
referenceAnswer?: string;
};
export type ManagedPracticeAssignmentBatchRequest = {
requestId: string;
targets: Array<{ extPartyId: string; traineeName?: string }>;
scenarioId: string;
reason: string;
dueDate: string;
};
export type ManagedPracticeAssignmentBatchResponse = {
requestId: string;
total: number;
created: number;
duplicate: number;
failed: number;
results: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>;
};
export type PracticePrepCardResponse = {
keyPoints: string[];
redLines: string[];
@@ -200,7 +261,7 @@ export type PromptTemplateRequest = {
description: string;
};
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string; includeDisabled?: boolean }): Promise<ApiResult<PracticeScenarioResponse[]>> {
return request({
url: '/api/train/practice/scenarios',
method: 'get',
@@ -208,6 +269,18 @@ export function listPracticeScenarios(params?: { position?: string; scenarioType
});
}
export function listPracticeQuestions(params?: { position?: string; type?: 'daily' | 'special'; includeDisabled?: boolean }): Promise<ApiResult<PracticeQuestion[]>> {
return request({ url: '/api/train/practice/questions', method: 'get', params });
}
export function savePracticeQuestion(data: PracticeQuestionSaveRequest): Promise<ApiResult<PracticeQuestion>> {
return request({ url: '/api/train/practice/questions', method: 'post', data });
}
export function updatePracticeQuestionEnabled(code: string, enabled: boolean): Promise<ApiResult<null>> {
return request({ url: `/api/train/practice/questions/${encodeURIComponent(code)}/enabled`, method: 'patch', data: { enabled } });
}
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
return request({
url: `/api/train/practice/scenarios/${id}/enabled`,
@@ -240,6 +313,18 @@ export function updatePracticeRubricEnabled(code: string, enabled: boolean): Pro
});
}
export function listManagedPracticeAssignments(params?: { position?: string; status?: string; source?: string; keyword?: string; limit?: number }): Promise<ApiResult<ManagedPracticeAssignment[]>> {
return request({ url: '/api/train/practice/assignments/manage', method: 'get', params });
}
export function createManagedPracticeAssignments(data: ManagedPracticeAssignmentBatchRequest): Promise<ApiResult<ManagedPracticeAssignmentBatchResponse>> {
return request({ url: '/api/train/practice/assignments/manage/batch', method: 'post', data, headers: { repeatSubmit: false } });
}
export function cancelManagedPracticeAssignment(id: number, reason?: string): Promise<ApiResult<null>> {
return request({ url: `/api/train/practice/assignments/manage/${id}/cancel`, method: 'patch', data: { reason }, headers: { repeatSubmit: false } });
}
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
return request({
url: `/api/train/practice/scenarios/${id}/prep-card`,
+12
View File
@@ -120,6 +120,18 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'TrainScenarios',
meta: { title: '场景与评分', icon: 'list' }
},
{
path: 'content',
component: () => import('@/views/train/content.vue'),
name: 'TrainContent',
meta: { title: '训练题库', icon: 'edit' }
},
{
path: 'tasks',
component: () => import('@/views/train/tasks.vue'),
name: 'TrainTasks',
meta: { title: '任务运营', icon: 'date' }
},
{
path: 'rubrics',
component: () => import('@/views/train/rubrics.vue'),
+158
View File
@@ -0,0 +1,158 @@
<template>
<div class="content-page">
<section class="page-head">
<div>
<h1>训练题库</h1>
<p>维护五岗位每日三题与专项任务题目;发布后只影响后续派发,已派任务保留内容快照。</p>
</div>
<el-button type="danger" @click="openCreate">新增题目</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="filters.type" clearable placeholder="题目用途"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select>
<el-select v-model="filters.enabled" clearable placeholder="状态"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select>
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="filteredRows" row-key="code">
<el-table-column prop="positionCode" label="岗位" width="96" />
<el-table-column prop="scenarioName" label="训练场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="type" label="用途" width="96"><template #default="{ row }">{{ row.type === 'daily' ? '每日三题' : '专项任务' }}</template></el-table-column>
<el-table-column prop="question" label="题干" min-width="300" show-overflow-tooltip />
<el-table-column prop="contentVersion" label="版本" width="90" />
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link @click="toggle(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">共 {{ filteredRows.length }} 题;每日任务调度仅选择已启用的 daily 题目和已启用场景。</div>
</section>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑题目(将生成新版本)' : '新增题目'" width="720px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="14">
<el-col :span="12"><el-form-item label="岗位" required><el-select v-model="form.positionCode" class="full" @change="onPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="用途" required><el-select v-model="form.type" class="full"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select></el-form-item></el-col>
</el-row>
<el-form-item label="训练场景" required><el-select v-model="form.scenarioCode" filterable class="full"><el-option v-for="item in scenarioOptions" :key="item.id" :label="`${item.name}(${item.id})`" :value="item.id" /></el-select></el-form-item>
<el-form-item label="题目编码"><el-input v-model="form.code" :disabled="editing" placeholder="留空自动生成;编码创建后不可修改" /></el-form-item>
<el-form-item label="题干" required><el-input v-model="form.question" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
<el-form-item label="参考答案 / 关键动作" required><el-input v-model="form.referenceAnswer" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
<el-row :gutter="14">
<el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" class="full" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="发布状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="save">保存并生成版本</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
listPracticeQuestions,
listPracticeScenarios,
savePracticeQuestion,
updatePracticeQuestionEnabled,
type PracticeQuestion,
type PracticeQuestionSaveRequest,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const editing = ref(false);
const rows = ref<PracticeQuestion[]>([]);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const filters = reactive<{ position: string; type: '' | 'daily' | 'special'; enabled: boolean | undefined }>({ position: '', type: '', enabled: undefined });
const emptyForm = (): PracticeQuestionSaveRequest => ({
code: '', scenarioCode: '', positionCode: '生活顾问', type: 'daily', question: '', referenceAnswer: '', enabled: true, sortOrder: 100
});
const form = reactive<PracticeQuestionSaveRequest>(emptyForm());
const scenarioOptions = computed(() => scenarios.value.filter((item) => item.position === form.positionCode));
const filteredRows = computed(() => rows.value.filter((item) =>
(!filters.position || item.positionCode === filters.position) &&
(!filters.type || item.type === filters.type) &&
(filters.enabled === undefined || item.enabled === filters.enabled)
));
const load = async () => {
loading.value = true;
try {
const [questionsResult, scenariosResult] = await Promise.all([
listPracticeQuestions({ includeDisabled: true }),
listPracticeScenarios({ includeDisabled: true })
]);
rows.value = questionsResult.data || [];
scenarios.value = scenariosResult.data || [];
} catch {
ElMessage.error('训练题库读取失败');
} finally {
loading.value = false;
}
};
const resetForm = () => Object.assign(form, emptyForm());
const openCreate = () => { editing.value = false; resetForm(); dialogVisible.value = true; };
const openEdit = (item: PracticeQuestion) => {
editing.value = true;
Object.assign(form, {
code: item.code,
scenarioCode: item.scenarioCode,
positionCode: item.positionCode,
type: item.type,
question: item.question,
referenceAnswer: item.referenceAnswer,
enabled: item.enabled,
sortOrder: item.sortOrder
});
dialogVisible.value = true;
};
const onPositionChange = () => {
if (!scenarioOptions.value.some((item) => item.id === form.scenarioCode)) form.scenarioCode = '';
};
const toggle = async (item: PracticeQuestion) => {
await updatePracticeQuestionEnabled(item.code, !item.enabled);
item.enabled = !item.enabled;
ElMessage.success(item.enabled ? '题目已启用' : '题目已停用');
};
const save = async () => {
if (!form.scenarioCode || !form.question.trim() || !form.referenceAnswer.trim()) {
ElMessage.warning('请补齐训练场景、题干和参考答案');
return;
}
saving.value = true;
try {
await savePracticeQuestion({ ...form, code: form.code.trim() });
dialogVisible.value = false;
await load();
ElMessage.success('题目已保存,后续派发会使用新版本');
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.content-page { min-height: calc(100vh - 84px); padding: 24px 28px 40px; background: #f4f6fa; color: #172033; }
.page-head { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 24px; }
.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }
.filter-card, .table-card { padding: 18px; border-radius: 10px; background: #fff; }
.filter-card { display: flex; gap: 12px; margin-bottom: 12px; }
.filter-card :deep(.el-select) { width: 180px; }
.table-footer { margin-top: 16px; color: #667085; font-size: 12px; }
.full { width: 100%; }
@media (max-width: 900px) { .filter-card { flex-wrap: wrap; } .filter-card :deep(.el-select) { width: 46%; } }
</style>
+64 -8
View File
@@ -3,14 +3,14 @@
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>生活顾问岗位 · 场景保存、Rubric 版本与启停维护</p>
<p>五岗位训练内容 · 场景回合、版本、Rubric 与启停维护</p>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" placeholder="岗位" clearable>
<el-option label="生活顾问" value="生活顾问" />
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
@@ -25,6 +25,7 @@
<section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="difficulty" label="难度" width="92">
@@ -51,7 +52,7 @@
</el-table-column>
</el-table>
<div class="table-footer">
<span>共 {{ filteredScenarios.length }} 个生活顾问场景 · 当前启用 {{ enabledCount }} 个</span>
<span>共 {{ filteredScenarios.length }} 个场景 · 当前启用 {{ enabledCount }} 个</span>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
@@ -78,7 +79,11 @@
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="岗位"><el-input v-model="form.position" /></el-form-item>
<el-form-item label="岗位">
<el-select v-model="form.position" class="full-control">
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
@@ -93,6 +98,19 @@
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
<el-form-item label="训练回合">
<div class="round-list">
<div v-for="(round, index) in form.rounds" :key="index" class="round-row">
<strong>第 {{ index + 1 }} 轮</strong>
<el-input v-model="round.customer" type="textarea" :rows="2" placeholder="业主/现场人员话术" />
<el-input v-model="round.coachHint" placeholder="教练提示" />
<el-input-number v-model="round.emotion" :min="0" :max="100" controls-position="right" />
<el-input-number v-model="round.trust" :min="0" :max="100" controls-position="right" />
<el-button link type="danger" :disabled="form.rounds.length <= 2" @click="form.rounds.splice(index, 1)">删除</el-button>
</div>
<el-button plain :disabled="form.rounds.length >= 6" @click="addRound">新增回合</el-button>
</div>
</el-form-item>
<el-switch v-model="form.enabled" active-text="启用场景" inactive-text="停用场景" />
</el-form>
<template #footer>
@@ -111,6 +129,7 @@ import {
savePracticeScenario,
updatePracticeScenarioEnabled,
type PracticeScenarioResponse,
type PracticeScenarioRound,
type PracticeScenarioSaveRequest
} from '@/api/aihr/practice';
@@ -119,9 +138,10 @@ const saving = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
position: '生活顾问',
position: '',
scenarioType: '',
enabled: undefined,
keyword: ''
@@ -144,7 +164,11 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
successCriteria: '',
strategy: '仅在漏关键步骤时提示',
goal: '',
enabled: true
enabled: true,
rounds: [
{ customer: '', coachHint: '先承接情绪,再确认具体诉求。', emotion: 72, trust: 70 },
{ customer: '我想知道接下来谁负责、什么时候反馈。', coachHint: '补上责任人、处理节点和回访时间。', emotion: 62, trust: 82 }
]
});
const form = reactive(emptyForm());
const dialogTitle = computed(() => (editingId.value ? '编辑场景' : '新增场景'));
@@ -153,6 +177,7 @@ const filteredScenarios = computed(() =>
const keyword = filters.keyword.trim().toLowerCase();
return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(!filters.position || item.position === filters.position) &&
(filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
);
@@ -167,7 +192,7 @@ const enabledCount = computed(() => scenarios.value.filter((item) => item.enable
const load = async () => {
loading.value = true;
try {
const { data } = await listPracticeScenarios();
const { data } = await listPracticeScenarios({ includeDisabled: true });
scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
} finally {
@@ -203,7 +228,8 @@ const fillForm = (item?: PracticeScenarioResponse) => {
successCriteria: item.successCriteria,
strategy: item.strategy,
goal: item.goal,
enabled: item.enabled
enabled: item.enabled,
rounds: item.rounds?.map((round) => ({ ...round })) || emptyForm().rounds
}
: {}
);
@@ -221,6 +247,12 @@ const openEdit = (item: PracticeScenarioResponse) => {
dialogVisible.value = true;
};
const addRound = () => {
if (form.rounds.length < 6) {
form.rounds.push({ customer: '', coachHint: '补上责任人、反馈时间和SOP依据。', emotion: 70, trust: 75 } as PracticeScenarioRound);
}
};
const toggleEnabled = async (item: PracticeScenarioResponse) => {
const enabled = item.enabled === false;
await updatePracticeScenarioEnabled(item.id, { enabled });
@@ -335,6 +367,27 @@ onMounted(load);
width: 100%;
}
.round-list {
display: grid;
width: 100%;
gap: 10px;
}
.round-row {
display: grid;
grid-template-columns: 72px minmax(180px, 1fr) minmax(160px, 1fr) 96px 96px 48px;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafbfc;
strong {
font-size: 13px;
}
}
@media (max-width: 1100px) {
.filter-card {
grid-template-columns: 1fr 1fr;
@@ -342,5 +395,8 @@ onMounted(load);
.filter-card .el-button {
width: 100%;
}
.round-row {
grid-template-columns: 1fr 1fr;
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="tasks-page">
<section class="page-head">
<div>
<h1>训练任务运营</h1>
<p>统一查看自动/人工/重练任务,按组织名单批量派发专项,并可在开始训练前撤回。</p>
</div>
<el-button type="danger" @click="openAssign">批量派发专项</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="filters.status" clearable placeholder="任务状态"><el-option label="待训练" value="待训练" /><el-option label="已完成" value="已完成" /><el-option label="已撤回" value="已撤回" /></el-select>
<el-select v-model="filters.source" clearable placeholder="任务来源"><el-option label="每日三题" value="daily" /><el-option label="人工专项" value="manual" /><el-option label="低分重练" value="retry" /></el-select>
<el-input v-model="filters.keyword" clearable placeholder="场景名称 / 员工ID" @keyup.enter="load" />
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
</section>
<section class="metric-grid">
<div><span>待训练</span><strong>{{ pendingCount }}</strong></div><div><span>已完成</span><strong>{{ completedCount }}</strong></div><div><span>已撤回</span><strong>{{ withdrawnCount }}</strong></div><div><span>当前列表</span><strong>{{ rows.length }}</strong></div>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column prop="traineeName" label="员工" width="112" /><el-table-column prop="extPartyId" label="员工ID" min-width="130" show-overflow-tooltip />
<el-table-column prop="scenarioName" label="场景" min-width="160" show-overflow-tooltip />
<el-table-column prop="source" label="来源" width="98"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column prop="dueDate" label="截止日" width="112" /><el-table-column prop="status" label="状态" width="92"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="contentVersion" label="内容版本" width="96" /><el-table-column prop="reason" label="任务说明" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="96" fixed="right"><template #default="{ row }"><el-button v-if="row.status === '待训练' && row.source !== 'daily'" type="danger" link @click="withdraw(row)">撤回</el-button><span v-else class="muted">—</span></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="assignVisible" title="批量派发专项训练" width="900px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="项目"><el-select v-model="projectCode" clearable filterable class="full" @change="loadPeople"><el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="岗位"><el-select v-model="assignmentPosition" class="full" @change="onAssignmentPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="训练场景"><el-select v-model="assignmentScenarioId" class="full"><el-option v-for="item in assignmentScenarios" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col>
</el-row>
<el-row :gutter="14"><el-col :span="12"><el-form-item label="截止日期"><el-date-picker v-model="dueDate" type="date" value-format="YYYY-MM-DD" class="full" :disabled-date="disablePast" /></el-form-item></el-col><el-col :span="12"><el-form-item label="任务说明"><el-input v-model="reason" maxlength="180" show-word-limit /></el-form-item></el-col></el-row>
<el-form-item label="选择员工(最多 100 人)"><el-alert :title="`当前岗位可派 ${eligiblePeople.length} 人,已选择 ${selectedPeople.length} 人。组织岗位会按统一规则归类;服务端会再次核验在职状态、组织范围和岗位匹配。`" type="info" :closable="false" /><el-table v-loading="peopleLoading" :data="eligiblePeople" max-height="360" row-key="extPartyId" @selection-change="onSelectPeople"><el-table-column type="selection" width="48" /><el-table-column prop="personName" label="员工" min-width="130" /><el-table-column label="组织岗位(训练归类)" min-width="180"><template #default="{ row }">{{ row.positionName }}({{ canonicalPosition(row.positionName) }})</template></el-table-column><el-table-column prop="projectName" label="项目" min-width="160" /><el-table-column prop="extPartyId" label="员工ID" min-width="140" /></el-table></el-form-item>
</el-form>
<template #footer><el-button @click="assignVisible = false">取消</el-button><el-button type="danger" :loading="assigning" @click="assign">确认派发</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
cancelManagedPracticeAssignment,
createManagedPracticeAssignments,
listManagedPracticeAssignments,
listPracticeScenarios,
type ManagedPracticeAssignment,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
import { getOrgSnapshot, type OrgPersonRow, type OrgProjectOption } from '@/api/aihr/mobile-admin';
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const loading = ref(false); const assigning = ref(false); const peopleLoading = ref(false); const assignVisible = ref(false);
const rows = ref<ManagedPracticeAssignment[]>([]); const scenarios = ref<PracticeScenarioResponse[]>([]); const people = ref<OrgPersonRow[]>([]); const selectedPeople = ref<OrgPersonRow[]>([]); const projectOptions = ref<OrgProjectOption[]>([]);
const filters = reactive({ position: '', status: '', source: '', keyword: '' });
const projectCode = ref(''); const assignmentPosition = ref('生活顾问'); const assignmentScenarioId = ref(''); const dueDate = ref(''); const reason = ref('训练运营派发专项训练');
const assignmentScenarios = computed(() => scenarios.value.filter((item) => item.enabled && item.position === assignmentPosition.value));
const canonicalPosition = (value: string) => {
const text = String(value || '').trim();
if (text.includes('生活顾问') || text.includes('客服管家') || text.includes('物业管家') || text === '管家') return '生活顾问';
if (text.includes('保安') || text.includes('秩序') || text.includes('安保')) return '保安';
if (text.includes('保洁') || text.includes('环境')) return '保洁';
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
if (text.includes('客服') || text.includes('前台')) return '客服';
return text;
};
const eligiblePeople = computed(() => people.value.filter((item) => canonicalPosition(item.positionName) === assignmentPosition.value));
const pendingCount = computed(() => rows.value.filter((item) => item.status === '待训练').length); const completedCount = computed(() => rows.value.filter((item) => item.status === '已完成').length); const withdrawnCount = computed(() => rows.value.filter((item) => item.status === '已撤回').length);
const sourceLabel = (source: string) => ({ daily: '每日三题', manual: '人工专项', retry: '低分重练', camp: '训练营' }[source] || source);
const statusType = (status: string) => status === '待训练' ? 'warning' : status === '已完成' ? 'success' : 'info';
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
const load = async () => { loading.value = true; try { const result = await listManagedPracticeAssignments({ ...filters, limit: 200 }); rows.value = result.data || []; } catch { ElMessage.error('训练任务读取失败'); } finally { loading.value = false; } };
const loadScenarios = async () => { const result = await listPracticeScenarios({ includeDisabled: false }); scenarios.value = result.data || []; onAssignmentPositionChange(); };
const loadPeople = async () => { peopleLoading.value = true; try { const result = await getOrgSnapshot({ status: 'active', projectCode: projectCode.value || undefined, pageNum: 1, pageSize: 500 }); people.value = result.data?.rows || []; projectOptions.value = result.data?.projects || projectOptions.value; selectedPeople.value = []; } catch { people.value = []; ElMessage.error('组织名单读取失败'); } finally { peopleLoading.value = false; } };
const onAssignmentPositionChange = () => { selectedPeople.value = []; if (!assignmentScenarios.value.some((item) => item.id === assignmentScenarioId.value)) assignmentScenarioId.value = assignmentScenarios.value[0]?.id || ''; };
const onSelectPeople = (value: OrgPersonRow[]) => { selectedPeople.value = value.slice(0, 100); if (value.length > 100) ElMessage.warning('单次最多派发 100 人'); };
const openAssign = async () => { assignVisible.value = true; dueDate.value = formatDate(new Date(Date.now() + 86400000)); await Promise.all([loadScenarios(), loadPeople()]); };
const assign = async () => { if (!assignmentScenarioId.value || !dueDate.value || !selectedPeople.value.length) { ElMessage.warning('请先选择场景、截止日期和员工'); return; } assigning.value = true; try { const result = await createManagedPracticeAssignments({ requestId: `hr-task:${Date.now()}`, targets: selectedPeople.value.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.personName })), scenarioId: assignmentScenarioId.value, reason: reason.value.trim() || '训练运营派发专项训练', dueDate: dueDate.value }); const failed = result.data?.failed || 0; ElMessage.success(failed ? `已派发,${failed} 人未成功` : '批量派发成功'); assignVisible.value = false; await load(); } catch { ElMessage.error('专项训练派发失败'); } finally { assigning.value = false; } };
const withdraw = async (row: ManagedPracticeAssignment) => { await ElMessageBox.confirm(`撤回“${row.scenarioName}”任务?员工尚未开始训练时才可撤回。`, '确认撤回', { type: 'warning' }); await cancelManagedPracticeAssignment(row.id, '训练运营调整'); ElMessage.success('任务已撤回'); await load(); };
onMounted(async () => { await Promise.all([load(), loadScenarios(), loadPeople()]); });
</script>
<style scoped lang="scss">
.tasks-page { min-height: calc(100vh - 84px); padding: 24px 28px 40px; background: #f4f6fa; color: #172033; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }.page-head h1 { margin: 0; font-size: 24px; }.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }
.filter-card, .table-card { padding: 18px; border-radius: 10px; background: #fff; }.filter-card { display: grid; grid-template-columns: 160px 140px 140px minmax(180px, 1fr) 76px; gap: 12px; margin-bottom: 12px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }.metric-grid > div { display: grid; gap: 8px; padding: 16px; border-radius: 10px; background: #fff; }.metric-grid span, .muted { color: #98a2b3; }.metric-grid strong { font-size: 26px; }.full { width: 100%; }
@media (max-width: 1000px) { .filter-card { grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: 1fr 1fr; } }
</style>
+7 -2
View File
@@ -180,7 +180,7 @@
<view class="chat-head">
<view class="chat-head-row">
<text class="chat-title">{{ activeScenarioName }}</text>
<text class="chat-context">{{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, 3)}/3 轮` }}</text>
<text class="chat-context">{{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, activeRoundCount)}/${activeRoundCount} 轮` }}</text>
</view>
<view v-if="hasCoachState" class="coach-strip">
<view class="coach-metric">
@@ -425,6 +425,7 @@ const storedRealtimeVoice = uni.getStorageSync(realtimeVoiceStorageKey);
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
const activeRoundCount = ref(3);
const draft = ref('');
const message = ref('');
const turns = ref<PracticeTurn[]>([]);
@@ -508,7 +509,7 @@ const practicePageTitle = computed(() => {
});
const practicePageSubtitle = computed(() => {
if (practiceView.value === 'result') return `${activeScenarioName.value} · ${Math.max(roundIndex.value, 1)} 轮对话`;
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, 3)}/3 轮`;
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, activeRoundCount.value)}/${activeRoundCount.value} 轮`;
return '先掌握要点,再开始场景练习';
});
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
@@ -794,6 +795,7 @@ const clearPracticeData = () => {
satisfactionSubmitted.value = false;
sessionId.value = '';
roundIndex.value = 0;
activeRoundCount.value = 3;
draft.value = '';
status.value = 'idle';
selectedScenarioId.value = defaultScenarioId;
@@ -1284,6 +1286,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
trustScore.value = 0;
redFlagActive.value = false;
roundIndex.value = 0;
activeRoundCount.value = 3;
activeAssignmentId.value = assignmentId;
try {
const data = await startPractice(scenarioId, assignmentId);
@@ -1291,6 +1294,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
activeRoundCount.value = Math.max(2, Math.min(6, data.roundCount || 3));
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText, data.emotion, true, 0);
status.value = 'active';
@@ -1416,6 +1420,7 @@ const submit = async () => {
return;
}
if (data.finished) {
roundIndex.value = data.roundIndex;
await finish(true);
return;
}
+1
View File
@@ -465,6 +465,7 @@ export interface PracticeStartResponse {
customerText: string;
emotion?: number;
trust?: number;
roundCount?: number;
customerAudioUrl?: string;
}