feat(practice): complete five-position training operations
This commit is contained in:
+58
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-2
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -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(";"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日三题的语义评估。模型未配置、成本闸门关闭或结构化结果解析失败时由调用方回退规则评分。
|
||||
*/
|
||||
|
||||
+641
-58
File diff suppressed because it is too large
Load Diff
+42
@@ -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 = '待训练';
|
||||
@@ -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`,审核通过不自动写入案例、知识或积分表。
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -465,6 +465,7 @@ export interface PracticeStartResponse {
|
||||
customerText: string;
|
||||
emotion?: number;
|
||||
trust?: number;
|
||||
roundCount?: number;
|
||||
customerAudioUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user