chore: initialize wygj monorepo

This commit is contained in:
2026-07-03 01:25:10 +08:00
commit 62ffcb6e99
1248 changed files with 137340 additions and 0 deletions
+586
View File
@@ -0,0 +1,586 @@
<template>
<div class="cases-page">
<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="selectSample">选择样例录音</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="selectSample">
<el-icon><FolderChecked /></el-icon>
<strong>{{ sampleSelected ? displayFileName : '拖入语音 / 选择样例录音' }}</strong>
<span>{{ sampleSelected ? '已加载样例录音并完成 ASR 转写。' : 'Demo 暂用样例转写文本,ASR 接入后替换。' }}</span>
</div>
<div v-if="sampleSelected" 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" @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: sampleVisible }">
<strong>视频样片</strong>
<p>
{{
sampleVisible ? '已命中车库积水投诉处理样片,可用于演示案例沉淀后的培训复用。' : '案例转视频本期只展示预渲染样片,不现场调用视频生成。'
}}
</p>
<el-button :icon="VideoCamera" @click="viewSample">查看样片</el-button>
</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, VideoCamera } 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 seedCase = {
fileName: '车库积水投诉_20260702.wav',
transcript: '业主反映地下车库长期积水,担心车辆受损。管家先确认车位号与积水时间,安排工程人员排查排水沟,并在业主群同步处理进度。',
tags: [
{ text: '投诉处理', type: 'pill-success' },
{ text: '车库', type: 'pill-primary' },
{ text: '高情绪', type: 'pill-warning' }
],
summary: [
{ label: '背景', text: '雨后车库积水,业主担心车辆与安全问题。' },
{ label: '处理', text: '确认位置、派工排查、群内同步、次日复盘。' },
{ label: '结果', text: '排水沟堵塞已清理,业主确认问题解决。' },
{ label: '亮点', text: '响应快、过程透明、主动同步进度。' }
]
} as const;
const sampleSelected = ref(false);
const organized = ref(false);
const reviewed = ref(false);
const stored = ref(false);
const sampleVisible = ref(false);
const caseId = ref('');
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiFileName = ref('');
const apiTranscript = ref('');
const apiSummary = ref<CaseSummaryItem[] | null>(null);
const apiTags = ref<CaseTag[] | null>(null);
const apiRecord = ref<CaseRecord | null>(null);
const apiSampleHint = ref('');
const displayFileName = computed(() => apiFileName.value || seedCase.fileName);
const displayTranscript = computed(() => apiTranscript.value || seedCase.transcript);
const displaySummary = computed(() => apiSummary.value ?? seedCase.summary);
const displayTags = computed(() => apiTags.value ?? seedCase.tags);
const summary = computed(() => (organized.value ? displaySummary.value : []));
const apiPill = computed(() => {
if (apiMode.value === 'api') return { label: '后端 API seed', type: 'pill-primary' };
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
return { label: '本地 seed 演示', 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 (sampleSelected.value) return { label: '已完成 ASR', type: 'pill-primary' };
return { label: '样片兜底', type: 'pill-warning' };
});
const pipeline = computed(() => [
{
name: '语音上传',
count: sampleSelected.value ? '1 条' : '0 条',
note: sampleSelected.value ? '样例录音已选择' : '等待上传',
state: sampleSelected.value ? 'done' : 'todo'
},
{
name: 'ASR 转写',
count: sampleSelected.value ? '1 条' : '0 条',
note: sampleSelected.value ? '本地 seed 转写完成' : '等待语音',
state: sampleSelected.value ? 'done' : 'todo'
},
{
name: 'AI 整理',
count: organized.value ? '1 条' : '0 条',
note: organized.value ? '结构化案例稿已生成' : '等待整理',
state: organized.value ? 'done' : sampleSelected.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: sampleVisible.value ? '1 条' : '5 条',
note: sampleVisible.value ? '已打开预渲染样片' : '演示兜底',
state: sampleVisible.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 seedRecord = {
title: '车库积水投诉处理 · seed入库',
type: '投诉处理',
status: '已入库',
time: '07-02 10:50',
owner: '培训组',
summary: 'ASR 转写后自动整理为案例稿,完成送审与入库,可用于三角色对练复用。'
};
const records = ref([...baseRecords]);
async function selectSample() {
sampleSelected.value = true;
organized.value = false;
reviewed.value = false;
stored.value = false;
sampleVisible.value = false;
apiSummary.value = null;
apiRecord.value = null;
apiSampleHint.value = '';
try {
const { data } = await uploadCaseAudio({
fileName: seedCase.fileName,
projectExtOrgId: 'seed-project'
});
caseId.value = data.caseId;
apiMode.value = 'api';
apiFileName.value = data.fileName;
apiTranscript.value = data.transcript;
apiTags.value = data.tags;
ElMessage.success('已通过后端 API 加载样例录音并完成 ASR 转写');
} catch {
caseId.value = 'case-garage-water';
apiMode.value = 'fallback';
apiFileName.value = '';
apiTranscript.value = '';
apiTags.value = null;
ElMessage.warning('后端案例上传 API 不可用,已回退本地 seed 转写');
}
}
async function organizeCase() {
if (!sampleSelected.value) {
await selectSample();
}
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('结构化案例稿已由后端 API 返回');
return;
} catch {
apiMode.value = 'fallback';
apiSummary.value = null;
apiTags.value = null;
ElMessage.warning('后端案例整理 API 不可用,已回退本地 seed 案例稿');
}
}
organized.value = true;
ElMessage.success('AI 已生成结构化案例稿');
}
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;
if (apiMode.value === 'api') {
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 入库,可用于训练复用');
return;
} catch {
apiMode.value = 'fallback';
apiRecord.value = null;
apiSampleHint.value = '';
ElMessage.warning('后端案例入库 API 不可用,已回退本地 seed 入库');
}
}
if (!records.value.some((item) => item.title === seedRecord.title)) {
records.value.unshift(seedRecord);
}
ElMessage.success('案例已入库,可用于训练复用');
}
function viewSample() {
sampleVisible.value = true;
ElMessage.info(apiSampleHint.value || '已打开预渲染样片兜底');
}
</script>
<style lang="scss" scoped>
.cases-page {
min-height: calc(100vh - 84px);
padding: 22px;
background: #f5f7fb;
color: #162033;
}
.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>