Files
prop-ai-hr/frontend/src/views/knowledge/sop.vue
T

789 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-select v-model="uploadSpaceCodes" multiple collapse-tags placeholder="选择上传空间" style="width: 230px">
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
</el-select>
<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 class="review-row">
<small>{{ reviewMessage }}</small>
<div>
<el-button size="small" type="success" :loading="reviewSaving" :disabled="!canReview" @click="markReview(true)">可用</el-button>
<el-button size="small" :loading="reviewSaving" :disabled="!canReview" @click="markReview(false)">不可用</el-button>
</div>
</div>
</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 pending-review-panel">
<div class="panel-title with-actions">
<div class="panel-title-main">
<el-icon><DocumentChecked /></el-icon>
<div>
<h2>答案待复核</h2>
<p>员工反馈为「没解决」的答案会进入这里,复核后从待办队列移除。</p>
</div>
</div>
<div class="review-toolbar">
<el-select v-model="pendingReviewStatus" size="small" @change="loadPendingReviews">
<el-option label="待复核" value="待复核" />
<el-option label="已复核" value="已复核" />
</el-select>
<el-button size="small" :loading="pendingReviewLoading" @click="loadPendingReviews">刷新</el-button>
</div>
</div>
<el-table v-loading="pendingReviewLoading" :data="pendingReviews" size="small" class="record-table">
<el-table-column prop="queryText" label="员工问题" min-width="260" />
<el-table-column prop="verdict" label="反馈" width="90">
<template #default="{ row }">
<span class="review-verdict negative">{{ row.verdict === 'down' ? '没解决' : row.verdict }}</span>
</template>
</el-table-column>
<el-table-column prop="feedbackReasonCodes" label="未解决原因" min-width="180" />
<el-table-column prop="feedbackComment" label="补充说明" min-width="180" show-overflow-tooltip />
<el-table-column prop="position" label="岗位" width="120" />
<el-table-column prop="createTime" label="提交时间" width="170" />
<el-table-column prop="reviewer" label="复核人" width="120" />
<el-table-column prop="reviewedTime" label="复核时间" width="170" />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.reviewStatus === '待复核'"
link
type="danger"
:loading="reviewingFeedbackId === row.id"
@click="markFeedbackReviewed(row)"
>
标记已复核
</el-button>
<span v-else class="review-done">已复核</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!pendingReviewLoading && !pendingReviews.length" description="暂无待复核答案" />
</section>
<section class="panel record-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>知识库文档</h2>
</div>
<el-alert
v-if="records.some((item) => item.source === 'demo')"
class="record-warning"
type="warning"
:closable="false"
show-icon
title="列表含内置演示记录,不代表这些文档已真实入库。"
/>
<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 label="来源" width="110">
<template #default="{ row }">
<el-tag :type="row.source === 'demo' ? 'warning' : 'success'" size="small">
{{ row.source === 'demo' ? '内置演示' : '已上传' }}
</el-tag>
</template>
</el-table-column>
<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, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
listAnswerFeedback,
reviewAnswerFeedback,
reviewKnowledgeSop,
searchKnowledgeSop,
uploadKnowledgeDoc,
type AnswerFeedbackItem,
type SopRecord,
type SopSearchResponse
} from '@/api/aihr/sop';
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
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 reviewSaving = ref(false);
const reviewStatus = ref('');
const pendingReviewStatus = ref('待复核');
const pendingReviewLoading = ref(false);
const reviewingFeedbackId = ref<number | null>(null);
const pendingReviews = ref<AnswerFeedbackItem[]>([]);
const manageableSpaces = ref<KnowledgeSpace[]>([]);
const uploadSpaceCodes = ref<string[]>([]);
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: '本地知识兜底', 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 canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId));
const reviewMessage = computed(() => {
if (reviewStatus.value) return reviewStatus.value;
if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`;
return '后端检索后可评审';
});
const seedRecords = [
{
title: '投诉处理 SOP v1.0',
category: '投诉处理',
version: 'v1.0',
status: '已入库',
owner: '品质组',
summary: '业主投诉的接待、记录、派单、反馈和闭环确认。',
source: 'demo'
},
{
title: '催缴沟通 SOP v1.0',
category: '催缴沟通',
version: 'v1.0',
status: '已入库',
owner: '财务组',
summary: '缴费提醒、费用争议回应、服务问题承接。',
source: 'demo'
},
{
title: '报修跟进 SOP v1.0',
category: '报修跟进',
version: 'v1.0',
status: '待审核',
owner: '工程组',
summary: '报修接单、派工、现场反馈、复核关闭。',
source: 'demo'
}
];
const records = computed(() => {
if (apiMode.value === 'api') {
return [...uploadedRecords.value, ...(apiResult.value?.records ?? [])];
}
return apiMode.value === 'fallback' ? [...seedRecords] : [...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;
reviewStatus.value = '';
}
async function runSearch() {
trainingGenerated.value = false;
reviewStatus.value = '';
try {
const { data } = await searchKnowledgeSop({
queryText: queryText.value,
category: selectedCategory.value,
spaceCodes: uploadSpaceCodes.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 不可用,已使用本地知识兜底');
}
}
async function markReview(usable: boolean) {
const reviewId = apiResult.value?.reviewId;
if (!reviewId) {
ElMessage.warning('请先完成一次后端 SOP 检索');
return;
}
reviewSaving.value = true;
try {
const { data } = await reviewKnowledgeSop(reviewId, {
usable,
reviewer: '管理端评审',
note: usable ? '回答可用于试点问答' : '回答需补充或修正'
});
reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`;
ElMessage.success('SOP 问答评审已记录');
} catch {
ElMessage.error('SOP 问答评审失败');
} finally {
reviewSaving.value = false;
}
}
async function loadPendingReviews() {
pendingReviewLoading.value = true;
try {
const { data } = await listAnswerFeedback(pendingReviewStatus.value);
pendingReviews.value = data ?? [];
} catch {
pendingReviews.value = [];
ElMessage.error('答案待复核列表加载失败');
} finally {
pendingReviewLoading.value = false;
}
}
async function markFeedbackReviewed(row: AnswerFeedbackItem) {
reviewingFeedbackId.value = row.id;
try {
await reviewAnswerFeedback(row.id);
pendingReviews.value = pendingReviews.value.filter((item) => item.id !== row.id);
ElMessage.success('答案已标记为已复核');
} catch {
ElMessage.error('答案复核失败');
} finally {
reviewingFeedbackId.value = null;
}
}
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;
}
if (uploadSpaceCodes.value.length === 0) {
ElMessage.warning('请先选择至少一个可管理的知识空间');
return;
}
uploading.value = true;
try {
const form = new FormData();
form.append('file', raw);
uploadSpaceCodes.value.forEach((code) => form.append('spaceCodes', code));
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} 个片段`,
source: 'uploaded'
});
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('已基于引用片段生成训练题');
}
onMounted(async () => {
loadPendingReviews();
try {
manageableSpaces.value = ((await listKnowledgeSpaces()).data || []).filter((item) => item.status === 'ACTIVE');
uploadSpaceCodes.value = manageableSpaces.value.slice(0, 1).map((item) => item.code);
} catch {
manageableSpaces.value = [];
}
});
</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;
}
}
.panel-title.with-actions {
justify-content: space-between;
}
.panel-title-main,
.review-toolbar {
display: flex;
align-items: center;
}
.panel-title-main {
gap: 8px;
p {
margin: 4px 0 0;
color: #657184;
font-size: 12px;
font-weight: 400;
}
}
.review-toolbar {
gap: 8px;
}
.pending-review-panel {
margin-top: 12px;
}
.review-verdict,
.review-done {
font-size: 12px;
}
.review-verdict.negative {
color: #c80f1f;
}
.review-done {
color: #15844a;
}
.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;
}
}
.review-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
small {
color: #657184;
font-size: 12px;
}
}
.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>