chore: initialize wygj monorepo
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user