Files
prop-ai-hr/frontend/src/views/knowledge/cases.vue
T

554 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="cases-page">
<input ref="audioInput" class="hidden-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleAudioPicked" />
<section class="page-head">
<div>
<h1>案例语音整理</h1>
<p>上传真实语音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
</div>
<div class="head-actions">
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" @click="audioInput?.click()">上传语音</el-button>
</div>
</section>
<section class="case-grid">
<article class="panel upload-panel">
<div class="panel-title">
<el-icon><Microphone /></el-icon>
<h2>上传与转写</h2>
</div>
<div class="upload-box" @click="audioInput?.click()">
<el-icon><FolderChecked /></el-icon>
<strong>{{ audioUploaded ? displayFileName : '选择语音文件' }}</strong>
<span>{{ audioUploaded ? '真实语音已完成 ASR 转写。' : '支持 mp3/wav/m4a/webm/ogg,需先配置 ASR 模型。' }}</span>
</div>
<div v-if="audioUploaded" class="transcript-card">
<span>ASR 转写结果</span>
<p>{{ displayTranscript }}</p>
</div>
<div v-else class="empty-state">先上传真实语音,页面会展示转写结果和后续整理动作。</div>
<div class="action-row">
<el-button type="danger" :disabled="!audioUploaded" @click="organizeCase">AI 整理</el-button>
</div>
</article>
<article class="panel summary-panel">
<div class="panel-title">
<el-icon><DocumentChecked /></el-icon>
<h2>结构化案例稿</h2>
</div>
<div v-if="organized" class="summary-grid">
<div v-for="item in summary" :key="item.label" class="summary-item">
<strong>{{ item.label }}</strong>
<p>{{ item.text }}</p>
</div>
</div>
<div v-else class="empty-state">AI 整理后生成背景、处理动作、结果和可复用亮点。</div>
<div v-if="organized" class="tag-row">
<span v-for="tag in displayTags" :key="tag.text" class="status-pill" :class="tag.type">{{ tag.text }}</span>
</div>
<div class="action-row">
<el-button :disabled="!organized" @click="submitReview">{{ reviewed ? '已送审' : '送审' }}</el-button>
<el-button type="danger" :disabled="!organized" @click="storeCase">{{ stored ? '已入库' : '入库' }}</el-button>
</div>
</article>
<article class="panel pipeline-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>案例沉淀 Pipeline</h2>
</div>
<div class="pipeline-list">
<div v-for="step in pipeline" :key="step.name" class="pipeline-item" :class="step.state">
<span>{{ step.name }}</span>
<strong>{{ step.count }}</strong>
<small>{{ step.note }}</small>
</div>
</div>
<div class="sample-card" :class="{ active: stored }">
<strong>复用状态</strong>
<p>{{ stored ? apiSampleHint || '案例已入库,可用于训练复用。' : '案例入库后进入训练复用;本期不展示视频样片。' }}</p>
</div>
</article>
</section>
<section class="panel record-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>最近案例</h2>
</div>
<el-table :data="records" size="small" class="record-table">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="type" label="类型" width="120" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="time" label="时间" width="130" />
<el-table-column prop="owner" label="负责人" width="120" />
<el-table-column prop="summary" label="摘要" min-width="300" />
</el-table>
</section>
</div>
</template>
<script setup name="KnowledgeCases" lang="ts">
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { curateKnowledgeCase, organizeKnowledgeCase, uploadCaseAudio, type CaseRecord, type CaseSummaryItem, type CaseTag } from '@/api/aihr/case';
const router = useRouter();
const audioInput = ref<HTMLInputElement>();
const audioUploaded = ref(false);
const organized = ref(false);
const reviewed = ref(false);
const stored = ref(false);
const caseId = ref('');
const apiMode = ref<'idle' | 'api' | 'error'>('idle');
const apiFileName = ref('');
const apiTranscript = ref('');
const apiSummary = ref<CaseSummaryItem[]>([]);
const apiTags = ref<CaseTag[]>([]);
const apiRecord = ref<CaseRecord | null>(null);
const apiSampleHint = ref('');
const displayFileName = computed(() => apiFileName.value || '未选择文件');
const displayTranscript = computed(() => apiTranscript.value || '暂无转写结果');
const displaySummary = computed(() => apiSummary.value);
const displayTags = computed(() => apiTags.value);
const summary = computed(() => (organized.value ? displaySummary.value : []));
const apiPill = computed(() => {
if (apiMode.value === 'api') return { label: '真实 ASR/API', type: 'pill-primary' };
if (apiMode.value === 'error') return { label: '接口失败', type: 'pill-warning' };
return { label: '待上传语音', type: 'pill-primary' };
});
const flowStatus = computed(() => {
if (stored.value) return { label: '已完成闭环', type: 'pill-success' };
if (reviewed.value) return { label: '已送审待入库', type: 'pill-primary' };
if (organized.value) return { label: '已生成案例稿', type: 'pill-warning' };
if (audioUploaded.value) return { label: '已完成 ASR', type: 'pill-primary' };
return { label: '等待语音', type: 'pill-warning' };
});
const pipeline = computed(() => [
{
name: '语音上传',
count: audioUploaded.value ? '1 条' : '0 条',
note: audioUploaded.value ? '真实音频已上传' : '等待上传',
state: audioUploaded.value ? 'done' : 'todo'
},
{
name: 'ASR 转写',
count: audioUploaded.value ? '1 条' : '0 条',
note: audioUploaded.value ? '真实 ASR 已转写' : '等待语音',
state: audioUploaded.value ? 'done' : 'todo'
},
{
name: 'AI 整理',
count: organized.value ? '1 条' : '0 条',
note: organized.value ? '结构化案例稿已生成' : '等待整理',
state: organized.value ? 'done' : audioUploaded.value ? 'active' : 'todo'
},
{
name: '审核入库',
count: stored.value ? '1 条' : reviewed.value ? '待入库' : '0 条',
note: stored.value ? '已进入知识库' : reviewed.value ? '已送审' : '等待审核',
state: stored.value ? 'done' : reviewed.value ? 'active' : 'todo'
},
{
name: '培训复用',
count: stored.value ? '1 条' : '0 条',
note: stored.value ? apiSampleHint.value || '已入库' : '未入库',
state: stored.value ? 'done' : 'todo'
}
]);
const baseRecords = [
{
title: '车库积水投诉处理',
type: '投诉处理',
status: '已入库',
time: '07-01 16:30',
owner: '培训组',
summary: '确认车位、派工排水、群内同步进度,业主确认问题解决。'
},
{
title: '催缴沟通争议化解',
type: '催缴沟通',
status: '待审核',
time: '07-01 14:10',
owner: '品质组',
summary: '业主质疑服务质量,管家先回应服务问题再说明缴费安排。'
},
{
title: '夜间噪音投诉跟进',
type: '邻里纠纷',
status: '整理中',
time: '06-30 19:45',
owner: '项目组',
summary: '记录噪音时间、沟通责任方、约定复核节点。'
}
];
const records = ref([...baseRecords]);
async function handleAudioPicked(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
await uploadAudio(file);
}
async function uploadAudio(file: File) {
audioUploaded.value = false;
organized.value = false;
reviewed.value = false;
stored.value = false;
apiSummary.value = [];
apiRecord.value = null;
apiSampleHint.value = '';
apiTranscript.value = '';
apiTags.value = [];
try {
const form = new FormData();
form.append('file', file);
form.append('projectExtOrgId', 'mobile-upload');
const { data } = await uploadCaseAudio(form);
caseId.value = data.caseId;
apiMode.value = 'api';
apiFileName.value = data.fileName;
apiTranscript.value = data.transcript;
apiTags.value = data.tags;
audioUploaded.value = true;
ElMessage.success('真实语音已完成 ASR 转写');
} catch {
caseId.value = '';
apiMode.value = 'error';
apiFileName.value = file.name;
ElMessage.error('语音转写失败,请检查 ASR 模型配置或音频格式');
}
}
async function organizeCase() {
if (!audioUploaded.value || !caseId.value) return;
if (apiMode.value === 'api') {
try {
const { data } = await organizeKnowledgeCase({ caseId: caseId.value });
apiSummary.value = data.summary;
apiTags.value = data.tags;
organized.value = true;
ElMessage.success(data.source === 'real-llm' ? '结构化案例稿已由真实模型返回' : '结构化案例稿已按真实转写生成');
return;
} catch {
apiMode.value = 'error';
apiSummary.value = [];
apiTags.value = [];
ElMessage.error('后端案例整理 API 不可用');
}
}
}
function submitReview() {
if (!organized.value) {
ElMessage.warning('请先完成 AI 整理');
return;
}
reviewed.value = true;
ElMessage.success('案例稿已送审');
}
async function storeCase() {
if (!organized.value) {
ElMessage.warning('请先完成 AI 整理');
return;
}
reviewed.value = true;
stored.value = true;
try {
const { data } = await curateKnowledgeCase({
caseId: caseId.value,
criteria: '可复用培训案例',
limit: 1
});
apiRecord.value = data.records?.[0] ?? null;
apiSampleHint.value = data.sampleHint;
if (apiRecord.value && !records.value.some((item) => item.title === apiRecord.value?.title)) {
records.value.unshift(apiRecord.value);
}
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
} catch {
apiMode.value = 'error';
apiRecord.value = null;
apiSampleHint.value = '';
ElMessage.error('后端案例入库 API 不可用');
}
}
</script>
<style lang="scss" scoped>
.cases-page {
min-height: calc(100vh - 84px);
padding: 22px;
background: #f5f7fb;
color: #162033;
}
.hidden-input {
display: none;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
h1 {
margin: 0 0 8px;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #657184;
font-size: 14px;
}
}
.head-actions,
.action-row,
.tag-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.case-grid {
display: grid;
grid-template-columns: minmax(300px, 0.9fr) minmax(420px, 1.25fr) minmax(300px, 0.85fr);
gap: 12px;
margin-bottom: 12px;
}
.panel {
border: 1px solid #e4e9f2;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 24px rgba(21, 30, 45, 0.04);
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: #4971b6;
h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
}
.upload-box,
.transcript-card,
.summary-item,
.pipeline-item,
.sample-card {
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f9fbfe;
}
.upload-box {
display: grid;
min-height: 150px;
place-items: center;
text-align: center;
color: #657184;
cursor: pointer;
.el-icon {
color: #d70f20;
font-size: 38px;
}
strong,
span {
display: block;
}
}
.transcript-card,
.sample-card {
margin-top: 12px;
padding: 12px;
span {
color: #7a8597;
font-size: 12px;
}
p {
margin: 6px 0 0;
color: #344255;
line-height: 1.65;
}
}
.empty-state {
margin-top: 12px;
border: 1px dashed #d7dee9;
border-radius: 8px;
background: #fbfcff;
color: #7a8597;
line-height: 1.6;
padding: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-item {
min-height: 112px;
padding: 12px;
p {
margin: 6px 0 0;
color: #344255;
line-height: 1.6;
}
}
.tag-row,
.action-row {
justify-content: flex-end;
margin-top: 12px;
}
.pipeline-list {
display: grid;
gap: 8px;
}
.pipeline-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 10px;
padding: 12px;
span {
font-weight: 700;
}
strong {
color: #0f7be8;
}
small {
grid-column: 1 / -1;
color: #657184;
}
&.done {
border-color: #cdeeda;
background: #f2fbf6;
strong {
color: #15844a;
}
}
&.active {
border-color: #ffd6d9;
background: #fff6f6;
strong {
color: #d70f20;
}
}
}
.sample-card {
background: #fff8ed;
&.active {
border-color: #ffd7a6;
}
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
line-height: 24px;
font-weight: 700;
}
.pill-success {
color: #15844a;
background: #e5f8ee;
}
.pill-warning {
color: #c46b00;
background: #fff2dc;
}
.pill-primary {
color: #1467bd;
background: #e7f2ff;
}
.record-panel {
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.record-table {
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: hidden;
}
@media (max-width: 1360px) {
.case-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.cases-page {
padding: 14px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>