feat(practice): complete five-position training operations

This commit is contained in:
2026-07-24 00:26:27 +08:00
parent 067cc30d80
commit faa2a77e9a
15 changed files with 1357 additions and 77 deletions
+86 -1
View File
@@ -30,6 +30,9 @@ export type PracticeScenarioResponse = {
strategy: string;
goal: string;
enabled: boolean;
rounds: PracticeScenarioRound[];
contentVersion: string;
contentHash: string;
rubric?: {
code: string;
name: string;
@@ -38,6 +41,13 @@ export type PracticeScenarioResponse = {
};
};
export type PracticeScenarioRound = {
customer: string;
coachHint: string;
emotion: number;
trust: number;
};
export type PracticeScenarioEnabledRequest = {
enabled: boolean;
};
@@ -60,6 +70,7 @@ export type PracticeScenarioSaveRequest = {
strategy: string;
goal: string;
enabled: boolean;
rounds: PracticeScenarioRound[];
};
export type PracticeRubricDimension = {
@@ -82,6 +93,56 @@ export type PracticeRubricAdmin = {
export type PracticeRubricSaveRequest = Omit<PracticeRubricAdmin, 'scenarioName'>;
export type PracticeQuestion = {
code: string;
scenarioCode: string;
scenarioName: string;
positionCode: string;
type: 'daily' | 'special';
question: string;
referenceAnswer: string;
contentVersion: string;
contentHash: string;
enabled: boolean;
sortOrder: number;
};
export type PracticeQuestionSaveRequest = Omit<PracticeQuestion, 'scenarioName' | 'contentVersion' | 'contentHash'>;
export type ManagedPracticeAssignment = {
id: number;
extPartyId: string;
traineeName: string;
scenarioId: string;
scenarioName: string;
source: string;
reason: string;
status: string;
createTime: string;
dueDate?: string;
contentVersion?: string;
contentHash?: string;
question?: string;
referenceAnswer?: string;
};
export type ManagedPracticeAssignmentBatchRequest = {
requestId: string;
targets: Array<{ extPartyId: string; traineeName?: string }>;
scenarioId: string;
reason: string;
dueDate: string;
};
export type ManagedPracticeAssignmentBatchResponse = {
requestId: string;
total: number;
created: number;
duplicate: number;
failed: number;
results: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>;
};
export type PracticePrepCardResponse = {
keyPoints: string[];
redLines: string[];
@@ -200,7 +261,7 @@ export type PromptTemplateRequest = {
description: string;
};
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string; includeDisabled?: boolean }): Promise<ApiResult<PracticeScenarioResponse[]>> {
return request({
url: '/api/train/practice/scenarios',
method: 'get',
@@ -208,6 +269,18 @@ export function listPracticeScenarios(params?: { position?: string; scenarioType
});
}
export function listPracticeQuestions(params?: { position?: string; type?: 'daily' | 'special'; includeDisabled?: boolean }): Promise<ApiResult<PracticeQuestion[]>> {
return request({ url: '/api/train/practice/questions', method: 'get', params });
}
export function savePracticeQuestion(data: PracticeQuestionSaveRequest): Promise<ApiResult<PracticeQuestion>> {
return request({ url: '/api/train/practice/questions', method: 'post', data });
}
export function updatePracticeQuestionEnabled(code: string, enabled: boolean): Promise<ApiResult<null>> {
return request({ url: `/api/train/practice/questions/${encodeURIComponent(code)}/enabled`, method: 'patch', data: { enabled } });
}
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
return request({
url: `/api/train/practice/scenarios/${id}/enabled`,
@@ -240,6 +313,18 @@ export function updatePracticeRubricEnabled(code: string, enabled: boolean): Pro
});
}
export function listManagedPracticeAssignments(params?: { position?: string; status?: string; source?: string; keyword?: string; limit?: number }): Promise<ApiResult<ManagedPracticeAssignment[]>> {
return request({ url: '/api/train/practice/assignments/manage', method: 'get', params });
}
export function createManagedPracticeAssignments(data: ManagedPracticeAssignmentBatchRequest): Promise<ApiResult<ManagedPracticeAssignmentBatchResponse>> {
return request({ url: '/api/train/practice/assignments/manage/batch', method: 'post', data, headers: { repeatSubmit: false } });
}
export function cancelManagedPracticeAssignment(id: number, reason?: string): Promise<ApiResult<null>> {
return request({ url: `/api/train/practice/assignments/manage/${id}/cancel`, method: 'patch', data: { reason }, headers: { repeatSubmit: false } });
}
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
return request({
url: `/api/train/practice/scenarios/${id}/prep-card`,
+12
View File
@@ -120,6 +120,18 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'TrainScenarios',
meta: { title: '场景与评分', icon: 'list' }
},
{
path: 'content',
component: () => import('@/views/train/content.vue'),
name: 'TrainContent',
meta: { title: '训练题库', icon: 'edit' }
},
{
path: 'tasks',
component: () => import('@/views/train/tasks.vue'),
name: 'TrainTasks',
meta: { title: '任务运营', icon: 'date' }
},
{
path: 'rubrics',
component: () => import('@/views/train/rubrics.vue'),
+158
View File
@@ -0,0 +1,158 @@
<template>
<div class="content-page">
<section class="page-head">
<div>
<h1>训练题库</h1>
<p>维护五岗位每日三题与专项任务题目;发布后只影响后续派发,已派任务保留内容快照。</p>
</div>
<el-button type="danger" @click="openCreate">新增题目</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="filters.type" clearable placeholder="题目用途"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select>
<el-select v-model="filters.enabled" clearable placeholder="状态"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select>
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="filteredRows" row-key="code">
<el-table-column prop="positionCode" label="岗位" width="96" />
<el-table-column prop="scenarioName" label="训练场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="type" label="用途" width="96"><template #default="{ row }">{{ row.type === 'daily' ? '每日三题' : '专项任务' }}</template></el-table-column>
<el-table-column prop="question" label="题干" min-width="300" show-overflow-tooltip />
<el-table-column prop="contentVersion" label="版本" width="90" />
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="openEdit(row)">编辑</el-button>
<el-button type="danger" link @click="toggle(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">共 {{ filteredRows.length }} 题;每日任务调度仅选择已启用的 daily 题目和已启用场景。</div>
</section>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑题目(将生成新版本)' : '新增题目'" width="720px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="14">
<el-col :span="12"><el-form-item label="岗位" required><el-select v-model="form.positionCode" class="full" @change="onPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="用途" required><el-select v-model="form.type" class="full"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select></el-form-item></el-col>
</el-row>
<el-form-item label="训练场景" required><el-select v-model="form.scenarioCode" filterable class="full"><el-option v-for="item in scenarioOptions" :key="item.id" :label="`${item.name}(${item.id})`" :value="item.id" /></el-select></el-form-item>
<el-form-item label="题目编码"><el-input v-model="form.code" :disabled="editing" placeholder="留空自动生成;编码创建后不可修改" /></el-form-item>
<el-form-item label="题干" required><el-input v-model="form.question" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
<el-form-item label="参考答案 / 关键动作" required><el-input v-model="form.referenceAnswer" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
<el-row :gutter="14">
<el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" class="full" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="发布状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="danger" :loading="saving" @click="save">保存并生成版本</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
listPracticeQuestions,
listPracticeScenarios,
savePracticeQuestion,
updatePracticeQuestionEnabled,
type PracticeQuestion,
type PracticeQuestionSaveRequest,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const editing = ref(false);
const rows = ref<PracticeQuestion[]>([]);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const filters = reactive<{ position: string; type: '' | 'daily' | 'special'; enabled: boolean | undefined }>({ position: '', type: '', enabled: undefined });
const emptyForm = (): PracticeQuestionSaveRequest => ({
code: '', scenarioCode: '', positionCode: '生活顾问', type: 'daily', question: '', referenceAnswer: '', enabled: true, sortOrder: 100
});
const form = reactive<PracticeQuestionSaveRequest>(emptyForm());
const scenarioOptions = computed(() => scenarios.value.filter((item) => item.position === form.positionCode));
const filteredRows = computed(() => rows.value.filter((item) =>
(!filters.position || item.positionCode === filters.position) &&
(!filters.type || item.type === filters.type) &&
(filters.enabled === undefined || item.enabled === filters.enabled)
));
const load = async () => {
loading.value = true;
try {
const [questionsResult, scenariosResult] = await Promise.all([
listPracticeQuestions({ includeDisabled: true }),
listPracticeScenarios({ includeDisabled: true })
]);
rows.value = questionsResult.data || [];
scenarios.value = scenariosResult.data || [];
} catch {
ElMessage.error('训练题库读取失败');
} finally {
loading.value = false;
}
};
const resetForm = () => Object.assign(form, emptyForm());
const openCreate = () => { editing.value = false; resetForm(); dialogVisible.value = true; };
const openEdit = (item: PracticeQuestion) => {
editing.value = true;
Object.assign(form, {
code: item.code,
scenarioCode: item.scenarioCode,
positionCode: item.positionCode,
type: item.type,
question: item.question,
referenceAnswer: item.referenceAnswer,
enabled: item.enabled,
sortOrder: item.sortOrder
});
dialogVisible.value = true;
};
const onPositionChange = () => {
if (!scenarioOptions.value.some((item) => item.id === form.scenarioCode)) form.scenarioCode = '';
};
const toggle = async (item: PracticeQuestion) => {
await updatePracticeQuestionEnabled(item.code, !item.enabled);
item.enabled = !item.enabled;
ElMessage.success(item.enabled ? '题目已启用' : '题目已停用');
};
const save = async () => {
if (!form.scenarioCode || !form.question.trim() || !form.referenceAnswer.trim()) {
ElMessage.warning('请补齐训练场景、题干和参考答案');
return;
}
saving.value = true;
try {
await savePracticeQuestion({ ...form, code: form.code.trim() });
dialogVisible.value = false;
await load();
ElMessage.success('题目已保存,后续派发会使用新版本');
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.content-page { min-height: calc(100vh - 84px); padding: 24px 28px 40px; background: #f4f6fa; color: #172033; }
.page-head { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 24px; }
.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }
.filter-card, .table-card { padding: 18px; border-radius: 10px; background: #fff; }
.filter-card { display: flex; gap: 12px; margin-bottom: 12px; }
.filter-card :deep(.el-select) { width: 180px; }
.table-footer { margin-top: 16px; color: #667085; font-size: 12px; }
.full { width: 100%; }
@media (max-width: 900px) { .filter-card { flex-wrap: wrap; } .filter-card :deep(.el-select) { width: 46%; } }
</style>
+64 -8
View File
@@ -3,14 +3,14 @@
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>生活顾问岗位 · 场景保存、Rubric 版本与启停维护</p>
<p>五岗位训练内容 · 场景回合、版本、Rubric 与启停维护</p>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" placeholder="岗位" clearable>
<el-option label="生活顾问" value="生活顾问" />
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
@@ -25,6 +25,7 @@
<section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="difficulty" label="难度" width="92">
@@ -51,7 +52,7 @@
</el-table-column>
</el-table>
<div class="table-footer">
<span>共 {{ filteredScenarios.length }} 个生活顾问场景 · 当前启用 {{ enabledCount }} 个</span>
<span>共 {{ filteredScenarios.length }} 个场景 · 当前启用 {{ enabledCount }} 个</span>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
@@ -78,7 +79,11 @@
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="岗位"><el-input v-model="form.position" /></el-form-item>
<el-form-item label="岗位">
<el-select v-model="form.position" class="full-control">
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
@@ -93,6 +98,19 @@
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
<el-form-item label="训练回合">
<div class="round-list">
<div v-for="(round, index) in form.rounds" :key="index" class="round-row">
<strong>第 {{ index + 1 }} 轮</strong>
<el-input v-model="round.customer" type="textarea" :rows="2" placeholder="业主/现场人员话术" />
<el-input v-model="round.coachHint" placeholder="教练提示" />
<el-input-number v-model="round.emotion" :min="0" :max="100" controls-position="right" />
<el-input-number v-model="round.trust" :min="0" :max="100" controls-position="right" />
<el-button link type="danger" :disabled="form.rounds.length <= 2" @click="form.rounds.splice(index, 1)">删除</el-button>
</div>
<el-button plain :disabled="form.rounds.length >= 6" @click="addRound">新增回合</el-button>
</div>
</el-form-item>
<el-switch v-model="form.enabled" active-text="启用场景" inactive-text="停用场景" />
</el-form>
<template #footer>
@@ -111,6 +129,7 @@ import {
savePracticeScenario,
updatePracticeScenarioEnabled,
type PracticeScenarioResponse,
type PracticeScenarioRound,
type PracticeScenarioSaveRequest
} from '@/api/aihr/practice';
@@ -119,9 +138,10 @@ const saving = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
position: '生活顾问',
position: '',
scenarioType: '',
enabled: undefined,
keyword: ''
@@ -144,7 +164,11 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
successCriteria: '',
strategy: '仅在漏关键步骤时提示',
goal: '',
enabled: true
enabled: true,
rounds: [
{ customer: '', coachHint: '先承接情绪,再确认具体诉求。', emotion: 72, trust: 70 },
{ customer: '我想知道接下来谁负责、什么时候反馈。', coachHint: '补上责任人、处理节点和回访时间。', emotion: 62, trust: 82 }
]
});
const form = reactive(emptyForm());
const dialogTitle = computed(() => (editingId.value ? '编辑场景' : '新增场景'));
@@ -153,6 +177,7 @@ const filteredScenarios = computed(() =>
const keyword = filters.keyword.trim().toLowerCase();
return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(!filters.position || item.position === filters.position) &&
(filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
);
@@ -167,7 +192,7 @@ const enabledCount = computed(() => scenarios.value.filter((item) => item.enable
const load = async () => {
loading.value = true;
try {
const { data } = await listPracticeScenarios();
const { data } = await listPracticeScenarios({ includeDisabled: true });
scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
} finally {
@@ -203,7 +228,8 @@ const fillForm = (item?: PracticeScenarioResponse) => {
successCriteria: item.successCriteria,
strategy: item.strategy,
goal: item.goal,
enabled: item.enabled
enabled: item.enabled,
rounds: item.rounds?.map((round) => ({ ...round })) || emptyForm().rounds
}
: {}
);
@@ -221,6 +247,12 @@ const openEdit = (item: PracticeScenarioResponse) => {
dialogVisible.value = true;
};
const addRound = () => {
if (form.rounds.length < 6) {
form.rounds.push({ customer: '', coachHint: '补上责任人、反馈时间和SOP依据。', emotion: 70, trust: 75 } as PracticeScenarioRound);
}
};
const toggleEnabled = async (item: PracticeScenarioResponse) => {
const enabled = item.enabled === false;
await updatePracticeScenarioEnabled(item.id, { enabled });
@@ -335,6 +367,27 @@ onMounted(load);
width: 100%;
}
.round-list {
display: grid;
width: 100%;
gap: 10px;
}
.round-row {
display: grid;
grid-template-columns: 72px minmax(180px, 1fr) minmax(160px, 1fr) 96px 96px 48px;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafbfc;
strong {
font-size: 13px;
}
}
@media (max-width: 1100px) {
.filter-card {
grid-template-columns: 1fr 1fr;
@@ -342,5 +395,8 @@ onMounted(load);
.filter-card .el-button {
width: 100%;
}
.round-row {
grid-template-columns: 1fr 1fr;
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="tasks-page">
<section class="page-head">
<div>
<h1>训练任务运营</h1>
<p>统一查看自动/人工/重练任务,按组织名单批量派发专项,并可在开始训练前撤回。</p>
</div>
<el-button type="danger" @click="openAssign">批量派发专项</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="filters.status" clearable placeholder="任务状态"><el-option label="待训练" value="待训练" /><el-option label="已完成" value="已完成" /><el-option label="已撤回" value="已撤回" /></el-select>
<el-select v-model="filters.source" clearable placeholder="任务来源"><el-option label="每日三题" value="daily" /><el-option label="人工专项" value="manual" /><el-option label="低分重练" value="retry" /></el-select>
<el-input v-model="filters.keyword" clearable placeholder="场景名称 / 员工ID" @keyup.enter="load" />
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
</section>
<section class="metric-grid">
<div><span>待训练</span><strong>{{ pendingCount }}</strong></div><div><span>已完成</span><strong>{{ completedCount }}</strong></div><div><span>已撤回</span><strong>{{ withdrawnCount }}</strong></div><div><span>当前列表</span><strong>{{ rows.length }}</strong></div>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column prop="traineeName" label="员工" width="112" /><el-table-column prop="extPartyId" label="员工ID" min-width="130" show-overflow-tooltip />
<el-table-column prop="scenarioName" label="场景" min-width="160" show-overflow-tooltip />
<el-table-column prop="source" label="来源" width="98"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column prop="dueDate" label="截止日" width="112" /><el-table-column prop="status" label="状态" width="92"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="contentVersion" label="内容版本" width="96" /><el-table-column prop="reason" label="任务说明" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="96" fixed="right"><template #default="{ row }"><el-button v-if="row.status === '待训练' && row.source !== 'daily'" type="danger" link @click="withdraw(row)">撤回</el-button><span v-else class="muted">—</span></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="assignVisible" title="批量派发专项训练" width="900px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="项目"><el-select v-model="projectCode" clearable filterable class="full" @change="loadPeople"><el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="岗位"><el-select v-model="assignmentPosition" class="full" @change="onAssignmentPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="训练场景"><el-select v-model="assignmentScenarioId" class="full"><el-option v-for="item in assignmentScenarios" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col>
</el-row>
<el-row :gutter="14"><el-col :span="12"><el-form-item label="截止日期"><el-date-picker v-model="dueDate" type="date" value-format="YYYY-MM-DD" class="full" :disabled-date="disablePast" /></el-form-item></el-col><el-col :span="12"><el-form-item label="任务说明"><el-input v-model="reason" maxlength="180" show-word-limit /></el-form-item></el-col></el-row>
<el-form-item label="选择员工(最多 100 人)"><el-alert :title="`当前岗位可派 ${eligiblePeople.length} 人,已选择 ${selectedPeople.length} 人。组织岗位会按统一规则归类;服务端会再次核验在职状态、组织范围和岗位匹配。`" type="info" :closable="false" /><el-table v-loading="peopleLoading" :data="eligiblePeople" max-height="360" row-key="extPartyId" @selection-change="onSelectPeople"><el-table-column type="selection" width="48" /><el-table-column prop="personName" label="员工" min-width="130" /><el-table-column label="组织岗位(训练归类)" min-width="180"><template #default="{ row }">{{ row.positionName }}({{ canonicalPosition(row.positionName) }})</template></el-table-column><el-table-column prop="projectName" label="项目" min-width="160" /><el-table-column prop="extPartyId" label="员工ID" min-width="140" /></el-table></el-form-item>
</el-form>
<template #footer><el-button @click="assignVisible = false">取消</el-button><el-button type="danger" :loading="assigning" @click="assign">确认派发</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
cancelManagedPracticeAssignment,
createManagedPracticeAssignments,
listManagedPracticeAssignments,
listPracticeScenarios,
type ManagedPracticeAssignment,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
import { getOrgSnapshot, type OrgPersonRow, type OrgProjectOption } from '@/api/aihr/mobile-admin';
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const loading = ref(false); const assigning = ref(false); const peopleLoading = ref(false); const assignVisible = ref(false);
const rows = ref<ManagedPracticeAssignment[]>([]); const scenarios = ref<PracticeScenarioResponse[]>([]); const people = ref<OrgPersonRow[]>([]); const selectedPeople = ref<OrgPersonRow[]>([]); const projectOptions = ref<OrgProjectOption[]>([]);
const filters = reactive({ position: '', status: '', source: '', keyword: '' });
const projectCode = ref(''); const assignmentPosition = ref('生活顾问'); const assignmentScenarioId = ref(''); const dueDate = ref(''); const reason = ref('训练运营派发专项训练');
const assignmentScenarios = computed(() => scenarios.value.filter((item) => item.enabled && item.position === assignmentPosition.value));
const canonicalPosition = (value: string) => {
const text = String(value || '').trim();
if (text.includes('生活顾问') || text.includes('客服管家') || text.includes('物业管家') || text === '管家') return '生活顾问';
if (text.includes('保安') || text.includes('秩序') || text.includes('安保')) return '保安';
if (text.includes('保洁') || text.includes('环境')) return '保洁';
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
if (text.includes('客服') || text.includes('前台')) return '客服';
return text;
};
const eligiblePeople = computed(() => people.value.filter((item) => canonicalPosition(item.positionName) === assignmentPosition.value));
const pendingCount = computed(() => rows.value.filter((item) => item.status === '待训练').length); const completedCount = computed(() => rows.value.filter((item) => item.status === '已完成').length); const withdrawnCount = computed(() => rows.value.filter((item) => item.status === '已撤回').length);
const sourceLabel = (source: string) => ({ daily: '每日三题', manual: '人工专项', retry: '低分重练', camp: '训练营' }[source] || source);
const statusType = (status: string) => status === '待训练' ? 'warning' : status === '已完成' ? 'success' : 'info';
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
const load = async () => { loading.value = true; try { const result = await listManagedPracticeAssignments({ ...filters, limit: 200 }); rows.value = result.data || []; } catch { ElMessage.error('训练任务读取失败'); } finally { loading.value = false; } };
const loadScenarios = async () => { const result = await listPracticeScenarios({ includeDisabled: false }); scenarios.value = result.data || []; onAssignmentPositionChange(); };
const loadPeople = async () => { peopleLoading.value = true; try { const result = await getOrgSnapshot({ status: 'active', projectCode: projectCode.value || undefined, pageNum: 1, pageSize: 500 }); people.value = result.data?.rows || []; projectOptions.value = result.data?.projects || projectOptions.value; selectedPeople.value = []; } catch { people.value = []; ElMessage.error('组织名单读取失败'); } finally { peopleLoading.value = false; } };
const onAssignmentPositionChange = () => { selectedPeople.value = []; if (!assignmentScenarios.value.some((item) => item.id === assignmentScenarioId.value)) assignmentScenarioId.value = assignmentScenarios.value[0]?.id || ''; };
const onSelectPeople = (value: OrgPersonRow[]) => { selectedPeople.value = value.slice(0, 100); if (value.length > 100) ElMessage.warning('单次最多派发 100 人'); };
const openAssign = async () => { assignVisible.value = true; dueDate.value = formatDate(new Date(Date.now() + 86400000)); await Promise.all([loadScenarios(), loadPeople()]); };
const assign = async () => { if (!assignmentScenarioId.value || !dueDate.value || !selectedPeople.value.length) { ElMessage.warning('请先选择场景、截止日期和员工'); return; } assigning.value = true; try { const result = await createManagedPracticeAssignments({ requestId: `hr-task:${Date.now()}`, targets: selectedPeople.value.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.personName })), scenarioId: assignmentScenarioId.value, reason: reason.value.trim() || '训练运营派发专项训练', dueDate: dueDate.value }); const failed = result.data?.failed || 0; ElMessage.success(failed ? `已派发,${failed} 人未成功` : '批量派发成功'); assignVisible.value = false; await load(); } catch { ElMessage.error('专项训练派发失败'); } finally { assigning.value = false; } };
const withdraw = async (row: ManagedPracticeAssignment) => { await ElMessageBox.confirm(`撤回“${row.scenarioName}”任务?员工尚未开始训练时才可撤回。`, '确认撤回', { type: 'warning' }); await cancelManagedPracticeAssignment(row.id, '训练运营调整'); ElMessage.success('任务已撤回'); await load(); };
onMounted(async () => { await Promise.all([load(), loadScenarios(), loadPeople()]); });
</script>
<style scoped lang="scss">
.tasks-page { min-height: calc(100vh - 84px); padding: 24px 28px 40px; background: #f4f6fa; color: #172033; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }.page-head h1 { margin: 0; font-size: 24px; }.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }
.filter-card, .table-card { padding: 18px; border-radius: 10px; background: #fff; }.filter-card { display: grid; grid-template-columns: 160px 140px 140px minmax(180px, 1fr) 76px; gap: 12px; margin-bottom: 12px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }.metric-grid > div { display: grid; gap: 8px; padding: 16px; border-radius: 10px; background: #fff; }.metric-grid span, .muted { color: #98a2b3; }.metric-grid strong { font-size: 26px; }.full { width: 100%; }
@media (max-width: 1000px) { .filter-card { grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: 1fr 1fr; } }
</style>