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>
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
<template>
<div class="sop-page">
<section class="page-head">
<div>
<h1>SOP 知识库</h1>
<p>住宅类 SOP 检索与 RAG 问答,回答必须引用可追溯 SOP 内容。</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-upload
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
:auto-upload="false"
:show-file-list="false"
:on-change="handleDocSelected"
>
<el-button :loading="uploading">
<el-icon><Upload /></el-icon>
上传文档
</el-button>
</el-upload>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" @click="generateTraining">生成训练题</el-button>
</div>
</section>
<section class="sop-grid">
<article class="panel category-panel">
<div class="panel-title">
<el-icon><Reading /></el-icon>
<h2>SOP 分类</h2>
</div>
<div class="category-list">
<button v-for="item in categories" :key="item" :class="{ active: item === selectedCategory }" @click="selectCategory(item)">
{{ item }}
</button>
</div>
</article>
<article class="panel qa-panel">
<div class="panel-title">
<el-icon><DocumentChecked /></el-icon>
<h2>AI 问答</h2>
</div>
<div class="search-row">
<el-input v-model="queryText" placeholder="输入员工问题" />
<el-button type="danger" @click="runSearch">检索</el-button>
</div>
<div v-if="searched" class="answer-card">
<strong>AI 回答</strong>
<p>{{ answerText }}</p>
<span>引用:{{ referenceText }}</span>
</div>
<div v-else class="empty-state">点击检索后展示命中文档、原文片段和带引用的 AI 回答。</div>
<div v-if="searched" class="doc-list">
<div v-for="doc in docs" :key="doc.title" class="doc-item">
<div>
<strong>{{ doc.title }}</strong>
<span>{{ doc.desc }}</span>
</div>
<span class="status-pill" :class="doc.type">{{ doc.hit }}</span>
</div>
</div>
<div v-if="trainingGenerated" class="training-card">
<strong>训练题已生成</strong>
<p v-for="item in training" :key="item">{{ item }}</p>
</div>
</article>
<article class="panel detail-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>SOP 原文片段</h2>
</div>
<div v-if="searched" class="snippet-list">
<div v-for="snippet in snippets" :key="snippet.title" class="snippet-item">
<strong>{{ snippet.title }}</strong>
<p>{{ snippet.text }}</p>
</div>
</div>
<div v-else class="empty-state">待检索后展示 SOP 原文片段,便于验收回答来源。</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="category" label="分类" width="140" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="status" label="状态" width="120" />
<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="KnowledgeSop" lang="ts">
import { DocumentChecked, Reading, Tickets, Upload } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import type { UploadFile } from 'element-plus';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
const router = useRouter();
const categories = ['投诉处理 SOP', '催缴沟通 SOP', '报修跟进 SOP', '交付接待 SOP', '巡检记录 SOP'] as const;
type Category = (typeof categories)[number];
const sopSeeds: Record<
Category,
{
question: string;
answer: string;
reference: string;
docs: { title: string; desc: string; hit: string; type: string }[];
snippets: { title: string; text: string }[];
training: string[];
}
> = {
'投诉处理 SOP': {
question: '业主投诉漏水,管家第一步怎么处理?',
answer: '先安抚业主情绪并确认影响范围;同步记录房号、报修时间、现场照片;再创建工单并明确首次反馈时限。',
reference: '投诉处理 SOP v1.0 第 2、3、5 条',
docs: [
{ title: '投诉处理 SOP v1.0', desc: '安抚、记录、派单、反馈时限', hit: '引用 3 段', type: 'pill-success' },
{ title: '报修跟进 SOP v1.0', desc: '报修流转、工程派工、闭环确认', hit: '引用 2 段', type: 'pill-primary' },
{ title: '高情绪沟通话术', desc: '先承接情绪,再进入事实确认', hit: '建议补充', type: 'pill-warning' }
],
snippets: [
{ title: '第 2 条:先安抚', text: '接到投诉后先表达理解,避免争辩责任归属。' },
{ title: '第 3 条:确认事实', text: '记录房号、联系人、发生时间、现场照片和影响范围。' },
{ title: '第 5 条:明确反馈', text: '创建工单后告知首次反馈时限和责任人。' }
],
training: ['题 1:漏水投诉首响时限如何表述?', '题 2:哪些事实必须在工单中记录?']
},
'催缴沟通 SOP': {
question: '业主说服务不到位所以拒缴,客服怎么回应?',
answer: '先承接服务不满并记录具体事项,再区分服务整改与费用义务;给出整改跟进人和缴费安排,避免直接争辩。',
reference: '催缴沟通 SOP v1.0 第 1、4、6 条',
docs: [
{ title: '催缴沟通 SOP v1.0', desc: '服务异议、费用说明、承诺跟进', hit: '引用 3 段', type: 'pill-success' },
{ title: '客户异议话术库', desc: '先确认诉求,再进入费用说明', hit: '引用 1 段', type: 'pill-primary' }
],
snippets: [
{ title: '第 1 条:先听诉求', text: '业主表达不满时,先记录具体服务问题,不以欠费事实压过诉求。' },
{ title: '第 4 条:费用解释', text: '说明费用构成、账期和缴费渠道,避免承诺超出权限的减免。' },
{ title: '第 6 条:跟进闭环', text: '对服务问题设置跟进责任人,并在约定时间内反馈处理进度。' }
],
training: ['题 1:服务异议与催缴说明如何分开表达?', '题 2:哪些减免承诺不能现场答复?']
},
'报修跟进 SOP': {
question: '报修超过一天还没处理,管家怎么跟进?',
answer: '先查询工单状态和责任人,再向工程组确认下一次到场时间;对业主同步原因、节点和复核方式。',
reference: '报修跟进 SOP v1.0 第 2、5、7 条',
docs: [
{ title: '报修跟进 SOP v1.0', desc: '派工、到场、复核、关闭', hit: '引用 3 段', type: 'pill-success' },
{ title: '工程派单规则', desc: '急修与普通维修分级', hit: '引用 2 段', type: 'pill-primary' }
],
snippets: [
{ title: '第 2 条:查工单', text: '跟进前必须确认工单编号、责任班组、当前状态和超时原因。' },
{ title: '第 5 条:给节点', text: '向业主说明下一次反馈或到场时间,避免只说尽快。' },
{ title: '第 7 条:复核关闭', text: '处理完成后需向业主确认效果,再关闭工单。' }
],
training: ['题 1:报修超时必须先查哪些字段?', '题 2:如何避免用“尽快处理”替代明确节点?']
},
'交付接待 SOP': {
question: '新业主交付当天资料不齐,接待岗怎么处理?',
answer: '先核验身份和已有资料,列出缺失项并给补交方式;不影响可办理事项先行办理,明确后续补齐节点。',
reference: '交付接待 SOP v1.0 第 1、3、6 条',
docs: [{ title: '交付接待 SOP v1.0', desc: '身份核验、资料补齐、流程引导', hit: '引用 3 段', type: 'pill-success' }],
snippets: [
{ title: '第 1 条:身份核验', text: '接待前核验业主身份、房号和交付批次。' },
{ title: '第 3 条:缺项清单', text: '资料不齐时提供缺项清单和补交渠道。' },
{ title: '第 6 条:后续节点', text: '对暂无法完成事项给出下一次办理节点。' }
],
training: ['题 1:资料不齐时如何不阻塞可办理事项?', '题 2:缺项清单应包含哪些内容?']
},
'巡检记录 SOP': {
question: '巡检发现消防通道堆物,记录要包含什么?',
answer: '记录位置、照片、发现时间、风险等级和责任人;同步整改时限,并在复查后补充关闭证据。',
reference: '巡检记录 SOP v1.0 第 2、4、8 条',
docs: [{ title: '巡检记录 SOP v1.0', desc: '发现、记录、派单、复查', hit: '引用 3 段', type: 'pill-success' }],
snippets: [
{ title: '第 2 条:定位证据', text: '巡检问题需记录楼栋、楼层、点位和现场照片。' },
{ title: '第 4 条:责任与时限', text: '派单时注明责任人、整改要求和完成时限。' },
{ title: '第 8 条:复查关闭', text: '复查通过后上传关闭照片,并保留处理记录。' }
],
training: ['题 1:消防通道堆物属于哪类巡检风险?', '题 2:复查关闭必须补哪些证据?']
}
};
const selectedCategory = ref<Category>('投诉处理 SOP');
const queryText = ref(sopSeeds[selectedCategory.value].question);
const searched = ref(false);
const trainingGenerated = ref(false);
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiResult = ref<SopSearchResponse | null>(null);
const uploading = ref(false);
const uploadedRecords = ref<SopRecord[]>([]);
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
const referenceText = computed(() => apiResult.value?.reference ?? activeSeed.value.reference);
const docs = computed(() => apiResult.value?.docs ?? activeSeed.value.docs);
const snippets = computed(() => apiResult.value?.snippets ?? activeSeed.value.snippets);
const training = computed(() => apiResult.value?.training ?? activeSeed.value.training);
const apiPill = computed(() => {
if (apiMode.value === 'api') return { label: '后端 API', type: 'pill-primary' };
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
return { label: '本地 seed 演示', type: 'pill-primary' };
});
const flowStatus = computed(() => {
if (trainingGenerated.value) return { label: '已完成闭环', type: 'pill-success' };
if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' };
return { label: '可引用 SOP 内容', type: 'pill-success' };
});
const seedRecords = [
{
title: '投诉处理 SOP v1.0',
category: '投诉处理',
version: 'v1.0',
status: '已入库',
owner: '品质组',
summary: '业主投诉的接待、记录、派单、反馈和闭环确认。'
},
{
title: '催缴沟通 SOP v1.0',
category: '催缴沟通',
version: 'v1.0',
status: '已入库',
owner: '财务组',
summary: '缴费提醒、费用争议回应、服务问题承接。'
},
{
title: '报修跟进 SOP v1.0',
category: '报修跟进',
version: 'v1.0',
status: '待审核',
owner: '工程组',
summary: '报修接单、派工、现场反馈、复核关闭。'
}
];
const records = computed(() => [...uploadedRecords.value, ...seedRecords]);
function selectCategory(item: Category) {
selectedCategory.value = item;
queryText.value = sopSeeds[item].question;
searched.value = false;
trainingGenerated.value = false;
apiMode.value = 'seed';
apiResult.value = null;
}
async function runSearch() {
trainingGenerated.value = false;
try {
const { data } = await searchKnowledgeSop({
queryText: queryText.value,
category: selectedCategory.value,
limit: 3
});
apiResult.value = data;
apiMode.value = 'api';
searched.value = true;
ElMessage.success(`已通过后端 API 命中 ${data.snippets.length} 段 SOP 原文`);
} catch {
apiResult.value = null;
apiMode.value = 'fallback';
searched.value = true;
ElMessage.warning('后端 SOP 检索 API 不可用,已回退本地 seed 知识库');
}
}
async function handleDocSelected(uploadFile: UploadFile) {
const raw = uploadFile.raw;
if (!raw) return;
if (!/\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx)$/i.test(raw.name)) {
ElMessage.warning('仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx 文件');
return;
}
if (raw.size > 100 * 1024 * 1024) {
ElMessage.warning('文件不能超过 100MB');
return;
}
uploading.value = true;
try {
const form = new FormData();
form.append('file', raw);
form.append('category', selectedCategory.value);
const { data } = await uploadKnowledgeDoc(form);
uploadedRecords.value.unshift({
title: data.fileName,
category: data.category.replace(' SOP', ''),
version: '上传',
status: '已入库',
owner: 'MinIO',
summary: `OSS #${data.ossId},已解析 ${data.fragments} 个片段`
});
queryText.value = data.snippets[0]?.text.slice(0, 40) || queryText.value;
ElMessage.success(`已入库 ${data.fragments} 个片段`);
await runSearch();
} finally {
uploading.value = false;
}
}
async function generateTraining() {
if (!searched.value) {
await runSearch();
}
trainingGenerated.value = true;
ElMessage.success('已基于引用片段生成训练题');
}
</script>
<style lang="scss" scoped>
.sop-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 {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sop-grid {
display: grid;
grid-template-columns: 240px minmax(460px, 1.2fr) minmax(320px, 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;
}
}
.category-list {
display: grid;
gap: 8px;
button {
min-height: 38px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #fff;
color: #405066;
text-align: left;
padding: 0 12px;
font-weight: 600;
cursor: pointer;
&.active {
color: #c80f1f;
background: #fff0f1;
border-color: #ffd6d9;
}
}
}
.search-row {
display: grid;
grid-template-columns: 1fr 90px;
gap: 10px;
margin-bottom: 12px;
}
.answer-card,
.doc-item,
.snippet-item,
.empty-state,
.training-card {
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f9fbfe;
}
.answer-card {
padding: 14px;
border-left: 4px solid #d70f20;
background: #fff6f6;
p {
margin: 8px 0;
line-height: 1.65;
}
span {
color: #657184;
font-size: 13px;
}
}
.empty-state,
.training-card {
padding: 14px;
color: #657184;
line-height: 1.6;
}
.training-card {
margin-top: 12px;
border-color: #cdeeda;
background: #f2fbf6;
strong {
color: #15844a;
}
p {
margin: 8px 0 0;
color: #344255;
}
}
.doc-list,
.snippet-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.doc-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px;
strong,
span {
display: block;
}
div > span {
margin-top: 4px;
color: #657184;
font-size: 12px;
}
}
.snippet-item {
padding: 12px;
p {
margin: 6px 0 0;
color: #46566e;
line-height: 1.6;
}
}
.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;
white-space: nowrap;
}
.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) {
.sop-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.sop-page {
padding: 14px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
.search-row {
grid-template-columns: 1fr;
}
}
</style>