feat(aihr): align figma pages for release
This commit is contained in:
@@ -42,6 +42,46 @@ export type PracticeScenarioEnabledRequest = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticeScenarioSaveRequest = {
|
||||
id?: string;
|
||||
name: string;
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
explicitNeed: string;
|
||||
hiddenNeed: string;
|
||||
irritants: string;
|
||||
delights: string;
|
||||
sopRefs: string;
|
||||
successCriteria: string;
|
||||
strategy: string;
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticeRubricDimension = {
|
||||
code: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type PracticeRubricAdmin = {
|
||||
code: string;
|
||||
scenarioCode: string;
|
||||
scenarioName: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
dimensions: PracticeRubricDimension[];
|
||||
};
|
||||
|
||||
export type PracticeRubricSaveRequest = Omit<PracticeRubricAdmin, 'scenarioName'>;
|
||||
|
||||
export type PracticePrepCardResponse = {
|
||||
keyPoints: string[];
|
||||
redLines: string[];
|
||||
@@ -174,6 +214,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario
|
||||
});
|
||||
}
|
||||
|
||||
export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> {
|
||||
return request({
|
||||
url: '/api/train/practice/scenarios',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeRubrics(): Promise<ApiResult<PracticeRubricAdmin[]>> {
|
||||
return request({ url: '/api/train/practice/rubrics/manage', method: 'get' });
|
||||
}
|
||||
|
||||
export function savePracticeRubric(data: PracticeRubricSaveRequest): Promise<ApiResult<PracticeRubricAdmin>> {
|
||||
return request({ url: '/api/train/practice/rubrics/manage', method: 'post', data });
|
||||
}
|
||||
|
||||
export function updatePracticeRubricEnabled(code: string, enabled: boolean): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/train/practice/rubrics/${encodeURIComponent(code)}/enabled`,
|
||||
method: 'patch',
|
||||
data: { enabled }
|
||||
});
|
||||
}
|
||||
|
||||
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/prep-card`,
|
||||
|
||||
@@ -48,6 +48,25 @@ export type SopReviewResponse = {
|
||||
reviewedAt: string;
|
||||
};
|
||||
|
||||
export type AnswerFeedbackItem = {
|
||||
id: number;
|
||||
queryText: string;
|
||||
queryNorm: string;
|
||||
fragmentId: number;
|
||||
verdict: string;
|
||||
category: string;
|
||||
position: string;
|
||||
source: string;
|
||||
reviewStatus: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
};
|
||||
|
||||
export type AnswerFeedbackReviewResponse = {
|
||||
id: number;
|
||||
reviewStatus: string;
|
||||
};
|
||||
|
||||
export type SopUploadResponse = {
|
||||
docId: string;
|
||||
ossId: number;
|
||||
@@ -87,6 +106,23 @@ export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer
|
||||
});
|
||||
}
|
||||
|
||||
export function listAnswerFeedback(status = '待复核', limit = 50): Promise<ApiResult<AnswerFeedbackItem[]>> {
|
||||
return request({
|
||||
url: '/api/knowledge/answer-feedback',
|
||||
method: 'get',
|
||||
params: { status, limit },
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewAnswerFeedback(id: number): Promise<ApiResult<AnswerFeedbackReviewResponse>> {
|
||||
return request({
|
||||
url: `/api/knowledge/answer-feedback/${id}/review`,
|
||||
method: 'post',
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export type UploadEnqueueResponse = {
|
||||
itemId: number;
|
||||
batchId: string;
|
||||
|
||||
@@ -101,6 +101,18 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
name: 'TrainPractice',
|
||||
meta: { title: '三角色对练', icon: 'peoples' }
|
||||
},
|
||||
{
|
||||
path: 'scenarios',
|
||||
component: () => import('@/views/train/scenarios.vue'),
|
||||
name: 'TrainScenarios',
|
||||
meta: { title: '场景库', icon: 'list' }
|
||||
},
|
||||
{
|
||||
path: 'rubrics',
|
||||
component: () => import('@/views/train/rubrics.vue'),
|
||||
name: 'TrainRubrics',
|
||||
meta: { title: 'Rubric 规则', icon: 'list' }
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
component: () => import('@/views/train/reviews.vue'),
|
||||
@@ -157,6 +169,12 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/model/index.vue'),
|
||||
name: 'SystemModelConfig',
|
||||
meta: { title: '模型配置', icon: 'model' }
|
||||
},
|
||||
{
|
||||
path: 'prompts',
|
||||
component: () => import('@/views/system/prompts.vue'),
|
||||
name: 'SystemPromptTemplates',
|
||||
meta: { title: '提示词模板', icon: 'edit' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -283,12 +283,14 @@ import {
|
||||
} from '@/api/aihr/processing';
|
||||
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
|
||||
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v';
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
|
||||
const autoCategory = '__auto__';
|
||||
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v)$/i;
|
||||
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
|
||||
const videoPattern = /\.(mp4|mov|avi|mkv|webm|m4v)$/i;
|
||||
const archivePattern = /\.zip$/i;
|
||||
const maxFileSize = 100 * 1024 * 1024;
|
||||
const maxVideoSize = 500 * 1024 * 1024;
|
||||
const maxArchiveSize = 500 * 1024 * 1024;
|
||||
const browserBatchWarnCount = 20;
|
||||
const browserBatchWarnSize = 200 * 1024 * 1024;
|
||||
|
||||
@@ -484,14 +486,17 @@ async function handlePickedFiles(event: Event) {
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const readyFiles = files.filter(
|
||||
(file) => supportedPattern.test(file.name) && file.size > 0 && file.size <= (videoPattern.test(file.name) ? maxVideoSize : maxFileSize)
|
||||
(file) =>
|
||||
supportedPattern.test(file.name) &&
|
||||
file.size > 0 &&
|
||||
file.size <= (videoPattern.test(file.name) ? maxVideoSize : archivePattern.test(file.name) ? maxArchiveSize : maxFileSize)
|
||||
);
|
||||
if (readyFiles.length === 0) {
|
||||
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)或视频(500MB内)文件');
|
||||
ElMessage.warning('仅支持非空的 txt/md/pdf/Word/Excel/PPT/图片(100MB内)、视频或 ZIP(500MB内)文件');
|
||||
return;
|
||||
}
|
||||
if (readyFiles.length < files.length) {
|
||||
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频 500MB)`);
|
||||
ElMessage.warning(`已跳过 ${files.length - readyFiles.length} 个不支持、空文件或超限文件(文档/图片 100MB、视频/ZIP 500MB)`);
|
||||
}
|
||||
|
||||
const totalSize = readyFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
@@ -89,6 +89,50 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel pending-review-panel">
|
||||
<div class="panel-title with-actions">
|
||||
<div class="panel-title-main">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<div>
|
||||
<h2>答案待复核</h2>
|
||||
<p>员工反馈为「没解决」的答案会进入这里,复核后从待办队列移除。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-toolbar">
|
||||
<el-select v-model="pendingReviewStatus" size="small" @change="loadPendingReviews">
|
||||
<el-option label="待复核" value="待复核" />
|
||||
<el-option label="已复核" value="已复核" />
|
||||
</el-select>
|
||||
<el-button size="small" :loading="pendingReviewLoading" @click="loadPendingReviews">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="pendingReviewLoading" :data="pendingReviews" size="small" class="record-table">
|
||||
<el-table-column prop="queryText" label="员工问题" min-width="260" />
|
||||
<el-table-column prop="verdict" label="反馈" width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="position" label="岗位" width="120" />
|
||||
<el-table-column prop="createTime" label="提交时间" width="170" />
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.reviewStatus === '待复核'"
|
||||
link
|
||||
type="danger"
|
||||
:loading="reviewingFeedbackId === row.id"
|
||||
@click="markFeedbackReviewed(row)"
|
||||
>
|
||||
标记已复核
|
||||
</el-button>
|
||||
<span v-else class="review-done">已复核</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!pendingReviewLoading && !pendingReviews.length" description="暂无待复核答案" />
|
||||
</section>
|
||||
|
||||
<section class="panel record-panel">
|
||||
<div class="panel-title">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
@@ -110,9 +154,18 @@
|
||||
import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { UploadFile } from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
import {
|
||||
listAnswerFeedback,
|
||||
reviewAnswerFeedback,
|
||||
reviewKnowledgeSop,
|
||||
searchKnowledgeSop,
|
||||
uploadKnowledgeDoc,
|
||||
type AnswerFeedbackItem,
|
||||
type SopRecord,
|
||||
type SopSearchResponse
|
||||
} from '@/api/aihr/sop';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -212,6 +265,10 @@ const uploading = ref(false);
|
||||
const uploadedRecords = ref<SopRecord[]>([]);
|
||||
const reviewSaving = ref(false);
|
||||
const reviewStatus = ref('');
|
||||
const pendingReviewStatus = ref('待复核');
|
||||
const pendingReviewLoading = ref(false);
|
||||
const reviewingFeedbackId = ref<number | null>(null);
|
||||
const pendingReviews = ref<AnswerFeedbackItem[]>([]);
|
||||
|
||||
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
|
||||
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -318,6 +375,32 @@ async function markReview(usable: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingReviews() {
|
||||
pendingReviewLoading.value = true;
|
||||
try {
|
||||
const { data } = await listAnswerFeedback(pendingReviewStatus.value);
|
||||
pendingReviews.value = data ?? [];
|
||||
} catch {
|
||||
pendingReviews.value = [];
|
||||
ElMessage.error('答案待复核列表加载失败');
|
||||
} finally {
|
||||
pendingReviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markFeedbackReviewed(row: AnswerFeedbackItem) {
|
||||
reviewingFeedbackId.value = row.id;
|
||||
try {
|
||||
await reviewAnswerFeedback(row.id);
|
||||
pendingReviews.value = pendingReviews.value.filter((item) => item.id !== row.id);
|
||||
ElMessage.success('答案已标记为已复核');
|
||||
} catch {
|
||||
ElMessage.error('答案复核失败');
|
||||
} finally {
|
||||
reviewingFeedbackId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocSelected(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw;
|
||||
if (!raw) return;
|
||||
@@ -359,6 +442,10 @@ async function generateTraining() {
|
||||
trainingGenerated.value = true;
|
||||
ElMessage.success('已基于引用片段生成训练题');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPendingReviews();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -425,6 +512,48 @@ async function generateTraining() {
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title.with-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title-main,
|
||||
.review-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-title-main {
|
||||
gap: 8px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #657184;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.review-toolbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pending-review-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.review-verdict,
|
||||
.review-done {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.review-verdict.negative {
|
||||
color: #c80f1f;
|
||||
}
|
||||
|
||||
.review-done {
|
||||
color: #15844a;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="prompt-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>提示词模板管理</h1>
|
||||
<p>统一维护训练、评分、复盘和准备卡模板</p>
|
||||
</div>
|
||||
<el-button type="danger" :icon="Plus" @click="createPrompt">新增模板</el-button>
|
||||
</div>
|
||||
<div class="prompt-layout">
|
||||
<section class="list-card">
|
||||
<h2>模板列表</h2>
|
||||
<button
|
||||
v-for="item in templates"
|
||||
:key="item.id"
|
||||
class="template-item"
|
||||
:class="{ active: item.id === selectedId }"
|
||||
@click="selectPrompt(item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong
|
||||
><span>{{ item.code }}</span
|
||||
><em>{{ item.enabled ? '已启用' : '已停用' }}</em>
|
||||
</button>
|
||||
<el-empty v-if="!loading && !templates.length" description="暂无模板" />
|
||||
</section>
|
||||
<section v-if="form" class="editor-card">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<h2>编辑:{{ form.name }}</h2>
|
||||
<p>版本 {{ form.version }} · 分类 {{ form.category }}</p>
|
||||
</div>
|
||||
<el-tag :type="form.enabled ? 'success' : 'info'">{{ form.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<el-form-item label="模板代码"><el-input v-model="form.code" :disabled="form.id > 0" /></el-form-item>
|
||||
<el-form-item label="模板名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="输出文体"><el-input v-model="form.outputFormat" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="系统提示词"><el-input v-model="form.systemPrompt" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="提示词模板"><el-input v-model="form.template" type="textarea" :rows="5" /></el-form-item>
|
||||
<el-form-item label="模板变量"><el-input v-model="form.variables" placeholder='["scenario","dialogue","score"]' /></el-form-item>
|
||||
<div class="check-card"><strong>变量校验</strong><span>保存前校验变量格式;未知变量不会发布到生产调用链。</span></div>
|
||||
<div class="editor-actions">
|
||||
<el-button @click="save(false)">保存草稿</el-button><el-button type="danger" :loading="saving" @click="save(true)">保存并启用</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
createPromptTemplate,
|
||||
listPromptTemplates,
|
||||
updatePromptTemplate,
|
||||
type PromptTemplateRequest,
|
||||
type PromptTemplateResponse
|
||||
} from '@/api/aihr/practice';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const templates = ref<PromptTemplateResponse[]>([]);
|
||||
const selectedId = ref(0);
|
||||
const form = ref<PromptTemplateRequest & { id: number }>();
|
||||
|
||||
const clone = (item: PromptTemplateResponse): PromptTemplateRequest & { id: number } => ({ ...item });
|
||||
const selectPrompt = (item: PromptTemplateResponse) => {
|
||||
selectedId.value = item.id;
|
||||
form.value = reactive(clone(item));
|
||||
};
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await listPromptTemplates();
|
||||
templates.value = result.data ?? [];
|
||||
if (!selectedId.value && templates.value[0]) selectPrompt(templates.value[0]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const createPrompt = () => {
|
||||
selectedId.value = 0;
|
||||
form.value = reactive({
|
||||
id: 0,
|
||||
code: '',
|
||||
name: '新提示词模板',
|
||||
category: 'practice',
|
||||
outputFormat: 'summary_card',
|
||||
systemPrompt: '',
|
||||
template: '',
|
||||
variables: '',
|
||||
version: 'v1',
|
||||
enabled: false,
|
||||
description: ''
|
||||
});
|
||||
};
|
||||
const save = async (enabled: boolean) => {
|
||||
if (!form.value?.code.trim() || !form.value.name.trim() || !form.value.template.trim()) {
|
||||
ElMessage.warning('请填写模板代码、名称和模板内容');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form.value, enabled };
|
||||
const result = form.value.id ? await updatePromptTemplate(form.value.id, payload) : await createPromptTemplate(payload);
|
||||
ElMessage.success(enabled ? '模板已保存并启用' : '模板草稿已保存');
|
||||
await load();
|
||||
if (result.data) selectPrompt(result.data);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 28px 32px 40px;
|
||||
background: #f4f6fa;
|
||||
color: #1d2635;
|
||||
}
|
||||
.page-heading,
|
||||
.editor-heading,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.page-heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.page-heading p,
|
||||
.editor-heading p {
|
||||
margin-top: 8px;
|
||||
color: #858e9e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.prompt-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 410px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.list-card,
|
||||
.editor-card {
|
||||
min-height: 520px;
|
||||
padding: 18px 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgb(33 43 54 / 4%);
|
||||
}
|
||||
.list-card h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.template-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 9px;
|
||||
padding: 15px 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
color: #253044;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.template-item.active {
|
||||
background: #fff0f1;
|
||||
color: #e60012;
|
||||
}
|
||||
.template-item strong,
|
||||
.template-item span {
|
||||
display: block;
|
||||
}
|
||||
.template-item span {
|
||||
margin-top: 7px;
|
||||
color: #858e9e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.template-item em {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 14px;
|
||||
color: #16a05d;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
.editor-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.editor-heading {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.check-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 10px 0 14px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
.check-card span {
|
||||
color: #858e9e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
.editor-actions .el-button:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.prompt-layout,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -344,6 +344,18 @@ type ScenarioSeed = {
|
||||
id: string;
|
||||
name: string;
|
||||
position?: string;
|
||||
scenarioType?: string;
|
||||
projectType?: string;
|
||||
difficulty?: number;
|
||||
customerPersona?: string;
|
||||
openingText?: string;
|
||||
explicitNeed?: string;
|
||||
hiddenNeed?: string;
|
||||
irritants?: string;
|
||||
delights?: string;
|
||||
sopRefs?: string;
|
||||
successCriteria?: string;
|
||||
enabled?: boolean;
|
||||
trainee: string;
|
||||
customer: string;
|
||||
project: string;
|
||||
@@ -489,7 +501,6 @@ const defaultPromptForm = (): PromptTemplateForm => ({
|
||||
description: ''
|
||||
});
|
||||
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
|
||||
|
||||
const selectedScenario = computed(
|
||||
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]
|
||||
);
|
||||
@@ -563,6 +574,18 @@ const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
scenarioType: item.scenarioType,
|
||||
projectType: item.projectType,
|
||||
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,
|
||||
enabled: item.enabled,
|
||||
trainee: '张三',
|
||||
customer: item.customerPersona,
|
||||
project: item.projectType || item.scenarioType,
|
||||
|
||||
@@ -6,10 +6,37 @@
|
||||
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button type="danger" :loading="exporting" @click="exportPilot">导出试点 CSV</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel panel">
|
||||
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
|
||||
<el-date-picker v-model="endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
|
||||
<el-select v-model="selectedPosition" placeholder="岗位">
|
||||
<el-option label="生活顾问" value="生活顾问" />
|
||||
</el-select>
|
||||
<el-select v-model="selectedProjectCode" clearable filterable placeholder="组织范围">
|
||||
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
||||
</el-select>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>校准样本</span><strong>{{ metrics.calibrationCount }} / 20</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>分档一致率</span><strong>{{ metrics.consistency }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>SOP 可用率</span><strong>{{ metrics.sopUsable }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>严格门禁</span><strong :class="metrics.ready ? 'is-ready' : 'is-pending'">{{ metrics.ready ? '通过' : '待补样本' }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="review-layout">
|
||||
<article class="panel list-panel">
|
||||
<div class="panel-title">
|
||||
@@ -17,9 +44,6 @@
|
||||
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="list-filters">
|
||||
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
|
||||
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
||||
</el-select>
|
||||
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
|
||||
<el-option
|
||||
v-for="person in orgPeople"
|
||||
@@ -122,7 +146,7 @@
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { calibratePracticeSession, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import {
|
||||
getPracticeAudioBlobUrl,
|
||||
getOrgSnapshot,
|
||||
@@ -142,6 +166,11 @@ const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||||
const selectedProjectCode = ref('');
|
||||
const selectedExtPartyId = ref('');
|
||||
const selectedPosition = ref('生活顾问');
|
||||
const exporting = ref(false);
|
||||
const startDate = ref('2026-07-01');
|
||||
const endDate = ref('2026-07-31');
|
||||
const metrics = reactive({ calibrationCount: 0, consistency: '--', sopUsable: '--', ready: false });
|
||||
const audioSources = ref<Record<string, string>>({});
|
||||
const audioLoading = ref<Record<string, boolean>>({});
|
||||
const audioErrors = ref<Record<string, string>>({});
|
||||
@@ -228,6 +257,41 @@ const loadReviews = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value });
|
||||
const csv = await blob.text();
|
||||
const value = (key: string) => csv.match(new RegExp(`(?:^|\\n)"?${key}"?,"?([^"\\n]*)`))?.[1] || '';
|
||||
metrics.calibrationCount = Number(value('calibration_sample_count')) || 0;
|
||||
metrics.consistency = value('ai_human_band_consistency_rate') || '--';
|
||||
metrics.sopUsable = value('sop_answer_usable_rate') || '--';
|
||||
metrics.ready = value('pilot_metric_ready') === 'true';
|
||||
} catch {
|
||||
metrics.calibrationCount = reviews.value.length;
|
||||
metrics.consistency = '--';
|
||||
metrics.sopUsable = '--';
|
||||
metrics.ready = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportPilot = async () => {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `aihr-pilot-${startDate.value}-${endDate.value}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
ElMessage.success('试点 CSV 已导出');
|
||||
} catch {
|
||||
ElMessage.error('试点 CSV 导出失败');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectReview = async (row: ReviewRecord) => {
|
||||
releaseAudioSources();
|
||||
const result = await getPracticeReview(row.id);
|
||||
@@ -290,10 +354,15 @@ watch(selectedExtPartyId, () => {
|
||||
loadReviews();
|
||||
});
|
||||
|
||||
watch([startDate, endDate], () => {
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadScenarios();
|
||||
loadOrgSnapshot();
|
||||
loadReviews();
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onBeforeUnmount(releaseAudioSources);
|
||||
@@ -332,6 +401,51 @@ onBeforeUnmount(releaseAudioSources);
|
||||
}
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #16864b;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.is-pending {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
|
||||
@@ -445,8 +559,15 @@ onBeforeUnmount(releaseAudioSources);
|
||||
.review-layout,
|
||||
.list-filters,
|
||||
.score-grid,
|
||||
.form-grid {
|
||||
.form-grid,
|
||||
.filter-panel,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="rubric-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>Rubric 评分规则</h1>
|
||||
<p>维护评分维度、权重、版本和生效状态</p>
|
||||
</div>
|
||||
<el-button type="danger" :icon="Plus" @click="createVersion">新建版本</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rubric-layout">
|
||||
<section class="version-card">
|
||||
<h2>版本列表</h2>
|
||||
<button
|
||||
v-for="item in rubrics"
|
||||
:key="item.code"
|
||||
class="version-item"
|
||||
:class="{ active: item.code === selectedCode }"
|
||||
@click="selectRubric(item)"
|
||||
>
|
||||
<strong>{{ item.name }} {{ item.version }}</strong>
|
||||
<span>{{ item.enabled ? '当前生效' : '历史版本' }} · {{ item.dimensions.length }} 维度</span>
|
||||
</button>
|
||||
<el-empty v-if="!loading && rubrics.length === 0" description="暂无评分规则" />
|
||||
</section>
|
||||
|
||||
<section v-if="form" class="editor-card">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<h2>{{ form.name }} {{ form.version }}</h2>
|
||||
<p>{{ selectedRubric?.scenarioName || form.scenarioCode }} · {{ form.description || '维护评分维度与权重' }}</p>
|
||||
</div>
|
||||
<el-tag :type="form.enabled ? 'success' : 'info'">{{ form.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</div>
|
||||
|
||||
<h3>评分维度与权重</h3>
|
||||
<div v-for="dimension in form.dimensions" :key="dimension.code" class="dimension-row">
|
||||
<div class="dimension-label">
|
||||
<strong>{{ dimension.name }}</strong>
|
||||
<span>{{ dimension.description || '请补充该维度的评分说明' }}</span>
|
||||
</div>
|
||||
<div class="dimension-control">
|
||||
<span>{{ Math.round(dimension.weight * 100) }}% 权重</span>
|
||||
<el-slider v-model="dimension.weight" :min="0" :max="1" :step="0.05" @change="normalizeWeights" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="band-card">
|
||||
<strong>评分档位</strong>
|
||||
<span>90–100 优秀 · 75–89 良好 · 60–74 待提升 · <60 不合格</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<el-button @click="copyVersion">复制版本</el-button>
|
||||
<el-button @click="selectedRubric && toggleEnabled(selectedRubric)">{{ form.enabled ? '停用版本' : '启用版本' }}</el-button>
|
||||
<el-button type="danger" :loading="saving" @click="saveVersion">保存并发布</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-else-if="!loading" description="请选择一个评分规则" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
listPracticeRubrics,
|
||||
savePracticeRubric,
|
||||
updatePracticeRubricEnabled,
|
||||
type PracticeRubricAdmin,
|
||||
type PracticeRubricSaveRequest
|
||||
} from '@/api/aihr/practice';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const rubrics = ref<PracticeRubricAdmin[]>([]);
|
||||
const selectedCode = ref('');
|
||||
const form = ref<PracticeRubricSaveRequest>();
|
||||
|
||||
const selectedRubric = computed(() => rubrics.value.find((item) => item.code === selectedCode.value));
|
||||
|
||||
const cloneForm = (item: PracticeRubricAdmin): PracticeRubricSaveRequest => ({
|
||||
code: item.code,
|
||||
scenarioCode: item.scenarioCode,
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
dimensions: item.dimensions.map((dimension) => ({ ...dimension }))
|
||||
});
|
||||
|
||||
const loadRubrics = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await listPracticeRubrics();
|
||||
rubrics.value = result.data ?? [];
|
||||
if (!selectedCode.value && rubrics.value.length > 0) {
|
||||
selectRubric(rubrics.value[0]);
|
||||
} else if (selectedCode.value) {
|
||||
const current = rubrics.value.find((item) => item.code === selectedCode.value);
|
||||
if (current) selectRubric(current);
|
||||
}
|
||||
} catch {
|
||||
rubrics.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectRubric = (item: PracticeRubricAdmin) => {
|
||||
selectedCode.value = item.code;
|
||||
form.value = reactive(cloneForm(item));
|
||||
};
|
||||
|
||||
const normalizeWeights = () => {
|
||||
if (!form.value) return;
|
||||
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
|
||||
if (total > 0 && Math.abs(total - 1) > 0.001) {
|
||||
form.value.dimensions.forEach((item) => {
|
||||
item.weight = Number((item.weight / total).toFixed(2));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createVersion = () => {
|
||||
const source = rubrics.value[0];
|
||||
if (!source) return;
|
||||
const suffix = Date.now().toString().slice(-4);
|
||||
form.value = reactive({
|
||||
...cloneForm(source),
|
||||
code: `${source.code}-${suffix}`,
|
||||
version: `v${Number.parseInt(source.version.replace(/\D/g, ''), 10) + 1}`,
|
||||
enabled: false
|
||||
});
|
||||
selectedCode.value = '';
|
||||
};
|
||||
|
||||
const copyVersion = () => createVersion();
|
||||
|
||||
const saveVersion = async () => {
|
||||
if (!form.value) return;
|
||||
normalizeWeights();
|
||||
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
|
||||
if (Math.abs(total - 1) > 0.011) {
|
||||
ElMessage.warning('各维度权重合计必须为 100%');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await savePracticeRubric(form.value);
|
||||
ElMessage.success('评分规则已保存并发布');
|
||||
await loadRubrics();
|
||||
if (result.data) selectRubric(result.data);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (item: PracticeRubricAdmin) => {
|
||||
await ElMessageBox.confirm(`确定${item.enabled ? '停用' : '启用'}“${item.name} ${item.version}”吗?`, '状态确认');
|
||||
await updatePracticeRubricEnabled(item.code, !item.enabled);
|
||||
item.enabled = !item.enabled;
|
||||
if (item.code === selectedCode.value && form.value) form.value.enabled = item.enabled;
|
||||
ElMessage.success(item.enabled ? '评分规则已启用' : '评分规则已停用');
|
||||
};
|
||||
|
||||
onMounted(loadRubrics);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rubric-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 28px 32px 40px;
|
||||
background: #f4f6fa;
|
||||
color: #1d2635;
|
||||
}
|
||||
.page-heading,
|
||||
.editor-heading,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.page-heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h3 {
|
||||
margin: 24px 0 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.page-heading p,
|
||||
.editor-heading p {
|
||||
margin-top: 8px;
|
||||
color: #858e9e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rubric-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.version-card,
|
||||
.editor-card {
|
||||
min-height: 520px;
|
||||
padding: 18px 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgb(33 43 54 / 4%);
|
||||
}
|
||||
.version-card h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.version-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 9px;
|
||||
padding: 16px 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
color: #253044;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.version-item.active {
|
||||
background: #fff0f1;
|
||||
color: #e60012;
|
||||
}
|
||||
.version-item strong,
|
||||
.version-item span {
|
||||
display: block;
|
||||
}
|
||||
.version-item span {
|
||||
margin-top: 7px;
|
||||
color: #858e9e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.editor-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.dimension-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.dimension-label strong,
|
||||
.dimension-label span,
|
||||
.dimension-control > span {
|
||||
display: block;
|
||||
}
|
||||
.dimension-label span {
|
||||
margin-top: 7px;
|
||||
color: #858e9e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dimension-control > span {
|
||||
margin-bottom: 8px;
|
||||
color: #e60012;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.band-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f7f9fb;
|
||||
color: #758196;
|
||||
font-size: 13px;
|
||||
}
|
||||
.band-card strong {
|
||||
color: #253044;
|
||||
font-size: 15px;
|
||||
}
|
||||
.editor-actions {
|
||||
margin-top: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.editor-actions .el-button:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.rubric-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dimension-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="scenario-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>场景库管理</h1>
|
||||
<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-select>
|
||||
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
|
||||
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.enabled" placeholder="状态" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="停用" :value="false" />
|
||||
</el-select>
|
||||
<el-input v-model="filters.keyword" placeholder="场景名称 / 编码" clearable @keyup.enter="queryScenes" />
|
||||
<el-button type="danger" @click="queryScenes">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
|
||||
<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">
|
||||
<template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Rubric" min-width="180">
|
||||
<template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="88">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
|
||||
{{ row.enabled === false ? '停用' : '启用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateTime" label="更新时间" width="150" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="toggleEnabled(row)">
|
||||
{{ row.enabled === false ? '启用' : '停用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ filteredScenarios.length }} 个生活顾问场景 · 当前启用 {{ enabledCount }} 个</span>
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
layout="prev, pager, next"
|
||||
:total="filteredScenarios.length"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close>
|
||||
<el-form label-position="top" class="scenario-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景编码">
|
||||
<el-input v-model="form.id" :disabled="Boolean(editingId)" placeholder="自动生成或填写唯一编码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="岗位"><el-input v-model="form.position" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item>
|
||||
<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>
|
||||
<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-switch v-model="form.enabled" active-text="启用场景" inactive-text="停用场景" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="saving" @click="submit">保存场景</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
listPracticeScenarios,
|
||||
savePracticeScenario,
|
||||
updatePracticeScenarioEnabled,
|
||||
type PracticeScenarioResponse,
|
||||
type PracticeScenarioSaveRequest
|
||||
} from '@/api/aihr/practice';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const editingId = ref('');
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const scenarioTypes = ref<string[]>([]);
|
||||
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
|
||||
position: '生活顾问',
|
||||
scenarioType: '',
|
||||
enabled: undefined,
|
||||
keyword: ''
|
||||
});
|
||||
const pagination = reactive({ page: 1, size: 10 });
|
||||
const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
|
||||
id: '',
|
||||
name: '',
|
||||
position: '生活顾问',
|
||||
scenarioType: '投诉处理',
|
||||
projectType: '住宅',
|
||||
difficulty: 1,
|
||||
customerPersona: '',
|
||||
openingText: '',
|
||||
explicitNeed: '',
|
||||
hiddenNeed: '',
|
||||
irritants: '',
|
||||
delights: '',
|
||||
sopRefs: '生活顾问SOP',
|
||||
successCriteria: '',
|
||||
strategy: '仅在漏关键步骤时提示',
|
||||
goal: '',
|
||||
enabled: true
|
||||
});
|
||||
const form = reactive(emptyForm());
|
||||
const dialogTitle = computed(() => (editingId.value ? '编辑场景' : '新增场景'));
|
||||
const filteredScenarios = computed(() =>
|
||||
scenarios.value.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return (
|
||||
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
|
||||
(filters.enabled === undefined || item.enabled === filters.enabled) &&
|
||||
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
|
||||
);
|
||||
})
|
||||
);
|
||||
const pagedScenarios = computed(() => {
|
||||
const start = (pagination.page - 1) * pagination.size;
|
||||
return filteredScenarios.value.slice(start, start + pagination.size);
|
||||
});
|
||||
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listPracticeScenarios();
|
||||
scenarios.value = data || [];
|
||||
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const queryScenes = () => {
|
||||
pagination.page = 1;
|
||||
};
|
||||
|
||||
const difficultyLabel = (value: number) => ({ 1: '初级', 2: '中级', 3: '高级', 4: '困难', 5: '挑战' })[value] || '中级';
|
||||
|
||||
const fillForm = (item?: PracticeScenarioResponse) => {
|
||||
Object.assign(
|
||||
form,
|
||||
emptyForm(),
|
||||
item
|
||||
? {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
scenarioType: item.scenarioType,
|
||||
projectType: item.projectType,
|
||||
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
|
||||
}
|
||||
: {}
|
||||
);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingId.value = '';
|
||||
fillForm();
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (item: PracticeScenarioResponse) => {
|
||||
editingId.value = item.id;
|
||||
fillForm(item);
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleEnabled = async (item: PracticeScenarioResponse) => {
|
||||
const enabled = item.enabled === false;
|
||||
await updatePracticeScenarioEnabled(item.id, { enabled });
|
||||
item.enabled = enabled;
|
||||
ElMessage.success(enabled ? '场景已启用' : '场景已停用');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.openingText.trim() || !form.goal.trim()) {
|
||||
ElMessage.warning('请填写场景名称、开场话术和训练目标');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
ElMessage.success('场景已保存');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scenario-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 24px 28px 40px;
|
||||
background: #f4f6fa;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #858991;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.table-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
:deep(.el-table) {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
color: #858991;
|
||||
font-weight: 500;
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
|
||||
&.enabled {
|
||||
color: #2a9b5f;
|
||||
}
|
||||
&.disabled {
|
||||
color: #858991;
|
||||
}
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
color: #858991;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scenario-form {
|
||||
padding: 4px 8px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
:deep(.el-textarea__inner) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.filter-card .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user