feat(aihr): finalize practice growth and access boundaries
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import request, { globalHeaders, reconcileTenantContextBlobResponse } from '@/utils/request';
|
||||
import request from '@/utils/request';
|
||||
|
||||
type ApiResult<T> = {
|
||||
code: number;
|
||||
@@ -7,123 +6,6 @@ type ApiResult<T> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type ReviewRecord = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
time: string;
|
||||
trainee: string;
|
||||
scene: string;
|
||||
score: number;
|
||||
status: string;
|
||||
summary: string;
|
||||
extPartyId: string;
|
||||
};
|
||||
|
||||
export type ReviewDetail = ReviewRecord & {
|
||||
mentorRewrite: string;
|
||||
aiComment: string;
|
||||
reviewAdvice: string;
|
||||
incentivePoint: number | null;
|
||||
scoreItems: Array<{ label: string; value: number | string; note: string }>;
|
||||
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string; audioOssId?: number | string }>;
|
||||
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
|
||||
};
|
||||
|
||||
export type ReviewAdviceRequest = {
|
||||
reviewer?: string;
|
||||
advice?: string;
|
||||
incentivePoint?: number;
|
||||
nextScenarioId?: string;
|
||||
nextReason?: string;
|
||||
};
|
||||
|
||||
export type CompetencyProfile = {
|
||||
score: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
trainingMinutes: number;
|
||||
contribution: number;
|
||||
assessmentScore: number;
|
||||
aiLevel: string;
|
||||
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
|
||||
growthPath: Array<{ level: string; progress: number; status: string; evidence: string }>;
|
||||
};
|
||||
|
||||
export type PracticeAlert = {
|
||||
type: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
severity: string;
|
||||
extPartyId: string;
|
||||
};
|
||||
|
||||
export type PracticeAssignment = {
|
||||
id: number;
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createTime: string;
|
||||
question?: string;
|
||||
referenceAnswer?: string;
|
||||
userAnswer?: string;
|
||||
feedback?: string;
|
||||
correct?: boolean;
|
||||
};
|
||||
|
||||
export type PracticeAssignmentRequest = {
|
||||
extPartyId: string;
|
||||
traineeName?: string;
|
||||
scenarioId: string;
|
||||
scenarioName?: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PracticeDifficulty = {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
cause: string;
|
||||
count: number;
|
||||
affectedPeople: number;
|
||||
averageScore: number;
|
||||
latestTime: string;
|
||||
position?: string;
|
||||
assignmentStatus?: string;
|
||||
attempts?: number;
|
||||
mistakeCount?: number;
|
||||
difficultyScore?: number;
|
||||
};
|
||||
|
||||
export type PracticeAssignmentBatchResponse = {
|
||||
requestId: string;
|
||||
total: number;
|
||||
created: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
results: Array<{ extPartyId: string; traineeName: string; status: 'CREATED' | 'DUPLICATE' | 'FAILED'; message: string }>;
|
||||
};
|
||||
|
||||
export type PromotionEvidence = {
|
||||
extPartyId: string;
|
||||
currentLevel: string;
|
||||
assessmentScore: number;
|
||||
trainingMinutes: number;
|
||||
contribution: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
|
||||
latestRecords: ReviewRecord[];
|
||||
assignments: PracticeAssignment[];
|
||||
evidenceItems: string[];
|
||||
humanAssessmentScore?: number | null;
|
||||
finalAdoptedScore?: number | null;
|
||||
calibrationCount?: number;
|
||||
};
|
||||
|
||||
export type OrgSyncRequest = {
|
||||
dryRun?: boolean;
|
||||
replaceExisting?: boolean;
|
||||
@@ -188,127 +70,6 @@ export type OrgSnapshotResponse = {
|
||||
rows: OrgPersonRow[];
|
||||
};
|
||||
|
||||
export type ReviewListParams = {
|
||||
projectCode?: string;
|
||||
extPartyId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export function listPracticeReviews(params?: ReviewListParams): Promise<ApiResult<ReviewRecord[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/reviews',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
|
||||
return request({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'get',
|
||||
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
|
||||
responseType: 'blob',
|
||||
headers: globalHeaders()
|
||||
});
|
||||
const contentType = String(response.headers['content-type'] || '');
|
||||
if (contentType.includes('application/json') && (await reconcileTenantContextBlobResponse(response.data))) {
|
||||
throw new Error('租户上下文已变更,请刷新后重试');
|
||||
}
|
||||
return URL.createObjectURL(response.data);
|
||||
} catch (error) {
|
||||
await reconcileTenantContextBlobResponse(axios.isAxiosError(error) ? error.response?.data : undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function getCompetencyProfile(extPartyId?: string): Promise<ApiResult<CompetencyProfile>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/profile',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function getPromotionEvidence(extPartyId?: string): Promise<ApiResult<PromotionEvidence>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/profile/promotion-evidence',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeAlerts(): Promise<ApiResult<PracticeAlert[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/alerts',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeAssignments(extPartyId?: string): Promise<ApiResult<PracticeAssignment[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'get',
|
||||
params: { extPartyId }
|
||||
});
|
||||
}
|
||||
|
||||
export function createPracticeAssignment(data: PracticeAssignmentRequest): Promise<ApiResult<PracticeAssignment>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeDifficulties(params?: { windowDays?: number; position?: string }): Promise<ApiResult<PracticeDifficulty[]>> {
|
||||
type RawDifficulty = Omit<PracticeDifficulty, 'cause' | 'count'> & { attempts: number; mistakeCount: number };
|
||||
return request<unknown, ApiResult<RawDifficulty[]>>({
|
||||
url: '/api/aihr/mobile/practice/difficulties',
|
||||
method: 'get',
|
||||
params
|
||||
}).then((result) => ({
|
||||
...result,
|
||||
data: (result.data || []).map((item) => ({
|
||||
...item,
|
||||
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
|
||||
count: item.mistakeCount
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function assignPracticeDifficulty(data: {
|
||||
scenarioId: string;
|
||||
cause: string;
|
||||
dueDate: string;
|
||||
requestId: string;
|
||||
windowDays: number;
|
||||
position?: string;
|
||||
}): Promise<ApiResult<PracticeAssignmentBatchResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/mobile/practice/difficulties/assign',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/org/sync',
|
||||
|
||||
@@ -18,6 +18,17 @@ export type PracticeScenarioResponse = {
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
growthLevel: string;
|
||||
competencyCode: string;
|
||||
collaborationPositions: string;
|
||||
reviewStatus: string;
|
||||
riskLevel: '待评估' | '常规' | '高风险';
|
||||
requiresDualReview: boolean;
|
||||
curriculumVersion: string;
|
||||
reviewer?: string;
|
||||
reviewedTime?: string;
|
||||
secondReviewer?: string;
|
||||
secondReviewedTime?: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
@@ -58,6 +69,11 @@ export type PracticeScenarioSaveRequest = {
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
growthLevel: string;
|
||||
competencyCode: string;
|
||||
collaborationPositions: string;
|
||||
riskLevel: '待评估' | '常规' | '高风险';
|
||||
curriculumVersion: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
@@ -73,6 +89,35 @@ export type PracticeScenarioSaveRequest = {
|
||||
rounds: PracticeScenarioRound[];
|
||||
};
|
||||
|
||||
export type PracticeScenarioReviewStatusRequest = {
|
||||
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
|
||||
};
|
||||
|
||||
export type PracticeScenarioReviewStatusResponse = {
|
||||
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
|
||||
awaitingSecondReview: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PracticeCurriculumCapability = {
|
||||
position: string;
|
||||
growthLevel: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PracticeCurriculumMatrixRow = {
|
||||
position: string;
|
||||
growthLevel: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
publishedCount: number;
|
||||
pendingReviewCount: number;
|
||||
draftCount: number;
|
||||
offlineCount: number;
|
||||
coverageStatus: string;
|
||||
};
|
||||
|
||||
export type PracticeRubricDimension = {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -289,6 +334,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePracticeScenarioReviewStatus(
|
||||
id: string,
|
||||
data: PracticeScenarioReviewStatusRequest
|
||||
): Promise<ApiResult<PracticeScenarioReviewStatusResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${encodeURIComponent(id)}/review-status`,
|
||||
method: 'patch',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function listPracticeCurriculumCapabilities(params?: {
|
||||
position?: string;
|
||||
growthLevel?: string;
|
||||
}): Promise<ApiResult<PracticeCurriculumCapability[]>> {
|
||||
return request({ url: '/api/train/practice/curriculum/capabilities', method: 'get', params });
|
||||
}
|
||||
|
||||
export function listPracticeCurriculumMatrix(params?: {
|
||||
position?: string;
|
||||
}): Promise<ApiResult<PracticeCurriculumMatrixRow[]>> {
|
||||
return request({ url: '/api/train/practice/curriculum/matrix', method: 'get', params });
|
||||
}
|
||||
|
||||
export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> {
|
||||
return request({
|
||||
url: '/api/train/practice/scenarios',
|
||||
|
||||
@@ -141,16 +141,17 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
component: () => import('@/views/train/reviews.vue'),
|
||||
name: 'TrainReviews',
|
||||
meta: { title: '复盘校准', icon: 'finish' }
|
||||
redirect: '/train/tasks',
|
||||
hidden: true,
|
||||
meta: { title: '复盘校准', icon: 'finish', activeMenu: '/train/tasks' }
|
||||
},
|
||||
{
|
||||
path: 'difficulties',
|
||||
component: () => import('@/views/train/difficulties.vue'),
|
||||
name: 'TrainDifficulties',
|
||||
redirect: '/train/tasks',
|
||||
hidden: true,
|
||||
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/scenarios' }
|
||||
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/tasks' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -285,19 +286,11 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/competency',
|
||||
component: Layout,
|
||||
path: '/competency/growth',
|
||||
name: 'CompetencyGrowth',
|
||||
redirect: '/train/tasks',
|
||||
hidden: true,
|
||||
redirect: '/competency/growth',
|
||||
meta: { roles: operationsConsoleRoles },
|
||||
children: [
|
||||
{
|
||||
path: 'growth',
|
||||
component: () => import('@/views/competency/growth.vue'),
|
||||
name: 'CompetencyGrowth',
|
||||
meta: { title: '成长激励', icon: 'chart' }
|
||||
}
|
||||
]
|
||||
meta: { title: '成长激励', icon: 'chart', roles: operationsConsoleRoles }
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
<template>
|
||||
<div class="growth-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>成长激励</h1>
|
||||
<p>展示能力画像、初/中/高级证据包、预警、待训任务和待配置的学分口径。</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<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="extPartyId" clearable filterable allow-create default-first-option placeholder="组织人员或 extPartyId">
|
||||
<el-option
|
||||
v-for="person in orgPeople"
|
||||
:key="person.extPartyId"
|
||||
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
|
||||
:value="person.extPartyId"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :icon="Download" :disabled="!evidence" @click="exportEvidenceCsv">导出证据包</el-button>
|
||||
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span>AI训练综合分</span>
|
||||
<strong>{{ profile?.assessmentScore ?? '-' }}</strong>
|
||||
<small>训练参考分,不等于 BRD 五维绩效总分</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>训练画像等级(参考)</span>
|
||||
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
|
||||
<small>需 HR/主管复核,不作为晋升结论</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>训练时长</span>
|
||||
<strong>{{ profile?.trainingMinutes ?? 0 }} 分钟</strong>
|
||||
<small>来自移动端训练记录</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>人工贡献分</span>
|
||||
<strong>{{ profile?.contribution ?? 0 }}</strong>
|
||||
<small>仅计入主管明确确认的有效贡献分,训练次数单独统计</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>能力维度</h2>
|
||||
</div>
|
||||
<div class="dimension-list">
|
||||
<div v-for="item in profile?.dimensions || []" :key="item.label" class="dimension-item">
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.note }}</span>
|
||||
</div>
|
||||
<el-progress
|
||||
v-if="item.value !== null && item.value !== undefined"
|
||||
:percentage="Number(item.value) || 0"
|
||||
:stroke-width="10"
|
||||
color="#e60012"
|
||||
/>
|
||||
<span v-else class="dimension-unavailable">待采集</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>成长参考路径</h2>
|
||||
</div>
|
||||
<div class="stage-list">
|
||||
<div v-for="stage in profile?.growthPath || []" :key="stage.level" class="stage-item">
|
||||
<div class="stage-head">
|
||||
<strong>{{ stage.level }}</strong>
|
||||
<el-tag :type="stage.status === '已达成' ? 'success' : 'info'">{{ stage.status }}</el-tag>
|
||||
</div>
|
||||
<el-progress :percentage="stage.progress" :stroke-width="9" />
|
||||
<small>{{ stage.evidence }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>晋升证据包(待复核)</h2>
|
||||
</div>
|
||||
<ul class="evidence-list">
|
||||
<li v-for="item in evidence?.evidenceItems || []" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<div class="evidence-score-row">
|
||||
<span>人工校准分:{{ evidence?.humanAssessmentScore ?? '待采集' }}</span>
|
||||
<span>最终采用分:{{ evidence?.finalAdoptedScore ?? evidence?.assessmentScore ?? '-' }}</span>
|
||||
<span>校准样本:{{ evidence?.calibrationCount ?? 0 }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>预警</h2>
|
||||
</div>
|
||||
<div class="alert-list">
|
||||
<div v-for="item in alerts" :key="item.title" class="alert-item" :class="`severity-${item.severity}`">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>待训任务</h2>
|
||||
<el-button type="danger" plain @click="createManualAssignment">派发专项</el-button>
|
||||
</div>
|
||||
<div class="assignment-form">
|
||||
<el-select v-model="assignmentScenarioId" filterable placeholder="选择专项场景">
|
||||
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
|
||||
</el-select>
|
||||
<el-input v-model="assignmentReason" placeholder="派发原因" />
|
||||
</div>
|
||||
<el-table :data="assignments" size="small">
|
||||
<el-table-column prop="traineeName" label="学员" width="110" />
|
||||
<el-table-column prop="scenarioName" label="训练" min-width="160" />
|
||||
<el-table-column prop="source" label="来源" width="92" />
|
||||
<el-table-column prop="status" label="状态" width="92" />
|
||||
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>学分规则(待 HR 配置)</h2>
|
||||
<el-tag type="info" effect="plain">规划参考,当前不生效</el-tag>
|
||||
</div>
|
||||
<el-table :data="rules" size="small">
|
||||
<el-table-column prop="source" label="来源" width="130" />
|
||||
<el-table-column prop="points" label="分值" width="90" />
|
||||
<el-table-column prop="guard" label="防刷分口径" min-width="220" />
|
||||
</el-table>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="CompetencyGrowth" lang="ts">
|
||||
import { Download, Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import FileSaver from 'file-saver';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import {
|
||||
createPracticeAssignment,
|
||||
getCompetencyProfile,
|
||||
getOrgSnapshot,
|
||||
getPromotionEvidence,
|
||||
listPracticeAlerts,
|
||||
listPracticeAssignments,
|
||||
type CompetencyProfile,
|
||||
type OrgSnapshotResponse,
|
||||
type PracticeAlert,
|
||||
type PracticeAssignment,
|
||||
type PromotionEvidence
|
||||
} from '@/api/aihr/mobile-admin';
|
||||
|
||||
const extPartyId = ref('');
|
||||
const selectedProjectCode = ref('');
|
||||
const loading = ref(false);
|
||||
const profile = ref<CompetencyProfile | null>(null);
|
||||
const evidence = ref<PromotionEvidence | null>(null);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const assignmentScenarioId = ref('');
|
||||
const assignmentReason = ref('HR在成长激励页派发专项训练');
|
||||
|
||||
const rules = [
|
||||
{ source: '训练完成', points: '待配置', guard: '只认完成记录,不按点击或打开计分' },
|
||||
{ source: '主管复盘采纳', points: '待配置', guard: '由主管复盘时人工确认' },
|
||||
{ source: '案例被采纳', points: '待配置', guard: '必须审核入库并署名' },
|
||||
{ source: '课程考试通过', points: '待配置', guard: '阶段二课程考试上线后启用' }
|
||||
];
|
||||
|
||||
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
|
||||
const selectedPerson = computed(() => orgPeople.value.find((item) => item.extPartyId === extPartyId.value));
|
||||
|
||||
const loadOrgSnapshot = async () => {
|
||||
try {
|
||||
const result = await getOrgSnapshot({
|
||||
status: 'active',
|
||||
projectCode: selectedProjectCode.value,
|
||||
pageNum: 1,
|
||||
pageSize: 500
|
||||
});
|
||||
orgSnapshot.value = result.data;
|
||||
} catch (error) {
|
||||
orgSnapshot.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
loading.value = true;
|
||||
const party = extPartyId.value.trim();
|
||||
try {
|
||||
const [profileResult, evidenceResult, alertsResult, assignmentsResult] = await Promise.all([
|
||||
getCompetencyProfile(party),
|
||||
getPromotionEvidence(party),
|
||||
listPracticeAlerts(),
|
||||
listPracticeAssignments(party)
|
||||
]);
|
||||
profile.value = profileResult.data;
|
||||
evidence.value = evidenceResult.data;
|
||||
alerts.value = alertsResult.data || [];
|
||||
assignments.value = assignmentsResult.data || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('成长数据加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScenarios = async () => {
|
||||
try {
|
||||
scenarios.value = ((await listPracticeScenarios()).data || []).filter((item) => item.enabled);
|
||||
assignmentScenarioId.value = assignmentScenarioId.value || scenarios.value[0]?.id || '';
|
||||
} catch (error) {
|
||||
scenarios.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const createManualAssignment = async () => {
|
||||
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
|
||||
if (!party) {
|
||||
ElMessage.warning('请先输入员工手机号或 extPartyId');
|
||||
return;
|
||||
}
|
||||
const scenario = scenarios.value.find((item) => item.id === assignmentScenarioId.value);
|
||||
if (!scenario) {
|
||||
ElMessage.warning('请先选择专项场景');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createPracticeAssignment({
|
||||
extPartyId: party,
|
||||
traineeName: selectedPerson.value?.personName || party,
|
||||
scenarioId: scenario.id,
|
||||
scenarioName: scenario.name,
|
||||
source: 'manual',
|
||||
reason: assignmentReason.value.trim() || 'HR在成长激励页派发专项训练'
|
||||
});
|
||||
ElMessage.success('专项训练已派发');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error('专项训练派发失败');
|
||||
}
|
||||
};
|
||||
|
||||
const csvCell = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
|
||||
const appendCsvRow = (rows: string[], ...values: unknown[]) => {
|
||||
rows.push(values.map(csvCell).join(','));
|
||||
};
|
||||
|
||||
const exportEvidenceCsv = () => {
|
||||
const party = extPartyId.value.trim() || evidence.value?.extPartyId?.trim() || 'all';
|
||||
if (!evidence.value) {
|
||||
ElMessage.warning('证据包数据加载后才能导出');
|
||||
return;
|
||||
}
|
||||
const rows: string[] = [];
|
||||
const personName = selectedPerson.value?.personName || (party === 'all' ? '全部员工' : party);
|
||||
appendCsvRow(rows, 'section', 'field', 'value');
|
||||
appendCsvRow(rows, 'summary', 'employee', personName);
|
||||
appendCsvRow(rows, 'summary', 'ext_party_id', party);
|
||||
appendCsvRow(rows, 'summary', 'current_level', evidence.value.currentLevel);
|
||||
appendCsvRow(rows, 'summary', 'assessment_score', evidence.value.assessmentScore);
|
||||
appendCsvRow(rows, 'summary', 'human_assessment_score', evidence.value.humanAssessmentScore ?? '待采集');
|
||||
appendCsvRow(rows, 'summary', 'final_adopted_score', evidence.value.finalAdoptedScore ?? evidence.value.assessmentScore);
|
||||
appendCsvRow(rows, 'summary', 'calibration_count', evidence.value.calibrationCount ?? 0);
|
||||
appendCsvRow(rows, 'summary', 'training_minutes', evidence.value.trainingMinutes);
|
||||
appendCsvRow(rows, 'summary', 'contribution', evidence.value.contribution);
|
||||
appendCsvRow(rows, 'summary', 'completed', evidence.value.completed);
|
||||
appendCsvRow(rows, 'summary', 'pending_review', evidence.value.pendingReview);
|
||||
profile.value?.growthPath.forEach((stage) =>
|
||||
appendCsvRow(rows, 'growth_path', `${stage.level} ${stage.status}`, `${stage.progress}% ${stage.evidence}`)
|
||||
);
|
||||
evidence.value.dimensions.forEach((item) => {
|
||||
const value = item.value === null || item.value === undefined ? '待采集' : item.value;
|
||||
appendCsvRow(rows, 'dimension', item.label, `${value} ${item.note}`);
|
||||
});
|
||||
evidence.value.evidenceItems.forEach((item, index) => appendCsvRow(rows, 'evidence_item', index + 1, item));
|
||||
evidence.value.latestRecords.forEach((item) =>
|
||||
appendCsvRow(rows, 'latest_record', item.scene, `${item.score} ${item.status} ${item.time} ${item.summary}`)
|
||||
);
|
||||
evidence.value.assignments.forEach((item) =>
|
||||
appendCsvRow(rows, 'assignment', item.scenarioName, `${item.status} ${item.createTime} ${item.reason}`)
|
||||
);
|
||||
FileSaver.saveAs(new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), `aihr-promotion-evidence-${party}.csv`);
|
||||
ElMessage.success('成长证据包 CSV 已导出');
|
||||
};
|
||||
|
||||
watch(selectedProjectCode, async () => {
|
||||
extPartyId.value = '';
|
||||
await loadOrgSnapshot();
|
||||
});
|
||||
|
||||
watch(extPartyId, () => {
|
||||
loadAll();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadOrgSnapshot();
|
||||
loadScenarios();
|
||||
loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.growth-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 280px auto auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.stage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.assignment-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.75fr) minmax(220px, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dimension-list,
|
||||
.stage-list,
|
||||
.alert-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dimension-item strong,
|
||||
.stage-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dimension-item span,
|
||||
.stage-item small {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.dimension-item .dimension-unavailable {
|
||||
margin-bottom: 8px;
|
||||
color: #98a2b3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.evidence-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
color: #475467;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-score-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 18px;
|
||||
margin-top: 14px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background: #fff1f3;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.page-head,
|
||||
.main-grid,
|
||||
.assignment-form,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -122,8 +122,8 @@
|
||||
<article class="panel training-panel">
|
||||
<div class="panel-title table-title">
|
||||
<h2>最近训练与复盘</h2>
|
||||
<el-button link type="primary" @click="go('/train/reviews')"
|
||||
>查看全部 <el-icon><ArrowRight /></el-icon
|
||||
<el-button link type="primary" @click="go('/train/tasks')"
|
||||
>任务运营 <el-icon><ArrowRight /></el-icon
|
||||
></el-button>
|
||||
</div>
|
||||
<div class="record-table-wrap" tabindex="0" role="region" aria-label="最近训练与复盘表,可横向滚动">
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div class="difficulty-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>常见难题</h1>
|
||||
<p>按岗位和项目权限聚合低分、红线与错题原因,并一键生成定向专项。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel panel">
|
||||
<el-select v-model="position" clearable placeholder="全部岗位" @change="load">
|
||||
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="windowDays" placeholder="统计窗口" @change="load">
|
||||
<el-option label="近7天" :value="7" />
|
||||
<el-option label="近30天" :value="30" />
|
||||
<el-option label="近90天" :value="90" />
|
||||
</el-select>
|
||||
<el-select v-model="sortKey" placeholder="排序方式">
|
||||
<el-option label="出现次数" value="count" />
|
||||
<el-option label="涉及人数" value="people" />
|
||||
<el-option label="低分优先" value="score" />
|
||||
<el-option label="最近发生" value="latest" />
|
||||
</el-select>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div class="metric-card"><span>难题组合</span><strong>{{ rows.length }}</strong></div>
|
||||
<div class="metric-card"><span>累计出现</span><strong>{{ totalCount }}</strong></div>
|
||||
<div class="metric-card"><span>单项最多涉及</span><strong>{{ totalPeople }}</strong></div>
|
||||
<div class="metric-card"><span>平均得分</span><strong>{{ averageScore }}</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<el-table v-loading="loading" :data="sortedRows" empty-text="当前项目范围内暂无常见难题">
|
||||
<el-table-column prop="scenarioName" label="场景" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="position" label="岗位" width="110" />
|
||||
<el-table-column prop="cause" label="归因" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="count" label="出现次数" width="96" sortable />
|
||||
<el-table-column prop="affectedPeople" label="涉及人数" width="96" sortable />
|
||||
<el-table-column prop="averageScore" label="平均分" width="90" sortable>
|
||||
<template #default="{ row }"><span :class="row.averageScore < 80 ? 'low-score' : ''">{{ row.averageScore }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="latestTime" label="最近发生" width="170" />
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }"><el-button type="danger" link @click="openAssign(row)">一键派专项</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="assignVisible" title="一键生成定向专项" width="520px">
|
||||
<div v-if="activeRow" class="assign-summary">
|
||||
<strong>{{ activeRow.scenarioName }}</strong>
|
||||
<span>{{ activeRow.cause }} · {{ activeRow.affectedPeople }} 人涉及</span>
|
||||
<el-alert type="info" :closable="false" title="人员范围由服务端按当前主管项目和该难题低分记录确定,前端不能越权指定。" />
|
||||
<el-date-picker v-model="dueDate" value-format="YYYY-MM-DD" type="date" :disabled-date="disablePast" placeholder="截止日期" />
|
||||
<div class="quick-dates">
|
||||
<el-button v-for="item in quickDates" :key="item.days" size="small" @click="dueDate = dateAfter(item.days)">{{ item.label }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="assigning" :disabled="!dueDate" @click="submitAssign">确认派发</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="resultVisible" title="派发结果" width="620px">
|
||||
<el-result v-if="batchResult" :icon="batchResult.failed ? 'warning' : 'success'" :title="`成功 ${batchResult.created + batchResult.duplicate} 人,失败 ${batchResult.failed} 人`" />
|
||||
<el-table v-if="batchResult?.results?.length" :data="batchResult.results" max-height="320">
|
||||
<el-table-column prop="traineeName" label="员工" min-width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="message" label="说明" min-width="180" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainDifficulties" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
assignPracticeDifficulty,
|
||||
listPracticeDifficulties,
|
||||
type PracticeAssignmentBatchResponse,
|
||||
type PracticeDifficulty
|
||||
} from '@/api/aihr/mobile-admin';
|
||||
|
||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
|
||||
const rows = ref<PracticeDifficulty[]>([]);
|
||||
const loading = ref(false);
|
||||
const assigning = ref(false);
|
||||
const position = ref('');
|
||||
const windowDays = ref(30);
|
||||
const sortKey = ref<'count' | 'people' | 'score' | 'latest'>('count');
|
||||
const assignVisible = ref(false);
|
||||
const resultVisible = ref(false);
|
||||
const activeRow = ref<PracticeDifficulty | null>(null);
|
||||
const dueDate = ref('');
|
||||
const batchResult = ref<PracticeAssignmentBatchResponse | null>(null);
|
||||
const sortedRows = computed(() => [...rows.value].sort((a, b) => {
|
||||
if (sortKey.value === 'people') return b.affectedPeople - a.affectedPeople;
|
||||
if (sortKey.value === 'score') return a.averageScore - b.averageScore;
|
||||
if (sortKey.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
|
||||
return b.count - a.count;
|
||||
}));
|
||||
const totalCount = computed(() => rows.value.reduce((sum, item) => sum + item.count, 0));
|
||||
const totalPeople = computed(() => rows.value.length ? Math.max(0, ...rows.value.map((item) => item.affectedPeople)) : 0);
|
||||
const averageScore = computed(() => rows.value.length
|
||||
? Math.round(rows.value.reduce((sum, item) => sum + item.averageScore * item.count, 0) / Math.max(1, totalCount.value))
|
||||
: '--');
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12, 0, 0, 0); date.setDate(date.getDate() + days); return formatDate(date); };
|
||||
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await listPracticeDifficulties({ windowDays: windowDays.value, position: position.value || undefined });
|
||||
rows.value = response.data || [];
|
||||
} catch {
|
||||
rows.value = [];
|
||||
ElMessage.error('常见难题读取失败');
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
const openAssign = (row: PracticeDifficulty) => { activeRow.value = row; dueDate.value = dateAfter(1); batchResult.value = null; assignVisible.value = true; };
|
||||
const submitAssign = async () => {
|
||||
if (!activeRow.value || !dueDate.value || assigning.value) return;
|
||||
assigning.value = true;
|
||||
try {
|
||||
const response = await assignPracticeDifficulty({
|
||||
scenarioId: activeRow.value.scenarioId,
|
||||
cause: activeRow.value.cause,
|
||||
dueDate: dueDate.value,
|
||||
requestId: `difficulty:${activeRow.value.scenarioId}:${Date.now()}`,
|
||||
windowDays: windowDays.value,
|
||||
position: activeRow.value.position
|
||||
});
|
||||
batchResult.value = response.data;
|
||||
assignVisible.value = false;
|
||||
resultVisible.value = true;
|
||||
await load();
|
||||
} catch { ElMessage.error('专项派发失败'); }
|
||||
finally { assigning.value = false; }
|
||||
};
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.difficulty-page { padding: 24px; color: #172033; }
|
||||
.page-head, .filter-panel, .metric-grid { display: flex; gap: 16px; align-items: center; }
|
||||
.page-head { justify-content: space-between; margin-bottom: 18px; }
|
||||
.page-head h1 { margin: 0; font-size: 28px; }
|
||||
.page-head p { margin: 8px 0 0; color: #667085; }
|
||||
.panel { border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; box-shadow: 0 4px 18px rgba(16,24,40,.05); }
|
||||
.filter-panel { padding: 16px; }
|
||||
.filter-panel :deep(.el-select) { width: 200px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0; }
|
||||
.metric-card { display: grid; gap: 8px; padding: 18px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; }
|
||||
.metric-card span { color: #667085; }
|
||||
.metric-card strong { font-size: 28px; }
|
||||
.table-panel { overflow: hidden; padding: 8px; }
|
||||
.low-score { color: #d92d20; font-weight: 800; }
|
||||
.assign-summary { display: grid; gap: 14px; }
|
||||
.assign-summary > span { color: #667085; }
|
||||
.quick-dates { display: flex; gap: 8px; }
|
||||
@media (max-width: 1100px) {
|
||||
.difficulty-page { padding: 16px; }
|
||||
.filter-panel { flex-wrap: wrap; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -8,29 +8,17 @@
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
||||
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
|
||||
<el-date-picker
|
||||
v-model="pilotDateRange"
|
||||
class="pilot-date-range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="试点开始"
|
||||
end-placeholder="试点结束"
|
||||
unlink-panels
|
||||
/>
|
||||
<el-button :icon="Download" :loading="exportingPilot" :disabled="!pilotExportWindow" @click="exportPilotCsv">导出试点CSV</el-button>
|
||||
<el-button @click="router.push('/index')">返回首页</el-button>
|
||||
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="recordsFallback"
|
||||
class="data-warning"
|
||||
type="warning"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="训练记录接口暂不可用,当前显示演示记录,不代表真实训练数据。"
|
||||
title="此页仅用于不绑定员工身份的运营预览;真实训练历史、主管复盘、团队难题和试点数据均不在此页展示。"
|
||||
/>
|
||||
|
||||
<section class="flow-strip">
|
||||
@@ -182,7 +170,7 @@
|
||||
<div class="calibration-card">
|
||||
<div class="calibration-head">
|
||||
<strong>人工校准</strong>
|
||||
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
|
||||
<small>{{ calibrationMessage || (canCalibrate ? '仅作运营预览校准,不计入员工成长或正式试点' : '完成后端运营预览评分即可记录') }}</small>
|
||||
</div>
|
||||
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
|
||||
<el-input v-model="calibrationReason" type="textarea" :rows="2" resize="none" :disabled="!canCalibrate" placeholder="校准原因" />
|
||||
@@ -194,21 +182,6 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel record-panel">
|
||||
<div class="panel-title">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<h2>最近对练记录</h2>
|
||||
</div>
|
||||
<el-table v-loading="recordsLoading" :data="records" size="small" class="record-table">
|
||||
<el-table-column prop="time" label="时间" width="128" />
|
||||
<el-table-column prop="trainee" label="学员" width="120" />
|
||||
<el-table-column prop="scene" label="场景" min-width="150" />
|
||||
<el-table-column prop="score" label="得分" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel prompt-panel">
|
||||
<div class="panel-title title-with-extra">
|
||||
<div>
|
||||
@@ -311,14 +284,12 @@
|
||||
|
||||
<script setup name="TrainPractice" lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Download, Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FileSaver from 'file-saver';
|
||||
import {
|
||||
calibratePracticeSession,
|
||||
createPromptTemplate,
|
||||
exportPracticePilotCsv,
|
||||
finishPracticeSession,
|
||||
getPracticePrepCard,
|
||||
listPromptTemplates,
|
||||
@@ -331,9 +302,7 @@ import {
|
||||
type PracticePrepCardResponse,
|
||||
type PracticeScenarioResponse
|
||||
} from '@/api/aihr/practice';
|
||||
import { listPracticeReviews } from '@/api/aihr/mobile-admin';
|
||||
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
import { resolvePilotDateWindow } from './pilot-export';
|
||||
|
||||
type ChatRole = 'customer' | 'trainee' | 'coach';
|
||||
|
||||
@@ -459,12 +428,6 @@ const fallbackScenarios: ScenarioSeed[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const baseRecords = [
|
||||
{ time: '07-02 09:32', trainee: '张三', scene: '客服应对投诉', score: 86, status: '已完成', summary: '能先安抚并确认诉求,但反馈时限需要更明确。' },
|
||||
{ time: '07-02 09:12', trainee: '李四', scene: '催缴沟通技巧', score: 78, status: '已完成', summary: '费用争议回应完整,服务改进承诺偏弱。' },
|
||||
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
|
||||
];
|
||||
|
||||
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
|
||||
const scenarioId = ref(fallbackScenarios[0].id);
|
||||
const started = ref(false);
|
||||
@@ -474,7 +437,7 @@ const showGoal = ref(false);
|
||||
const activeRound = ref(0);
|
||||
const draftReply = ref('');
|
||||
const sessionId = ref('');
|
||||
const apiMode = ref<'local' | 'api' | 'fallback'>('local');
|
||||
const apiMode = ref<'local' | 'preview' | 'fallback'>('local');
|
||||
const apiScores = ref<ScenarioSeed['scores'] | null>(null);
|
||||
const apiTotal = ref<number | null>(null);
|
||||
const apiRewrite = ref('');
|
||||
@@ -487,9 +450,6 @@ const sopHelp = ref<SopSearchResponse | null>(null);
|
||||
const sopHelpLoading = ref(false);
|
||||
const sopHelpMessage = ref('');
|
||||
const conversation = ref<ChatTurn[]>([]);
|
||||
const records = ref<Array<(typeof baseRecords)[number]>>([]);
|
||||
const recordsLoading = ref(true);
|
||||
const recordsFallback = ref(false);
|
||||
const promptTemplates = ref<PromptTemplateResponse[]>([]);
|
||||
const promptLoading = ref(false);
|
||||
const promptSaving = ref(false);
|
||||
@@ -497,9 +457,6 @@ const calibrationScore = ref(0);
|
||||
const calibrationReason = ref('管理端人工校准');
|
||||
const calibrationSaving = ref(false);
|
||||
const calibrationMessage = ref('');
|
||||
const exportingPilot = ref(false);
|
||||
const pilotDateRange = ref<string[] | null>(null);
|
||||
const pilotExportWindow = computed(() => resolvePilotDateWindow(pilotDateRange.value));
|
||||
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
|
||||
const defaultPromptForm = (): PromptTemplateForm => ({
|
||||
id: null,
|
||||
@@ -521,7 +478,7 @@ const selectedScenario = computed(
|
||||
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
|
||||
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
|
||||
const canScore = computed(() => finished.value && !scored.value);
|
||||
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value));
|
||||
const canCalibrate = computed(() => scored.value && apiMode.value === 'preview' && Boolean(sessionId.value));
|
||||
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
|
||||
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
|
||||
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
|
||||
@@ -548,8 +505,8 @@ const emotionScore = computed(() => {
|
||||
return Math.max(45, 76 - completedRounds.value * 8);
|
||||
});
|
||||
const apiPill = computed(() => {
|
||||
if (apiMode.value === 'api') {
|
||||
return { text: '真实 API', className: 'pill-info' };
|
||||
if (apiMode.value === 'preview') {
|
||||
return { text: '运营预览 API', className: 'pill-info' };
|
||||
}
|
||||
if (apiMode.value === 'fallback') {
|
||||
return { text: '本地剧本', className: 'pill-warning' };
|
||||
@@ -563,7 +520,7 @@ const flowPill = computed(() => {
|
||||
if (!scored.value) {
|
||||
return { text: '对练进行中', className: 'pill-warning' };
|
||||
}
|
||||
return { text: '已完成闭环', className: 'pill-success' };
|
||||
return { text: '已完成运营预览', className: 'pill-success' };
|
||||
});
|
||||
const flowSteps = computed(() => [
|
||||
{ index: '1', title: '选场景', desc: selectedScenario.value.name, active: !started.value, done: started.value || scored.value },
|
||||
@@ -657,27 +614,6 @@ const loadScenarioOptions = async () => {
|
||||
await loadPrepCard();
|
||||
};
|
||||
|
||||
const loadRecentRecords = async () => {
|
||||
recordsLoading.value = true;
|
||||
try {
|
||||
const { data } = await listPracticeReviews({ limit: 10 });
|
||||
records.value = (data || []).map((item) => ({
|
||||
time: item.time,
|
||||
trainee: item.trainee,
|
||||
scene: item.scene,
|
||||
score: item.score,
|
||||
status: item.status,
|
||||
summary: item.summary
|
||||
}));
|
||||
recordsFallback.value = false;
|
||||
} catch {
|
||||
records.value = [...baseRecords];
|
||||
recordsFallback.value = true;
|
||||
} finally {
|
||||
recordsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const appendTurn = (role: ChatRole, text: string) => {
|
||||
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
|
||||
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
|
||||
@@ -721,11 +657,11 @@ const startPractice = async () => {
|
||||
mode: 'real'
|
||||
});
|
||||
sessionId.value = data.sessionId;
|
||||
apiMode.value = 'api';
|
||||
apiMode.value = 'preview';
|
||||
apiTrust.value = data.trust;
|
||||
apiEmotion.value = data.emotion;
|
||||
appendTurn('customer', data.customerText || fallbackCustomer);
|
||||
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
|
||||
ElMessage.success(`已进入 ${selectedScenario.value.name} 的后端运营预览;不会使用或生成员工身份`);
|
||||
} catch {
|
||||
sessionId.value = '';
|
||||
apiMode.value = 'fallback';
|
||||
@@ -745,7 +681,7 @@ const continueRound = async () => {
|
||||
appendTurn('trainee', traineeText);
|
||||
draftReply.value = '';
|
||||
|
||||
if (apiMode.value === 'api' && sessionId.value) {
|
||||
if (apiMode.value === 'preview' && sessionId.value) {
|
||||
try {
|
||||
const { data } = await submitPracticeTurn({
|
||||
sessionId: sessionId.value,
|
||||
@@ -803,16 +739,7 @@ const askSopHelp = async () => {
|
||||
};
|
||||
|
||||
const finishScore = async () => {
|
||||
let nextRecord = {
|
||||
time: '07-02 10:42',
|
||||
trainee: selectedScenario.value.trainee,
|
||||
scene: selectedScenario.value.name,
|
||||
score: displayTotal.value,
|
||||
status: '已完成',
|
||||
summary: displaySummary.value
|
||||
};
|
||||
|
||||
if (apiMode.value === 'api' && sessionId.value) {
|
||||
if (apiMode.value === 'preview' && sessionId.value) {
|
||||
try {
|
||||
const { data } = await finishPracticeSession({ sessionId: sessionId.value });
|
||||
apiScores.value = data.score;
|
||||
@@ -820,8 +747,7 @@ const finishScore = async () => {
|
||||
apiRewrite.value = data.mentorRewrite;
|
||||
apiSummary.value = data.aiComment;
|
||||
apiTrust.value = data.trustCurve?.[data.trustCurve.length - 1] ?? selectedScenario.value.trustEnd;
|
||||
nextRecord = data.records?.[0] ?? { ...nextRecord, score: data.totalScore, summary: data.aiComment };
|
||||
ElMessage.success('三角色对练评分已由后端 API 返回');
|
||||
ElMessage.success('运营预览评分已由后端 API 返回,不计入员工训练记录');
|
||||
} catch {
|
||||
apiMode.value = 'fallback';
|
||||
apiScores.value = null;
|
||||
@@ -839,15 +765,14 @@ const finishScore = async () => {
|
||||
|
||||
scored.value = true;
|
||||
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
|
||||
records.value = [nextRecord, ...records.value];
|
||||
if (apiMode.value !== 'api') {
|
||||
ElMessage.success('考官评分已生成,并写入本地对练记录');
|
||||
if (apiMode.value !== 'preview') {
|
||||
ElMessage.success('考官评分已生成,仅保留在当前页面的本地演示记录');
|
||||
}
|
||||
};
|
||||
|
||||
const saveCalibration = async () => {
|
||||
if (!canCalibrate.value) {
|
||||
ElMessage.warning('请先完成一次真实 API 对练评分');
|
||||
ElMessage.warning('请先完成一次后端运营预览评分');
|
||||
return;
|
||||
}
|
||||
calibrationSaving.value = true;
|
||||
@@ -866,25 +791,6 @@ const saveCalibration = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportPilotCsv = async () => {
|
||||
const dateWindow = pilotExportWindow.value;
|
||||
if (!dateWindow) {
|
||||
ElMessage.warning('请选择完整的试点批次日期');
|
||||
return;
|
||||
}
|
||||
const { startDate, endDate } = dateWindow;
|
||||
exportingPilot.value = true;
|
||||
try {
|
||||
const data = await exportPracticePilotCsv({ startDate, endDate });
|
||||
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), `aihr-practice-pilot-${startDate}-${endDate}.csv`);
|
||||
ElMessage.success('试点数据 CSV 已导出');
|
||||
} catch {
|
||||
ElMessage.error('试点数据导出失败,请检查权限');
|
||||
} finally {
|
||||
exportingPilot.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
|
||||
id: template.id,
|
||||
code: template.code || '',
|
||||
@@ -1004,7 +910,6 @@ const togglePromptTemplate = async (template: PromptTemplateResponse) => {
|
||||
onMounted(() => {
|
||||
void loadScenarioOptions();
|
||||
void loadPromptTemplates();
|
||||
void loadRecentRecords();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1044,10 +949,6 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pilot-date-range {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.flow-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -1451,12 +1352,6 @@ onMounted(() => {
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.record-panel {
|
||||
:deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.record-table {
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>复盘校准</h1>
|
||||
<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>
|
||||
|
||||
<el-alert
|
||||
v-if="reviewsFallback || metricsFallback"
|
||||
class="data-warning"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="部分真实数据接口暂不可用;当前页面不展示演示复盘记录或猜测性试点指标。"
|
||||
/>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>校准样本</span><strong>{{ calibrationMetric }}</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">
|
||||
<h2>待复盘记录</h2>
|
||||
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="list-filters">
|
||||
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
|
||||
<el-option
|
||||
v-for="person in orgPeople"
|
||||
:key="person.extPartyId"
|
||||
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
|
||||
:value="person.extPartyId"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="visibleReviews" size="small" highlight-current-row @row-click="selectReview">
|
||||
<el-table-column prop="trainee" label="学员" width="96" />
|
||||
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="score" label="得分" width="70" />
|
||||
<el-table-column prop="status" label="状态" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '待复盘' ? 'warning' : 'success'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="时间" width="128" />
|
||||
</el-table>
|
||||
</article>
|
||||
|
||||
<article class="panel detail-panel">
|
||||
<template v-if="detail">
|
||||
<div class="panel-title">
|
||||
<h2>{{ detail.trainee }} · {{ detail.scene }}</h2>
|
||||
<el-tag :type="detail.score >= 80 ? 'success' : 'danger'">{{ detail.score }} 分</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="score-grid">
|
||||
<div v-for="item in detail.scoreItems" :key="item.label" class="score-card">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small>{{ item.note }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialogue-list">
|
||||
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
|
||||
<div class="dialogue-head">
|
||||
<strong>{{ turn.label }}</strong>
|
||||
<el-tag v-if="hasAudio(turn)" size="small" type="info">有录音</el-tag>
|
||||
</div>
|
||||
<p>{{ turn.text }}</p>
|
||||
<div v-if="hasAudio(turn)" class="dialogue-audio-row">
|
||||
<audio v-if="audioSource(turn, index)" controls :src="audioSource(turn, index)" />
|
||||
<el-button v-else size="small" :loading="audioLoading[audioKey(index)]" @click.stop="loadAudio(turn, index)">加载录音</el-button>
|
||||
<span v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annotation-list">
|
||||
<h3>逐句标注</h3>
|
||||
<el-table :data="detail.annotations" size="small" empty-text="暂无逐句标注">
|
||||
<el-table-column prop="sourceText" label="原句" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="issue" label="问题" min-width="160" />
|
||||
<el-table-column prop="rewrite" label="推荐改写" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="sopPoint" label="SOP点" min-width="160" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-form class="review-form" label-position="top">
|
||||
<el-form-item label="复盘建议">
|
||||
<el-input v-model="reviewForm.advice" type="textarea" :rows="3" placeholder="写给员工的复盘建议" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="人工贡献分">
|
||||
<el-input-number v-model="reviewForm.incentivePoint" :min="0" :max="100" placeholder="可选,不填不计分" />
|
||||
</el-form-item>
|
||||
<el-form-item label="人工校准总分">
|
||||
<el-input-number v-model="reviewForm.correctedTotal" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="校准原因">
|
||||
<el-input v-model="reviewForm.calibrationReason" placeholder="例如:AI 对安抚情绪权重偏低" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="下次训练">
|
||||
<el-select v-model="reviewForm.nextScenarioId" clearable placeholder="不派发">
|
||||
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="派发原因">
|
||||
<el-input v-model="reviewForm.nextReason" placeholder="例如:低分维度定向再练" />
|
||||
</el-form-item>
|
||||
<div class="form-actions">
|
||||
<el-button type="danger" :loading="saving" @click="submitReview">标记已复盘</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
<el-empty v-else description="选择一条待复盘记录" />
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainReviews" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
||||
import { resolvePilotDateWindow } from './pilot-export';
|
||||
import {
|
||||
getPracticeAudioBlobUrl,
|
||||
getOrgSnapshot,
|
||||
getPracticeReview,
|
||||
listPracticeReviews,
|
||||
markPracticeReviewed,
|
||||
type OrgSnapshotResponse,
|
||||
type ReviewDetail,
|
||||
type ReviewRecord
|
||||
} from '@/api/aihr/mobile-admin';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const reviews = ref<ReviewRecord[]>([]);
|
||||
const detail = ref<ReviewDetail | null>(null);
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||||
const selectedProjectCode = ref('');
|
||||
const selectedExtPartyId = ref('');
|
||||
const selectedPosition = ref('生活顾问');
|
||||
const exporting = ref(false);
|
||||
const reviewsFallback = ref(false);
|
||||
const metricsFallback = ref(false);
|
||||
const startDate = ref('2026-07-01');
|
||||
const endDate = ref('2026-07-31');
|
||||
const metrics = reactive({ calibrationCount: null as number | null, consistency: '--', sopUsable: '--', ready: false });
|
||||
const audioSources = ref<Record<string, string>>({});
|
||||
const audioLoading = ref<Record<string, boolean>>({});
|
||||
const audioErrors = ref<Record<string, string>>({});
|
||||
const pilotDateWindow = computed(() => resolvePilotDateWindow([startDate.value, endDate.value]));
|
||||
|
||||
const reviewForm = reactive({
|
||||
reviewer: '主管',
|
||||
advice: '',
|
||||
incentivePoint: null as number | null,
|
||||
correctedTotal: 0,
|
||||
calibrationReason: '',
|
||||
nextScenarioId: '',
|
||||
nextReason: ''
|
||||
});
|
||||
|
||||
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
|
||||
const visibleReviews = computed(() => reviews.value);
|
||||
const calibrationMetric = computed(() => (metrics.calibrationCount === null ? '--' : `${metrics.calibrationCount} / 20`));
|
||||
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
|
||||
|
||||
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
|
||||
const inlineAudioSource = (turn: ReviewDialogueTurn) =>
|
||||
turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : '';
|
||||
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioOssId || inlineAudioSource(turn));
|
||||
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || inlineAudioSource(turn);
|
||||
const needsCalibration = () =>
|
||||
Boolean(detail.value?.sessionId) && (reviewForm.correctedTotal !== detail.value.score || Boolean(reviewForm.calibrationReason.trim()));
|
||||
|
||||
const releaseAudioSources = () => {
|
||||
Object.values(audioSources.value).forEach((url) => URL.revokeObjectURL(url));
|
||||
audioSources.value = {};
|
||||
audioLoading.value = {};
|
||||
audioErrors.value = {};
|
||||
};
|
||||
|
||||
const loadAudio = async (turn: ReviewDialogueTurn, index: number) => {
|
||||
const key = audioKey(index);
|
||||
if (audioSource(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
|
||||
audioLoading.value = { ...audioLoading.value, [key]: true };
|
||||
audioErrors.value = { ...audioErrors.value, [key]: '' };
|
||||
try {
|
||||
const url = await getPracticeAudioBlobUrl(turn.audioOssId);
|
||||
audioSources.value = { ...audioSources.value, [key]: url };
|
||||
} catch {
|
||||
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
|
||||
} finally {
|
||||
audioLoading.value = { ...audioLoading.value, [key]: false };
|
||||
}
|
||||
};
|
||||
|
||||
const loadScenarios = async () => {
|
||||
try {
|
||||
const result = await listPracticeScenarios();
|
||||
scenarios.value = result.data || [];
|
||||
} catch (error) {
|
||||
scenarios.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrgSnapshot = async () => {
|
||||
try {
|
||||
const result = await getOrgSnapshot({
|
||||
status: 'active',
|
||||
projectCode: selectedProjectCode.value,
|
||||
pageNum: 1,
|
||||
pageSize: 500
|
||||
});
|
||||
orgSnapshot.value = result.data;
|
||||
} catch (error) {
|
||||
orgSnapshot.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviews = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await listPracticeReviews({
|
||||
projectCode: selectedProjectCode.value,
|
||||
extPartyId: selectedExtPartyId.value,
|
||||
limit: 50
|
||||
});
|
||||
reviews.value = result.data || [];
|
||||
reviewsFallback.value = false;
|
||||
await selectFirstVisibleReview();
|
||||
} catch (error) {
|
||||
reviews.value = [];
|
||||
detail.value = null;
|
||||
releaseAudioSources();
|
||||
reviewsFallback.value = true;
|
||||
ElMessage.error('待复盘列表加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMetrics = async () => {
|
||||
const dateWindow = pilotDateWindow.value;
|
||||
if (!dateWindow) {
|
||||
metrics.calibrationCount = null;
|
||||
metrics.consistency = '--';
|
||||
metrics.sopUsable = '--';
|
||||
metrics.ready = false;
|
||||
metricsFallback.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await exportPracticePilotCsv(dateWindow);
|
||||
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';
|
||||
metricsFallback.value = false;
|
||||
} catch {
|
||||
metrics.calibrationCount = null;
|
||||
metrics.consistency = '--';
|
||||
metrics.sopUsable = '--';
|
||||
metrics.ready = false;
|
||||
metricsFallback.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const exportPilot = async () => {
|
||||
const dateWindow = pilotDateWindow.value;
|
||||
if (!dateWindow) {
|
||||
ElMessage.warning('请选择有效的试点起止日期');
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await exportPracticePilotCsv(dateWindow);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `aihr-pilot-${dateWindow.startDate}-${dateWindow.endDate}.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);
|
||||
detail.value = result.data;
|
||||
reviewForm.advice = result.data.reviewAdvice || '';
|
||||
reviewForm.incentivePoint = result.data.incentivePoint ?? null;
|
||||
reviewForm.correctedTotal = result.data.score;
|
||||
reviewForm.calibrationReason = '';
|
||||
reviewForm.nextScenarioId = '';
|
||||
reviewForm.nextReason = '';
|
||||
};
|
||||
|
||||
const selectFirstVisibleReview = async () => {
|
||||
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
|
||||
const first = visibleReviews.value[0];
|
||||
if (!first) {
|
||||
releaseAudioSources();
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
await selectReview(first);
|
||||
};
|
||||
|
||||
const submitReview = async () => {
|
||||
if (!detail.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (needsCalibration()) {
|
||||
await calibratePracticeSession(detail.value.sessionId, {
|
||||
reviewer: reviewForm.reviewer,
|
||||
correctedTotal: reviewForm.correctedTotal,
|
||||
reason: reviewForm.calibrationReason
|
||||
});
|
||||
}
|
||||
await markPracticeReviewed(detail.value.id, {
|
||||
reviewer: reviewForm.reviewer,
|
||||
advice: reviewForm.advice,
|
||||
incentivePoint: reviewForm.incentivePoint ?? undefined,
|
||||
nextScenarioId: reviewForm.nextScenarioId,
|
||||
nextReason: reviewForm.nextReason
|
||||
});
|
||||
ElMessage.success('已完成复盘处理');
|
||||
releaseAudioSources();
|
||||
detail.value = null;
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
ElMessage.error('复盘提交失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedProjectCode, async () => {
|
||||
selectedExtPartyId.value = '';
|
||||
await loadOrgSnapshot();
|
||||
await loadReviews();
|
||||
});
|
||||
|
||||
watch(selectedExtPartyId, () => {
|
||||
loadReviews();
|
||||
});
|
||||
|
||||
watch([startDate, endDate], () => {
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadScenarios();
|
||||
loadOrgSnapshot();
|
||||
loadReviews();
|
||||
loadMetrics();
|
||||
});
|
||||
|
||||
onBeforeUnmount(releaseAudioSources);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.review-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background: #f5f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel,
|
||||
.score-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.score-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
padding: 12px;
|
||||
|
||||
span,
|
||||
small {
|
||||
display: block;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialogue-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialogue-item {
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #475467;
|
||||
}
|
||||
}
|
||||
|
||||
.dialogue-head,
|
||||
.dialogue-audio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialogue-audio-row {
|
||||
margin-top: 10px;
|
||||
|
||||
audio {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
color: #d92d20;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.annotation-list {
|
||||
margin-top: 18px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.review-layout,
|
||||
.list-filters,
|
||||
.score-grid,
|
||||
.form-grid,
|
||||
.filter-panel,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>场景库管理</h1>
|
||||
<p>五岗位训练内容 · 场景回合、版本、Rubric 与启停维护</p>
|
||||
<p>住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护</p>
|
||||
</div>
|
||||
<el-button type="danger" @click="openCreate">新增场景</el-button>
|
||||
</section>
|
||||
@@ -15,6 +15,12 @@
|
||||
<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.growthLevel" placeholder="成长层级" clearable>
|
||||
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.reviewStatus" placeholder="审核状态" clearable>
|
||||
<el-option v-for="item in reviewStatuses" :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" />
|
||||
@@ -23,18 +29,55 @@
|
||||
<el-button type="danger" @click="queryScenes">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section class="matrix-card">
|
||||
<div class="matrix-head">
|
||||
<div>
|
||||
<h2>矩阵缺口</h2>
|
||||
<p>按住宅物业 × 岗位 × 层级 × 能力项核对。只有“已发布且启用”才计为可训练内容。</p>
|
||||
</div>
|
||||
<el-button text type="danger" @click="loadMatrix">刷新矩阵</el-button>
|
||||
</div>
|
||||
<el-table :data="visibleMatrixRows" size="small" max-height="240">
|
||||
<el-table-column prop="position" label="岗位" width="100" />
|
||||
<el-table-column prop="growthLevel" label="层级" width="90" />
|
||||
<el-table-column prop="competencyName" label="能力项" min-width="180" />
|
||||
<el-table-column prop="publishedCount" label="已发布" width="82" />
|
||||
<el-table-column prop="pendingReviewCount" label="待审核" width="82" />
|
||||
<el-table-column prop="draftCount" label="草稿" width="70" />
|
||||
<el-table-column label="覆盖状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matrixTagType(row.coverageStatus)" size="small">{{ row.coverageStatus }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<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="growthLevel" label="层级" width="84" />
|
||||
<el-table-column label="能力项" min-width="160">
|
||||
<template #default="{ row }">{{ capabilityName(row) }}</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<el-table-column label="审核" width="98">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="reviewTagType(row.reviewStatus)" size="small">{{ row.reviewStatus || '草稿' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务审核" min-width="210" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ reviewTrail(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启停" width="72">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
|
||||
{{ row.enabled === false ? '停用' : '启用' }}
|
||||
@@ -42,9 +85,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateTime" label="更新时间" width="150" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<el-table-column label="操作" width="286" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="advanceReviewStatus(row)">{{ reviewActionLabel(row) }}</el-button>
|
||||
<el-button v-if="row.reviewStatus === '待业务审核'" link type="info" @click="returnToDraft(row)">退回草稿</el-button>
|
||||
<el-button link type="danger" @click="toggleEnabled(row)">
|
||||
{{ row.enabled === false ? '启用' : '停用' }}
|
||||
</el-button>
|
||||
@@ -89,12 +134,60 @@
|
||||
<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-form-item label="成长层级">
|
||||
<el-select v-model="form.growthLevel" class="full-control">
|
||||
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="能力项">
|
||||
<el-select v-model="form.competencyCode" class="full-control" placeholder="选择本岗位、层级的能力项">
|
||||
<el-option
|
||||
v-if="form.competencyCode && !currentCapabilityOptions.some((item) => item.code === form.competencyCode)"
|
||||
:label="`${form.competencyCode}(待归类)`"
|
||||
:value="form.competencyCode"
|
||||
/>
|
||||
<el-option v-for="item in currentCapabilityOptions" :key="item.code" :label="item.name" :value="item.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="目录版本"><el-input v-model="form.curriculumVersion" /></el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8"><el-form-item label="项目类型"><el-input v-model="form.projectType" disabled /></el-form-item></el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="内容风险" required>
|
||||
<el-select v-model="form.riskLevel" class="full-control">
|
||||
<el-option label="待评估" value="待评估" />
|
||||
<el-option label="常规" value="常规" />
|
||||
<el-option label="高风险(需两位不同审核人)" value="高风险" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8"><el-form-item label="协同岗位(可选)"><el-input v-model="form.collaborationPositions" placeholder="例如:保安、客服" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-alert
|
||||
:closable="false"
|
||||
type="info"
|
||||
show-icon
|
||||
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>
|
||||
<el-form-item label="业务来源 / SOP 引用" required>
|
||||
<el-input v-model="form.sopRefs" type="textarea" :rows="2" placeholder="例如:SOP-LG-001 / 对应制度条款" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务红线(不应这样做)" required>
|
||||
<el-input v-model="form.irritants" type="textarea" :rows="2" placeholder="例如:不得推诿、不得承诺无法兑现的时限" />
|
||||
</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>
|
||||
@@ -126,8 +219,13 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
listPracticeScenarios,
|
||||
listPracticeCurriculumCapabilities,
|
||||
listPracticeCurriculumMatrix,
|
||||
savePracticeScenario,
|
||||
updatePracticeScenarioEnabled,
|
||||
updatePracticeScenarioReviewStatus,
|
||||
type PracticeCurriculumCapability,
|
||||
type PracticeCurriculumMatrixRow,
|
||||
type PracticeScenarioResponse,
|
||||
type PracticeScenarioRound,
|
||||
type PracticeScenarioSaveRequest
|
||||
@@ -138,11 +236,25 @@ const saving = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const editingId = ref('');
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const curriculumCapabilities = ref<PracticeCurriculumCapability[]>([]);
|
||||
const matrixRows = ref<PracticeCurriculumMatrixRow[]>([]);
|
||||
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
|
||||
const growthLevels = ['初级', '中级', '高级'];
|
||||
const reviewStatuses = ['草稿', '待业务审核', '已发布', '已下线'];
|
||||
const riskLevels = ['待评估', '常规', '高风险'];
|
||||
const scenarioTypes = ref<string[]>([]);
|
||||
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
|
||||
const filters = reactive<{
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
growthLevel: string;
|
||||
reviewStatus: string;
|
||||
enabled: boolean | undefined;
|
||||
keyword: string;
|
||||
}>({
|
||||
position: '',
|
||||
scenarioType: '',
|
||||
growthLevel: '',
|
||||
reviewStatus: '',
|
||||
enabled: undefined,
|
||||
keyword: ''
|
||||
});
|
||||
@@ -153,6 +265,11 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
|
||||
position: '生活顾问',
|
||||
scenarioType: '投诉处理',
|
||||
projectType: '住宅',
|
||||
growthLevel: '初级',
|
||||
competencyCode: '',
|
||||
collaborationPositions: '',
|
||||
riskLevel: '待评估',
|
||||
curriculumVersion: '住宅五岗-v1',
|
||||
difficulty: 1,
|
||||
customerPersona: '',
|
||||
openingText: '',
|
||||
@@ -160,7 +277,7 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
|
||||
hiddenNeed: '',
|
||||
irritants: '',
|
||||
delights: '',
|
||||
sopRefs: '生活顾问SOP',
|
||||
sopRefs: '',
|
||||
successCriteria: '',
|
||||
strategy: '仅在漏关键步骤时提示',
|
||||
goal: '',
|
||||
@@ -178,6 +295,8 @@ const filteredScenarios = computed(() =>
|
||||
return (
|
||||
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
|
||||
(!filters.position || item.position === filters.position) &&
|
||||
(!filters.growthLevel || item.growthLevel === filters.growthLevel) &&
|
||||
(!filters.reviewStatus || item.reviewStatus === filters.reviewStatus) &&
|
||||
(filters.enabled === undefined || item.enabled === filters.enabled) &&
|
||||
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
|
||||
);
|
||||
@@ -188,6 +307,12 @@ const pagedScenarios = computed(() => {
|
||||
return filteredScenarios.value.slice(start, start + pagination.size);
|
||||
});
|
||||
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
|
||||
const visibleMatrixRows = computed(() =>
|
||||
matrixRows.value.filter((item) => !filters.position || item.position === filters.position)
|
||||
);
|
||||
const currentCapabilityOptions = computed(() =>
|
||||
curriculumCapabilities.value.filter((item) => item.position === form.position && item.growthLevel === form.growthLevel)
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
@@ -195,11 +320,22 @@ const load = async () => {
|
||||
const { data } = await listPracticeScenarios({ includeDisabled: true });
|
||||
scenarios.value = data || [];
|
||||
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
|
||||
await Promise.all([loadCapabilities(), loadMatrix()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
const { data } = await listPracticeCurriculumCapabilities();
|
||||
curriculumCapabilities.value = data || [];
|
||||
};
|
||||
|
||||
const loadMatrix = async () => {
|
||||
const { data } = await listPracticeCurriculumMatrix({ position: filters.position || undefined });
|
||||
matrixRows.value = data || [];
|
||||
};
|
||||
|
||||
const queryScenes = () => {
|
||||
pagination.page = 1;
|
||||
};
|
||||
@@ -217,6 +353,11 @@ const fillForm = (item?: PracticeScenarioResponse) => {
|
||||
position: item.position,
|
||||
scenarioType: item.scenarioType,
|
||||
projectType: item.projectType,
|
||||
growthLevel: item.growthLevel || '初级',
|
||||
competencyCode: item.competencyCode || '',
|
||||
collaborationPositions: item.collaborationPositions || '',
|
||||
riskLevel: riskLevels.includes(item.riskLevel) ? item.riskLevel : '待评估',
|
||||
curriculumVersion: item.curriculumVersion || '住宅五岗-v1',
|
||||
difficulty: item.difficulty,
|
||||
customerPersona: item.customerPersona,
|
||||
openingText: item.openingText,
|
||||
@@ -258,6 +399,84 @@ const toggleEnabled = async (item: PracticeScenarioResponse) => {
|
||||
await updatePracticeScenarioEnabled(item.id, { enabled });
|
||||
item.enabled = enabled;
|
||||
ElMessage.success(enabled ? '场景已启用' : '场景已停用');
|
||||
await loadMatrix();
|
||||
};
|
||||
|
||||
const reviewActionLabel = (item: PracticeScenarioResponse) => {
|
||||
const status = item.reviewStatus || '草稿';
|
||||
if (status === '待业务审核' && item.requiresDualReview && item.reviewer && !item.secondReviewer) {
|
||||
return '复核并发布';
|
||||
}
|
||||
return ({
|
||||
草稿: '提交审核',
|
||||
待业务审核: '审核并发布',
|
||||
已发布: '下线',
|
||||
已下线: '重开草稿'
|
||||
}[status] || '提交审核');
|
||||
};
|
||||
|
||||
const nextReviewStatus = (status?: string): '草稿' | '待业务审核' | '已发布' | '已下线' => ({
|
||||
草稿: '待业务审核',
|
||||
待业务审核: '已发布',
|
||||
已发布: '已下线',
|
||||
已下线: '草稿'
|
||||
}[status || '草稿'] || '待业务审核') as '草稿' | '待业务审核' | '已发布' | '已下线';
|
||||
|
||||
const advanceReviewStatus = async (item: PracticeScenarioResponse) => {
|
||||
const reviewStatus = nextReviewStatus(item.reviewStatus);
|
||||
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus });
|
||||
await load();
|
||||
ElMessage.success(result.data?.message || `场景已更新为“${reviewStatus}”`);
|
||||
};
|
||||
|
||||
const returnToDraft = async (item: PracticeScenarioResponse) => {
|
||||
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus: '草稿' });
|
||||
await load();
|
||||
ElMessage.success(result.data?.message || '场景已退回草稿');
|
||||
};
|
||||
|
||||
const reviewerWithTime = (reviewer?: string, reviewedTime?: string) =>
|
||||
reviewer ? `${reviewer}${reviewedTime ? `(${reviewedTime})` : ''}` : '未记录';
|
||||
|
||||
const reviewTrail = (item: PracticeScenarioResponse) => {
|
||||
const status = item.reviewStatus || '草稿';
|
||||
if (item.requiresDualReview) {
|
||||
if (status === '草稿') {
|
||||
return item.reviewer ? `高风险 · 已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '高风险 · 草稿未提交';
|
||||
}
|
||||
if (status === '待业务审核') {
|
||||
return item.reviewer
|
||||
? `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待第二人复核`
|
||||
: '高风险 · 待首审';
|
||||
}
|
||||
if (status === '已发布') {
|
||||
return `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)};复审:${reviewerWithTime(item.secondReviewer, item.secondReviewedTime)} · 已双审`;
|
||||
}
|
||||
return '高风险 · 已下线';
|
||||
}
|
||||
if (status === '草稿') return item.reviewer ? `已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '草稿未提交';
|
||||
if (status === '待业务审核') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待发布` : '待业务审核';
|
||||
if (status === '已发布') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 已发布` : '已发布';
|
||||
return '已下线';
|
||||
};
|
||||
|
||||
const reviewTagType = (status?: string) => ({
|
||||
草稿: 'info',
|
||||
待业务审核: 'warning',
|
||||
已发布: 'success',
|
||||
已下线: 'danger'
|
||||
}[status || '草稿'] || 'info') as 'success' | 'warning' | 'info' | 'danger';
|
||||
|
||||
const matrixTagType = (status?: string) => {
|
||||
if (status === '已覆盖') return 'success';
|
||||
if (status === '待审核' || status === '草稿待补全') return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const capabilityName = (scenario: PracticeScenarioResponse) => {
|
||||
const item = curriculumCapabilities.value.find((capability) => capability.code === scenario.competencyCode
|
||||
&& capability.position === scenario.position && capability.growthLevel === scenario.growthLevel);
|
||||
return item?.name || scenario.competencyCode || '待归类';
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -265,6 +484,14 @@ const submit = async () => {
|
||||
ElMessage.warning('请填写场景名称、开场话术和训练目标');
|
||||
return;
|
||||
}
|
||||
if (!form.competencyCode.trim()) {
|
||||
ElMessage.warning('请先选择场景所属的能力项;未归类内容不能发布');
|
||||
return;
|
||||
}
|
||||
if (!form.sopRefs.trim() || !form.irritants.trim()) {
|
||||
ElMessage.warning('请填写业务来源/SOP和服务红线;它们是业务审核与发布的必要依据');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
|
||||
@@ -307,7 +534,8 @@ onMounted(load);
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.table-card {
|
||||
.table-card,
|
||||
.matrix-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
@@ -315,11 +543,46 @@ onMounted(load);
|
||||
|
||||
.filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr)) minmax(180px, 1.4fr) 78px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.matrix-card {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:deep(.el-table) {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
color: #858991;
|
||||
font-weight: 500;
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
|
||||
.matrix-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #25282d;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 0;
|
||||
color: #858991;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
:deep(.el-table) {
|
||||
color: #111;
|
||||
@@ -361,6 +624,10 @@ onMounted(load);
|
||||
:deep(.el-textarea__inner) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:deep(.el-alert) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-control {
|
||||
@@ -390,7 +657,7 @@ onMounted(load);
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filter-card {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.filter-card .el-button {
|
||||
width: 100%;
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'path';
|
||||
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const proxyTarget = env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
|
||||
const proxyTarget = process.env.VITE_APP_PROXY_TARGET || env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
|
||||
return {
|
||||
// 部署生产环境和开发环境下的URL。
|
||||
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
|
||||
|
||||
Reference in New Issue
Block a user