feat: 完善人工关注与内容合规配置

This commit is contained in:
2026-08-24 17:49:56 +08:00
parent 6ffdd2c259
commit 910e67bd89
53 changed files with 1808 additions and 49 deletions

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>();
const loading = ref(false);
const saving = ref(false);
const previewLoading = ref(false);
const previewMessages = ref<AttentionPreviewMessage[]>([]);
const selectedMessages = ref<AttentionPreviewMessage[]>([]);
const previewResults = ref<AttentionPreviewResult[]>([]);
const keyword = ref("");
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const terms = reactive({ urgent: "", important: "", normal: "" });
const config = reactive<AttentionConfig>({
enabled: true,
keywordEnabled: true,
aiEnabled: false,
knowledgeMissingEnabled: true,
urgentTerms: [],
importantTerms: [],
normalTerms: [],
promptTemplate: "",
recognitionItems: [],
});
const canRunPreview = computed(() => props.canPreview && selectedMessages.value.length > 0 && !previewLoading.value);
onMounted(async () => {
if (props.canConfig || props.canPreview) await loadConfig();
if (props.canPreview) await loadPreviewMessages();
});
async function loadConfig() {
loading.value = true;
try {
assignConfig(props.canConfig ? await api.attentionConfig() : await api.attentionPreviewConfig());
} finally {
loading.value = false;
}
}
function assignConfig(value: AttentionConfig) {
Object.assign(config, value);
terms.urgent = value.urgentTerms.join("\n");
terms.important = value.importantTerms.join("\n");
terms.normal = value.normalTerms.join("\n");
}
function currentConfig(): AttentionConfig {
return {
...config,
urgentTerms: splitTerms(terms.urgent),
importantTerms: splitTerms(terms.important),
normalTerms: splitTerms(terms.normal),
};
}
function splitTerms(value: string) {
return [...new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))];
}
function addRecognitionItem() {
if (config.recognitionItems.length >= 30) {
ElMessage.warning("可识别项最多配置 30 个");
return;
}
config.recognitionItems.push({ name: "", description: "", priority: "normal", enabled: true });
}
function removeRecognitionItem(index: number) {
config.recognitionItems.splice(index, 1);
}
async function saveConfig() {
saving.value = true;
try {
assignConfig(await api.saveAttentionConfig(currentConfig()));
ElMessage.success("人工关注筛选规则已保存,后续新问答立即生效");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
} finally {
saving.value = false;
}
}
async function loadPreviewMessages(page = 1, pageSize = pager.pageSize) {
previewLoading.value = true;
try {
const result = await api.attentionPreviewMessages({ keyword: keyword.value.trim(), page, pageSize });
previewMessages.value = result.items;
selectedMessages.value = [];
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
} finally {
previewLoading.value = false;
}
}
function onSelectionChange(rows: AttentionPreviewMessage[]) {
if (rows.length > 20) {
ElMessage.warning("单次最多预览 20 条历史消息");
selectedMessages.value = rows.slice(0, 20);
return;
}
selectedMessages.value = rows;
}
async function runPreview() {
if (!canRunPreview.value) return;
previewLoading.value = true;
try {
previewResults.value = await api.previewAttention(
selectedMessages.value.map((item) => item.messageId),
currentConfig(),
);
ElMessage.success(`已完成 ${previewResults.value.length} 条历史消息筛选预览`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "提示词筛选预览失败");
} finally {
previewLoading.value = false;
}
}
function priorityLabel(value: string) {
return ({ urgent: "紧急", important: "重要", normal: "普通" } as Record<string, string>)[value] || "-";
}
function sourceLabel(value: string) {
return ({ keyword: "关键词", knowledge_missing: "知识缺失", ai: "AI 提示词", rules: "未触发", disabled: "总开关关闭" } as Record<string, string>)[value] || value;
}
</script>
<template>
<section v-loading="loading" class="attention-config-workspace">
<el-alert
title="运行顺序:紧急/重要/普通关键词 → 知识缺失 → AI 提示词。确定性规则命中后不再调用模型。"
type="info"
:closable="false"
show-icon
/>
<article v-if="canConfig" class="attention-config-card">
<header><div><h3>自动筛选规则</h3><p>配置只影响保存后的新问答不会自动重跑已有记录</p></div><el-button type="primary" :loading="saving" @click="saveConfig">保存规则</el-button></header>
<div class="attention-switch-grid">
<label><span><strong>启用人工关注自动筛选</strong><small>关闭后不再自动创建新的关注记录</small></span><el-switch v-model="config.enabled" /></label>
<label><span><strong>启用关键词筛选</strong><small>优先执行不产生模型调用费用</small></span><el-switch v-model="config.keywordEnabled" /></label>
<label><span><strong>知识缺失自动关注</strong><small>知识库应答缺少可靠正式知识时触发</small></span><el-switch v-model="config.knowledgeMissingEnabled" /></label>
<label><span><strong>启用 AI 提示词筛选</strong><small>仅在前面规则未命中时异步执行不会拖慢用户收到回答</small></span><el-switch v-model="config.aiEnabled" /></label>
</div>
<div class="attention-term-grid">
<label><strong>紧急关键词</strong><small>每行一个命中后标记为紧急</small><el-input v-model="terms.urgent" type="textarea" :rows="6" /></label>
<label><strong>重要关键词</strong><small>每行一个命中后标记为重要</small><el-input v-model="terms.important" type="textarea" :rows="6" /></label>
<label><strong>普通关键词</strong><small>每行一个命中后标记为普通</small><el-input v-model="terms.normal" type="textarea" :rows="6" /></label>
</div>
<section class="recognition-item-editor">
<header><span><strong>AI 可识别项</strong><small>语义筛选只会从已启用项目中选择可按业务需要继续增加</small></span><el-button @click="addRecognitionItem">新增可识别项</el-button></header>
<el-table :data="config.recognitionItems" border>
<el-table-column label="启用" width="72"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="项目名称" width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="50" placeholder="例如:疑似投诉" /></template></el-table-column>
<el-table-column label="识别说明" min-width="360"><template #default="{ row }"><el-input v-model="row.description" maxlength="500" placeholder="说明什么情况下算命中,尽量写清边界" /></template></el-table-column>
<el-table-column label="优先级" width="130"><template #default="{ row }"><el-select v-model="row.priority"><el-option label="紧急" value="urgent" /><el-option label="重要" value="important" /><el-option label="普通" value="normal" /></el-select></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="removeRecognitionItem($index)">删除</el-button></template></el-table-column>
</el-table>
</section>
<label class="attention-prompt-editor">
<span><strong>AI 筛选提示词</strong><small>系统会自动追加历史问答数据安全边界和固定 JSON 输出格式无需在提示词中写变量</small></span>
<el-input v-model="config.promptTemplate" type="textarea" :rows="12" maxlength="8000" show-word-limit />
</label>
</article>
<article v-if="canPreview" class="attention-config-card">
<header><div><h3>历史消息筛选预览</h3><p>可使用当前页面尚未保存的关键词和提示词运行不会创建人工关注记录</p></div><el-button type="primary" :loading="previewLoading" :disabled="!canRunPreview" @click="runPreview">预览选中消息</el-button></header>
<div class="attention-preview-search"><el-input v-model="keyword" clearable placeholder="搜索历史问题、用户姓名或手机号" @keyup.enter="loadPreviewMessages(1)" /><el-button @click="loadPreviewMessages(1)">查询</el-button><span>已选 {{ selectedMessages.length }}/20</span></div>
<el-table :data="previewMessages" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="question" label="历史问题" min-width="280" show-overflow-tooltip />
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip />
<el-table-column label="知识缺失" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeMissing ? 'warning' : 'info'">{{ row.knowledgeMissing ? "是" : "否" }}</el-tag></template></el-table-column>
<el-table-column prop="createdAt" label="提问时间" width="180" />
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
</article>
<article v-if="previewResults.length" class="attention-config-card preview-result-card">
<header><div><h3>筛选结果</h3><p>结果仅供调整规则和提示词不会写入关注记录</p></div></header>
<el-table :data="previewResults" stripe>
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.needsAttention ? 'danger' : 'success'">{{ row.needsAttention ? "需关注" : "不关注" }}</el-tag></template></el-table-column>
<el-table-column label="优先级" width="90"><template #default="{ row }">{{ priorityLabel(row.priority) }}</template></el-table-column>
<el-table-column label="来源" width="110"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column prop="matchedItem" label="命中项目" width="140" />
<el-table-column prop="question" label="历史问题" min-width="220" show-overflow-tooltip />
<el-table-column prop="reason" label="判断理由" min-width="220" />
<el-table-column prop="summary" label="问题摘要" min-width="220" />
<el-table-column label="详情" width="100"><template #default="{ row }"><el-popover placement="left" :width="560" trigger="click"><template #reference><el-button link type="primary">提示词/原始输出</el-button></template><div class="attention-preview-detail"><strong>最终提示词</strong><pre>{{ row.renderedPrompt || '关键词规则命中,未调用 AI' }}</pre><strong>模型原始输出</strong><pre>{{ row.rawOutput || '无' }}</pre></div></el-popover></template></el-table-column>
</el-table>
</article>
</section>
</template>
<style scoped>
.attention-config-workspace { display: grid; gap: 18px; }
.attention-config-card { padding: 20px; border: 1px solid #e3e9e7; border-radius: 14px; background: #fff; }
.attention-config-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.attention-config-card h3 { margin: 0; color: #1f2d29; }
.attention-config-card p { margin: 6px 0 0; color: #73817c; font-size: 13px; }
.attention-switch-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.attention-switch-grid label { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid #e6ece9; border-radius: 10px; }
.attention-switch-grid span, .attention-prompt-editor > span { display: grid; gap: 5px; }
.attention-switch-grid small, .attention-term-grid small, .attention-prompt-editor small { color: #82908b; font-weight: 400; }
.attention-term-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
.attention-term-grid label { display: grid; gap: 7px; }
.attention-prompt-editor { display: grid; gap: 10px; margin-top: 18px; }
.recognition-item-editor { display: grid; gap: 10px; margin-top: 18px; }
.recognition-item-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.recognition-item-editor > header span { display: grid; gap: 5px; }
.recognition-item-editor small { color: #82908b; font-weight: 400; }
.attention-preview-search { display: grid; grid-template-columns: minmax(260px, 520px) auto 1fr; align-items: center; gap: 10px; margin-bottom: 14px; }
.attention-preview-search span { color: #73817c; font-size: 13px; }
.attention-preview-detail { display: grid; gap: 8px; }
.attention-preview-detail pre { max-height: 300px; margin: 0 0 10px; padding: 10px; overflow: auto; border-radius: 8px; background: #f6f8f7; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 980px) { .attention-switch-grid, .attention-term-grid { grid-template-columns: 1fr; } }
</style>