feat(practice): streamline pilot content operations
This commit is contained in:
+3
-1
@@ -98,7 +98,9 @@ public final class AihrPracticeDto {
|
||||
String strategy,
|
||||
String goal,
|
||||
Boolean enabled,
|
||||
List<ScenarioRoundResponse> rounds
|
||||
List<ScenarioRoundResponse> rounds,
|
||||
String expectedContentVersion,
|
||||
String expectedContentHash
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+26
-5
@@ -124,8 +124,10 @@ public class AihrPracticeSeedService {
|
||||
private static final Set<String> SCENARIO_REVIEW_STATUSES = Set.of("草稿", "待业务审核", "已发布", "已下线");
|
||||
private static final Set<String> SCENARIO_RISK_LEVELS = Set.of("待评估", "常规", "高风险");
|
||||
private static final List<String> MANDATORY_DUAL_REVIEW_TERMS = List.of(
|
||||
"投诉", "客诉", "纠纷", "收费", "催费", "费用", "停车费", "退款", "减免",
|
||||
"安全", "事故", "消防", "燃气", "漏电", "电梯", "高空", "受伤", "人身伤害", "舆情"
|
||||
"事故", "消防", "火灾", "起火", "爆炸", "燃气", "漏电", "触电", "带电",
|
||||
"高空", "困人", "受伤", "人身伤害", "摔倒", "暴力", "肢体冲突",
|
||||
"危险物品", "化学品", "医疗废弃", "针具", "锐器", "有限空间", "中毒",
|
||||
"舆情", "赔偿", "退款", "退费", "减免", "免单", "豁免"
|
||||
);
|
||||
private static final String UNCLASSIFIED_COMPETENCY = "待归类";
|
||||
private static final String DEFAULT_CURRICULUM_VERSION = "住宅五岗-v1";
|
||||
@@ -429,6 +431,7 @@ public class AihrPracticeSeedService {
|
||||
throw new ServiceException("训练场景不存在或内容未发布");
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ScenarioResponse saveScenario(ScenarioSaveRequest request) {
|
||||
ensureScenarioTable();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -437,6 +440,20 @@ public class AihrPracticeSeedService {
|
||||
String position = canonicalPracticePosition(request == null ? null : request.position());
|
||||
String scenarioType = firstNonBlank(request == null ? null : request.scenarioType(), "投诉处理");
|
||||
String projectType = firstNonBlank(request == null ? null : request.projectType(), "住宅");
|
||||
if (request != null && (!isBlank(request.expectedContentVersion()) || !isBlank(request.expectedContentHash()))) {
|
||||
List<String[]> current = jdbcTemplate.query("""
|
||||
SELECT content_version, content_hash
|
||||
FROM aihr_practice_scenario
|
||||
WHERE tenant_id = ? AND scenario_code = ?
|
||||
FOR UPDATE
|
||||
""", (rs, rowNum) -> new String[] {rs.getString("content_version"), rs.getString("content_hash")},
|
||||
tenantId(), code);
|
||||
if (current.isEmpty()
|
||||
|| !Objects.equals(current.get(0)[0], request.expectedContentVersion())
|
||||
|| !Objects.equals(current.get(0)[1], request.expectedContentHash())) {
|
||||
throw new ServiceException("训练场景已发生变更,请刷新后重新编辑");
|
||||
}
|
||||
}
|
||||
ScenarioCurriculumMetadata existingCurriculum = existingScenarioCurriculum(code);
|
||||
String growthLevel = normalizeGrowthLevel(firstNonBlank(request == null ? null : request.growthLevel(),
|
||||
existingCurriculum == null ? null : existingCurriculum.growthLevel()));
|
||||
@@ -7653,13 +7670,13 @@ public class AihrPracticeSeedService {
|
||||
return requiresMandatoryDualReview(content) ? "高风险" : riskLevel;
|
||||
}
|
||||
|
||||
private boolean requiresMandatoryDualReview(String... content) {
|
||||
static boolean requiresMandatoryDualReviewContent(String... content) {
|
||||
for (String value : Optional.ofNullable(content).orElse(new String[0])) {
|
||||
if (isBlank(value)) {
|
||||
if (value == null || value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if ("高风险".equals(normalized)) {
|
||||
if (normalized.contains("高风险")) {
|
||||
return true;
|
||||
}
|
||||
for (String term : MANDATORY_DUAL_REVIEW_TERMS) {
|
||||
@@ -7671,6 +7688,10 @@ public class AihrPracticeSeedService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean requiresMandatoryDualReview(String... content) {
|
||||
return requiresMandatoryDualReviewContent(content);
|
||||
}
|
||||
|
||||
private String normalizeReviewStatus(String value) {
|
||||
String status = firstNonBlank(value, "草稿");
|
||||
if (!SCENARIO_REVIEW_STATUSES.contains(status)) {
|
||||
|
||||
+13
-1
@@ -513,6 +513,8 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertFalse(source.contains("WHEN enabled = 1 THEN '已发布' ELSE '待业务审核' END"));
|
||||
assertTrue(source.contains("content_hash <=> ?"));
|
||||
assertTrue(source.contains("训练场景已发生变更,请刷新后重新审核"));
|
||||
assertTrue(source.contains("expectedContentVersion"));
|
||||
assertTrue(source.contains("训练场景已发生变更,请刷新后重新编辑"));
|
||||
assertTrue(source.contains("boolean reviewDecision = \"待业务审核\".equals(current)"));
|
||||
assertTrue(source.contains("SCENARIO_RISK_LEVELS"));
|
||||
assertTrue(source.contains("approveHighRiskScenario"));
|
||||
@@ -577,6 +579,16 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertEquals("已发布", jdbcTemplate.reviewStatus);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void genericServiceTopicsDoNotRequireDualReviewWithoutMaterialRisk() {
|
||||
assertFalse(AihrPracticeSeedService.requiresMandatoryDualReviewContent(
|
||||
"投诉信息完整登记", "费用明细查询", "电梯轿厢日常保洁", "安全确认"
|
||||
));
|
||||
assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("电梯困人信息收集与升级"));
|
||||
assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("物业费减免申请"));
|
||||
assertTrue(AihrPracticeSeedService.requiresMandatoryDualReviewContent("高风险作业"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scenarioCannotPublishUntilContentRiskIsExplicitlyClassified() throws Exception {
|
||||
HighRiskScenarioJdbcTemplate jdbcTemplate = new HighRiskScenarioJdbcTemplate("待评估");
|
||||
@@ -800,7 +812,7 @@ public class AihrPracticeSeedServiceTest {
|
||||
));
|
||||
|
||||
assertEquals("待业务审核", jdbcTemplate.reviewStatus);
|
||||
assertEquals("高风险", jdbcTemplate.riskLevel);
|
||||
assertEquals("常规", jdbcTemplate.riskLevel);
|
||||
assertNull(jdbcTemplate.reviewer);
|
||||
assertNull(jdbcTemplate.reviewerUserId);
|
||||
assertNull(jdbcTemplate.secondReviewer);
|
||||
|
||||
@@ -87,6 +87,8 @@ export type PracticeScenarioSaveRequest = {
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
rounds: PracticeScenarioRound[];
|
||||
expectedContentVersion?: string;
|
||||
expectedContentHash?: string;
|
||||
};
|
||||
|
||||
export type PracticeScenarioReviewStatusRequest = {
|
||||
|
||||
@@ -2,13 +2,85 @@
|
||||
<div class="scenario-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>场景库管理</h1>
|
||||
<p>住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护</p>
|
||||
<h1>试点场景运营</h1>
|
||||
<p>默认每岗位、每层级选 1 个核心场景;目标 15 个,现有内容不足时按实际可用数量展示。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button @click="showAdvanced = !showAdvanced">{{ showAdvanced ? '收起完整场景库' : '展开完整场景库' }}</el-button>
|
||||
<el-button v-if="showAdvanced" type="danger" @click="openCreate">新增场景</el-button>
|
||||
</div>
|
||||
<el-button type="danger" @click="openCreate">新增场景</el-button>
|
||||
</section>
|
||||
|
||||
<section class="filter-card">
|
||||
<section class="pilot-card">
|
||||
<div class="pilot-head">
|
||||
<div>
|
||||
<h2>{{ pilotRows.length }} 个试点场景</h2>
|
||||
<p>自动初审只建议能力项与风险级别,不代替业务审核,也不会自动发布。</p>
|
||||
</div>
|
||||
<div class="pilot-actions">
|
||||
<el-button :disabled="!pilotSuggestionCount" :loading="batchSaving" @click="applyPilotSuggestions">
|
||||
批量确认初审建议({{ pilotSuggestionCount }})
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="pilotReadyDrafts.length"
|
||||
:loading="batchReviewing"
|
||||
@click="advancePilotRows(pilotReadyDrafts, '待业务审核')"
|
||||
>
|
||||
批量提交审核({{ pilotReadyDrafts.length }})
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="pilotReadyReviews.length"
|
||||
type="danger"
|
||||
:loading="batchReviewing"
|
||||
@click="advancePilotRows(pilotReadyReviews, '已发布')"
|
||||
>
|
||||
批量审核并发布({{ pilotReadyReviews.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert
|
||||
:closable="false"
|
||||
type="info"
|
||||
show-icon
|
||||
:title="`已选 ${pilotRows.length} 个场景;${pilotReadyCount} 个已通过发布预检,${pilotSuggestionCount} 个可批量补齐归类或风险。高风险仍执行双人复核。`"
|
||||
/>
|
||||
<el-table v-loading="loading" :data="pilotRows" row-key="id" size="small">
|
||||
<el-table-column prop="position" label="岗位" width="96" />
|
||||
<el-table-column prop="growthLevel" label="层级" width="72" />
|
||||
<el-table-column prop="name" label="场景" min-width="170" />
|
||||
<el-table-column label="能力项" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<span>{{ capabilityName(row) }}</span>
|
||||
<span v-if="row.suggestedCompetency && row.competencyCode !== row.suggestedCompetency.code" class="suggestion">
|
||||
→ {{ row.suggestedCompetency.name }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="风险" width="128">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.riskLevel }}</span>
|
||||
<span v-if="row.riskLevel !== row.suggestedRiskLevel" class="suggestion">→ {{ row.suggestedRiskLevel }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预检" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.issues.length ? 'warning' : 'success'" size="small">
|
||||
{{ row.issues.length ? `待补:${row.issues.join('、')}` : '可审核' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="openEdit(row)">查看</el-button>
|
||||
<el-button v-if="row.suggestionNeeded" link type="danger" :disabled="batchSaving" @click="applyPilotSuggestion(row)">
|
||||
确认建议
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section v-if="showAdvanced" class="filter-card">
|
||||
<el-select v-model="filters.position" placeholder="岗位" clearable>
|
||||
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
@@ -29,7 +101,7 @@
|
||||
<el-button type="danger" @click="queryScenes">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section class="matrix-card">
|
||||
<section v-if="showAdvanced" class="matrix-card">
|
||||
<div class="matrix-head">
|
||||
<div>
|
||||
<h2>矩阵缺口</h2>
|
||||
@@ -52,7 +124,7 @@
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<section v-if="showAdvanced" 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" />
|
||||
@@ -178,7 +250,7 @@
|
||||
:closable="false"
|
||||
type="info"
|
||||
show-icon
|
||||
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;收费、安全、投诉等受控内容会自动升级为高风险,需两位不同审核人。"
|
||||
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;人身、消防、燃气、带电、高空、舆情或退款减免等实质风险会自动升级为高风险,需两位不同审核人。"
|
||||
/>
|
||||
<el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item>
|
||||
<el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item>
|
||||
@@ -216,7 +288,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
listPracticeScenarios,
|
||||
listPracticeCurriculumCapabilities,
|
||||
@@ -233,6 +305,9 @@ import {
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const batchSaving = ref(false);
|
||||
const batchReviewing = ref(false);
|
||||
const showAdvanced = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const editingId = ref('');
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
@@ -282,6 +357,8 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
|
||||
strategy: '仅在漏关键步骤时提示',
|
||||
goal: '',
|
||||
enabled: true,
|
||||
expectedContentVersion: '',
|
||||
expectedContentHash: '',
|
||||
rounds: [
|
||||
{ customer: '', coachHint: '先承接情绪,再确认具体诉求。', emotion: 72, trust: 70 },
|
||||
{ customer: '我想知道接下来谁负责、什么时候反馈。', coachHint: '补上责任人、处理节点和回访时间。', emotion: 62, trust: 82 }
|
||||
@@ -314,6 +391,90 @@ const currentCapabilityOptions = computed(() =>
|
||||
curriculumCapabilities.value.filter((item) => item.position === form.position && item.growthLevel === form.growthLevel)
|
||||
);
|
||||
|
||||
type PilotScenarioRow = PracticeScenarioResponse & {
|
||||
suggestedCompetency?: PracticeCurriculumCapability;
|
||||
suggestedRiskLevel: '常规' | '高风险';
|
||||
suggestionNeeded: boolean;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
const knownCapability = (scenario: PracticeScenarioResponse) =>
|
||||
curriculumCapabilities.value.find((item) =>
|
||||
item.code === scenario.competencyCode
|
||||
&& item.position === scenario.position
|
||||
&& item.growthLevel === scenario.growthLevel
|
||||
);
|
||||
|
||||
const suggestedCapability = (scenario: PracticeScenarioResponse) => {
|
||||
const existing = knownCapability(scenario);
|
||||
if (existing) return existing;
|
||||
const options = curriculumCapabilities.value.filter((item) =>
|
||||
item.position === scenario.position && item.growthLevel === scenario.growthLevel
|
||||
);
|
||||
const text = [
|
||||
scenario.name,
|
||||
scenario.scenarioType,
|
||||
scenario.openingText,
|
||||
scenario.goal,
|
||||
scenario.sopRefs
|
||||
].join('');
|
||||
return options
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
score: item.name
|
||||
.split(/[\/、与及()()]/)
|
||||
.filter((term) => term.length >= 2)
|
||||
.reduce((score, term) => score + (text.includes(term) ? 1 : 0), 0)
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)[0]?.item;
|
||||
};
|
||||
|
||||
const scenarioIssues = (
|
||||
scenario: PracticeScenarioResponse,
|
||||
competencyCode = scenario.competencyCode,
|
||||
riskLevel = scenario.riskLevel
|
||||
) => {
|
||||
const issues: string[] = [];
|
||||
const capability = curriculumCapabilities.value.some((item) =>
|
||||
item.code === competencyCode && item.position === scenario.position && item.growthLevel === scenario.growthLevel
|
||||
);
|
||||
if (!capability) issues.push('能力项');
|
||||
if (riskLevel === '待评估') issues.push('风险');
|
||||
if (!scenario.sopRefs?.trim()) issues.push('SOP');
|
||||
if (!scenario.irritants?.trim()) issues.push('服务红线');
|
||||
if (!scenario.goal?.trim()) issues.push('训练目标');
|
||||
if (!scenario.successCriteria?.trim()) issues.push('成功标准');
|
||||
if (!scenario.rounds || scenario.rounds.length < 2) issues.push('训练回合');
|
||||
if (!scenario.rubric || scenario.rubric.dimensions?.length !== 5) issues.push('五维评分');
|
||||
return issues;
|
||||
};
|
||||
|
||||
const pilotRows = computed<PilotScenarioRow[]>(() =>
|
||||
positions.flatMap((position) =>
|
||||
growthLevels.map((growthLevel) => {
|
||||
const candidates = scenarios.value.filter((item) => item.position === position && item.growthLevel === growthLevel);
|
||||
const scenario = candidates.find((item) => item.enabled !== false && !item.requiresDualReview)
|
||||
|| candidates.find((item) => item.enabled !== false)
|
||||
|| candidates[0];
|
||||
if (!scenario) return undefined;
|
||||
const competency = suggestedCapability(scenario);
|
||||
const riskLevel = scenario.requiresDualReview ? '高风险' : '常规';
|
||||
return {
|
||||
...scenario,
|
||||
suggestedCompetency: competency,
|
||||
suggestedRiskLevel: riskLevel,
|
||||
suggestionNeeded: (!knownCapability(scenario) && Boolean(competency)) || scenario.riskLevel === '待评估',
|
||||
issues: scenarioIssues(scenario)
|
||||
};
|
||||
})
|
||||
).filter((item): item is PilotScenarioRow => Boolean(item))
|
||||
);
|
||||
const pilotSuggestionCount = computed(() => pilotRows.value.filter((item) => item.suggestionNeeded).length);
|
||||
const pilotReadyCount = computed(() => pilotRows.value.filter((item) => !item.issues.length).length);
|
||||
const pilotReadyDrafts = computed(() => pilotRows.value.filter((item) => !item.issues.length && item.reviewStatus === '草稿'));
|
||||
const pilotReadyReviews = computed(() => pilotRows.value.filter((item) => !item.issues.length && item.reviewStatus === '待业务审核'));
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -342,6 +503,101 @@ const queryScenes = () => {
|
||||
|
||||
const difficultyLabel = (value: number) => ({ 1: '初级', 2: '中级', 3: '高级', 4: '困难', 5: '挑战' })[value] || '中级';
|
||||
|
||||
const scenarioSavePayload = (
|
||||
item: PracticeScenarioResponse,
|
||||
competencyCode: string,
|
||||
riskLevel: '待评估' | '常规' | '高风险'
|
||||
): PracticeScenarioSaveRequest => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
scenarioType: item.scenarioType,
|
||||
projectType: item.projectType,
|
||||
growthLevel: item.growthLevel,
|
||||
competencyCode,
|
||||
collaborationPositions: item.collaborationPositions || '',
|
||||
riskLevel,
|
||||
curriculumVersion: item.curriculumVersion,
|
||||
difficulty: item.difficulty,
|
||||
customerPersona: item.customerPersona,
|
||||
openingText: item.openingText,
|
||||
explicitNeed: item.explicitNeed,
|
||||
hiddenNeed: item.hiddenNeed,
|
||||
irritants: item.irritants,
|
||||
delights: item.delights,
|
||||
sopRefs: item.sopRefs,
|
||||
successCriteria: item.successCriteria,
|
||||
strategy: item.strategy,
|
||||
goal: item.goal,
|
||||
enabled: item.enabled,
|
||||
expectedContentVersion: item.contentVersion,
|
||||
expectedContentHash: item.contentHash,
|
||||
rounds: item.rounds.map((round) => ({ ...round }))
|
||||
});
|
||||
|
||||
const savePilotSuggestions = async (rows: PilotScenarioRow[]) => {
|
||||
const targets = rows.filter((item) => item.suggestionNeeded && item.suggestedCompetency);
|
||||
const results = await Promise.allSettled(targets.map((item) =>
|
||||
savePracticeScenario(scenarioSavePayload(item, item.suggestedCompetency!.code, item.suggestedRiskLevel))
|
||||
));
|
||||
await load();
|
||||
const success = results.filter((item) => item.status === 'fulfilled').length;
|
||||
const failed = results.length - success;
|
||||
if (failed) ElMessage.warning(`已更新 ${success} 个,${failed} 个失败,请按行重试`);
|
||||
else ElMessage.success(`已确认 ${success} 个场景的初审建议`);
|
||||
};
|
||||
|
||||
const applyPilotSuggestion = async (row: PilotScenarioRow) => {
|
||||
batchSaving.value = true;
|
||||
try {
|
||||
await savePilotSuggestions([row]);
|
||||
} finally {
|
||||
batchSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPilotSuggestions = async () => {
|
||||
const count = pilotSuggestionCount.value;
|
||||
if (!count) return;
|
||||
await ElMessageBox.confirm(
|
||||
`将批量回写 ${count} 个试点场景的能力项和风险建议;不会自动发布。`,
|
||||
'确认初审建议',
|
||||
{ type: 'warning' }
|
||||
);
|
||||
batchSaving.value = true;
|
||||
try {
|
||||
await savePilotSuggestions(pilotRows.value);
|
||||
} finally {
|
||||
batchSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const advancePilotRows = async (
|
||||
rows: PilotScenarioRow[],
|
||||
reviewStatus: '待业务审核' | '已发布'
|
||||
) => {
|
||||
if (!rows.length) return;
|
||||
const action = reviewStatus === '已发布' ? '审核并发布' : '提交审核';
|
||||
await ElMessageBox.confirm(
|
||||
`将对 ${rows.length} 个已通过预检的试点场景执行“${action}”。高风险场景仍需另一位审核人复核。`,
|
||||
`批量${action}`,
|
||||
{ type: 'warning' }
|
||||
);
|
||||
batchReviewing.value = true;
|
||||
try {
|
||||
const results = await Promise.allSettled(rows.map((item) =>
|
||||
updatePracticeScenarioReviewStatus(item.id, { reviewStatus })
|
||||
));
|
||||
await load();
|
||||
const success = results.filter((item) => item.status === 'fulfilled').length;
|
||||
const failed = results.length - success;
|
||||
if (failed) ElMessage.warning(`已处理 ${success} 个,${failed} 个未通过服务端校验`);
|
||||
else ElMessage.success(`已处理 ${success} 个场景,页面已刷新显示最终审核状态`);
|
||||
} finally {
|
||||
batchReviewing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fillForm = (item?: PracticeScenarioResponse) => {
|
||||
Object.assign(
|
||||
form,
|
||||
@@ -370,6 +626,8 @@ const fillForm = (item?: PracticeScenarioResponse) => {
|
||||
strategy: item.strategy,
|
||||
goal: item.goal,
|
||||
enabled: item.enabled,
|
||||
expectedContentVersion: item.contentVersion,
|
||||
expectedContentHash: item.contentHash,
|
||||
rounds: item.rounds?.map((round) => ({ ...round })) || emptyForm().rounds
|
||||
}
|
||||
: {}
|
||||
@@ -535,12 +793,52 @@ onMounted(load);
|
||||
|
||||
.filter-card,
|
||||
.table-card,
|
||||
.matrix-card {
|
||||
.matrix-card,
|
||||
.pilot-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-actions,
|
||||
.pilot-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pilot-card {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:deep(.el-alert) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pilot-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 0;
|
||||
color: #858991;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
margin-left: 6px;
|
||||
color: #e60012;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr)) minmax(180px, 1.4fr) 78px;
|
||||
@@ -656,6 +954,9 @@ onMounted(load);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.pilot-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filter-card {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const proxyTarget = process.env.VITE_APP_PROXY_TARGET || env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
|
||||
const keepProxyPrefix = process.env.VITE_PROXY_KEEP_PREFIX === '1';
|
||||
return {
|
||||
// 部署生产环境和开发环境下的URL。
|
||||
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
|
||||
@@ -29,7 +30,7 @@ export default defineConfig(({ mode, command }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
|
||||
rewrite: (path) => keepProxyPrefix ? path : path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<text class="scene-picker-note">复杂场景的正式前置解锁规则仍待业务确认;系统不会以估算分数硬性拦截已发布训练。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card employee-card">
|
||||
<view v-if="loggedIn && practiceView === 'prep' && availableScenarios.length" class="panel scenario-card practice-scenario-card employee-card">
|
||||
<view class="scenario-headline">
|
||||
<text class="scenario-title">{{ activeScenarioName }}</text>
|
||||
<text class="scenario-desc">{{ selectedScenarioDescription }}</text>
|
||||
@@ -110,7 +110,14 @@
|
||||
>
|
||||
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
|
||||
</button>
|
||||
<view v-if="(status === 'idle' || status === 'finished') && realtimePersonaOptions.length" class="realtime-persona-picker">
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry-card employee-card">
|
||||
<view class="scenario-headline">
|
||||
<text class="scenario-title">实时陪练 Beta</text>
|
||||
<text class="scenario-desc">直接与数字师傅或模拟业主对话,不依赖场景审核发布。</text>
|
||||
</view>
|
||||
<view v-if="realtimePersonaOptions.length" class="realtime-persona-picker">
|
||||
<view class="realtime-persona-heading">
|
||||
<text class="realtime-persona-title">实时陪练人设</text>
|
||||
</view>
|
||||
@@ -134,10 +141,10 @@
|
||||
<button
|
||||
v-if="status === 'idle' || status === 'finished'"
|
||||
class="secondary-button realtime-start-button"
|
||||
:disabled="!canStartPractice || busy"
|
||||
:disabled="!canStartRealtime || busy"
|
||||
@click="startRealtime"
|
||||
>
|
||||
实时陪练 Beta
|
||||
开始实时陪练
|
||||
</button>
|
||||
<text class="realtime-beta-note">自然打断、边听边说;本次体验不计入训练成绩。</text>
|
||||
</view>
|
||||
@@ -593,6 +600,9 @@ const canStartPractice = computed(() =>
|
||||
&& (status.value === 'idle' || status.value === 'finished')
|
||||
&& Boolean(selectedScenario.value?.id)
|
||||
);
|
||||
const canStartRealtime = computed(() =>
|
||||
status.value === 'idle' || status.value === 'finished'
|
||||
);
|
||||
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
||||
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
|
||||
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
|
||||
@@ -1005,7 +1015,7 @@ const stopRealtime = () => {
|
||||
};
|
||||
|
||||
const startRealtime = async () => {
|
||||
if (!canStartPractice.value || !requireLogin()) return;
|
||||
if (!canStartRealtime.value || !requireLogin()) return;
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
turnVoiceGeneration += 1;
|
||||
|
||||
@@ -376,10 +376,14 @@ test('场景目录加载失败时清空旧场景并禁止开始训练', async ()
|
||||
assert.match(source, /!curriculumLoading\.value/);
|
||||
});
|
||||
|
||||
test('无已发布场景时不展示默认预习卡或训练入口', async () => {
|
||||
test('无已发布场景时隐藏常规训练,但仍展示独立实时陪练入口', async () => {
|
||||
const source = await pageSource('../src/pages/user/practice/index.vue');
|
||||
|
||||
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card employee-card"/);
|
||||
assert.match(source, /v-if="loggedIn && practiceView === 'prep' && availableScenarios\.length" class="panel scenario-card practice-scenario-card employee-card"/);
|
||||
assert.match(source, /v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card realtime-entry-card employee-card"/);
|
||||
assert.match(source, /:disabled="!canStartRealtime \|\| busy"/);
|
||||
assert.match(source, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);
|
||||
assert.doesNotMatch(source, /if \(!canStartPractice\.value \|\| !requireLogin\(\)\) return;\s*speechPlayback\.stop\(\);\s*cancelRecording\(\);\s*turnVoiceGeneration \+= 1;/);
|
||||
assert.match(source, /const activeScenario = nextScenarios\.find\([\s\S]*?\|\| nextScenarios\[0\];[\s\S]*?else \{\s*selectedScenarioId\.value = '';\s*activeScenarioName\.value = '暂无可用训练场景';\s*prepCard\.value = null;/);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ test('实时陪练通过受认证代理接入,前端不携带供应商密钥
|
||||
assert.match(page, /fetchRealtimePersonas/);
|
||||
assert.match(page, /createRealtimeSession/);
|
||||
assert.match(page, /正在查知识库/);
|
||||
assert.match(page, /class="panel scenario-card realtime-entry-card employee-card"/);
|
||||
assert.match(page, /const canStartRealtime = computed/);
|
||||
assert.match(page, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);
|
||||
assert.doesNotMatch(page, /realtimeVoice|selectRealtimeVoice|aihr_realtime_practice_voice/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user