479 lines
14 KiB
Vue
479 lines
14 KiB
Vue
<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.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>AI辅助参考,人可否决</small>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>当前等级</span>
|
|
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
|
|
<small>初/中/高级生活顾问</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 :percentage="Number(item.value) || 0" :stroke-width="10" color="#e60012" />
|
|
</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>
|
|
</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>学分规则最小集</h2>
|
|
<el-switch v-model="leaderboardEnabled" active-text="排行榜" inactive-text="默认关闭" />
|
|
</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 leaderboardEnabled = 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: '+5', guard: '只认完成记录,不按点击或打开计分' },
|
|
{ source: '主管复盘采纳', points: '+0~100', guard: '由主管复盘时人工确认' },
|
|
{ source: '案例被采纳', points: '+20', guard: '必须审核入库并署名' },
|
|
{ source: '课程考试通过', points: '+10', 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', '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) => appendCsvRow(rows, 'dimension', item.label, `${item.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;
|
|
}
|
|
|
|
.evidence-list {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
|
|
li {
|
|
margin-bottom: 10px;
|
|
color: #475467;
|
|
}
|
|
}
|
|
|
|
.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>
|