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
+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>