789 lines
25 KiB
Vue
789 lines
25 KiB
Vue
<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>
|