554 lines
14 KiB
Vue
554 lines
14 KiB
Vue
<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>
|