| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785 |
- <template>
- <div class="ai-page ai-kb">
- <!-- 内容卡:标题 + 摘要 + 搜索 + 操作 + 知识库卡片网格(工具栏合并进卡头,版式对齐研判方案页) -->
- <section class="ai-kb__panel">
- <div class="ai-kb__panel-head">
- <span class="ai-kb__card-title">知识库</span>
- <span class="ai-kb__summary">{{ summary }}</span>
- <label class="ai-kb__search">
- <Icon icon="mdi:magnify" :size="14" />
- <input v-model="keyword" type="text" placeholder="输入知识库名称搜索" />
- </label>
- <span class="ai-kb__spacer"></span>
- <span class="ai-kb__panel-count">共 {{ filteredBases.length }} 个</span>
- <div class="ai-kb__actions">
- <a-button :loading="loading" @click="load">
- <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
- 刷新
- </a-button>
- <a-button type="primary" @click="openCreate">
- <template #icon><Icon icon="mdi:plus" :size="14" /></template>
- 新建知识库
- </a-button>
- </div>
- </div>
- <div class="ai-kb__panel-body">
- <a-spin :spinning="loading">
- <!-- 卡片混合展示(内置在前),卡片头标签区分内置 / 自定义 -->
- <div v-if="filteredBases.length" class="ai-kb__grid">
- <article v-for="kb in filteredBases" :key="kb.id" class="ai-kb__card">
- <div class="ai-kb__card-head">
- <span class="ai-kb__card-icon">
- <Icon icon="mdi:bookshelf" :size="17" />
- </span>
- <div class="ai-kb__card-heading">
- <div class="ai-kb__name-row">
- <span class="ai-kb__name">{{ kb.name }}</span>
- <span class="ai-kb__chip" :class="{ 'is-primary': !kb.builtin }">
- {{ kb.builtin ? '内置' : '自定义' }}
- </span>
- </div>
- <span class="ai-kb__card-sub">{{ kb.builtin ? '系统内置知识库(管理员维护)' : '我的知识库' }}</span>
- </div>
- </div>
- <p class="ai-kb__desc">{{ kb.description || '暂无描述' }}</p>
- <div class="ai-kb__chips">
- <span class="ai-kb__chip">
- <i class="ai-kb__chip-dot" style="background: #0969da"></i>
- 我的文档 {{ kb.myDocCount }} 篇
- </span>
- <span v-if="kb.myPendingCount > 0" class="ai-kb__chip">
- <i class="ai-kb__chip-dot" style="background: #d97706"></i>
- {{ kb.myPendingCount }} 篇待索引
- </span>
- </div>
- <div class="ai-kb__card-ops">
- <!-- 「管理文档」= 主操作:打开我的上传文档弹窗(列表/上传/下载按上传者隔离) -->
- <button class="ai-kb__run" type="button" @click="openDocs(kb)">
- 管理文档
- <Icon icon="mdi:arrow-right" :size="13" />
- </button>
- <span class="ai-kb__card-ops-sp"></span>
- <!-- 查看详情:内置/自定义都可看(只读弹框) -->
- <a-tooltip title="查看详情">
- <button class="ai-kb__iconbtn" type="button" aria-label="查看知识库详情" @click="openDetail(kb)">
- <Icon icon="mdi:eye-outline" :size="15" />
- </button>
- </a-tooltip>
- <!-- 内置只可查看/绑定(编辑/删除入口根本不渲染,后端另有双重校验) -->
- <template v-if="!kb.builtin">
- <a-tooltip title="编辑知识库">
- <button class="ai-kb__iconbtn" type="button" aria-label="编辑知识库" @click="openEdit(kb)">
- <Icon icon="mdi:pencil-outline" :size="15" />
- </button>
- </a-tooltip>
- <a-tooltip title="删除知识库">
- <button class="ai-kb__iconbtn is-danger" type="button" aria-label="删除知识库" @click="onDelete(kb)">
- <Icon icon="mdi:trash-can-outline" :size="15" />
- </button>
- </a-tooltip>
- </template>
- </div>
- </article>
- </div>
- <EmptyState
- v-else-if="!loading"
- :title="bases.length ? '没有匹配的知识库' : '还没有知识库'"
- :description="
- bases.length
- ? '换个知识库名称试试,或清空搜索条件查看全部。'
- : '点击右上角「新建知识库」创建第一个;内置知识库对所有用户可见。'
- "
- :min-height="300"
- description-width="min(100%, 520px)"
- />
- </a-spin>
- </div>
- </section>
- <!-- 新建/编辑:小弹框(名称 + 描述) -->
- <BasicModal
- :open="formOpen"
- :title="editing ? '编辑知识库' : '新建知识库'"
- :width="520"
- :confirm-loading="submitting"
- ok-text="保存"
- cancel-text="取消"
- @ok="submit"
- @cancel="formOpen = false"
- >
- <a-form layout="vertical">
- <a-form-item label="知识库名称" required>
- <a-input v-model:value="form.name" placeholder="如:法规与量刑指引" :maxlength="100" />
- </a-form-item>
- <a-form-item label="描述">
- <a-textarea
- v-model:value="form.description"
- :rows="3"
- :maxlength="500"
- placeholder="一句话说明这个知识库收录什么内容(可选)"
- />
- </a-form-item>
- </a-form>
- </BasicModal>
- <!-- 知识库详情(只读):卡片眼睛图标打开 -->
- <BasicModal
- :open="detailOpen"
- :title="detail ? `知识库详情:${detail.name}` : '知识库详情'"
- :width="640"
- :show-ok-btn="false"
- cancel-text="关闭"
- @cancel="detailOpen = false"
- >
- <div v-if="detail" class="akb-detail">
- <div class="akb-detail__head">
- <span class="akb-detail__badge" :class="detail.builtin ? 'is-builtin' : 'is-custom'">
- {{ detail.builtin ? '内置' : '自定义' }}
- </span>
- <span class="akb-detail__name">{{ detail.name }}</span>
- </div>
- <p class="akb-detail__desc">{{ detail.description || '暂无描述' }}</p>
- <section class="akb-detail__panel">
- <div class="akb-detail__label">使用方式</div>
- <p class="akb-detail__text">
- 在「研判方案」新建/编辑时绑定本知识库,对话中模型即可用 search_knowledge
- 工具按需检索库内知识(含来源文档名)。文档按上传者隔离:管理文档只显示自己上传的文件。
- </p>
- </section>
- <section class="akb-detail__panel">
- <div class="akb-detail__label">我的文档</div>
- <p class="akb-detail__text">
- {{ detail.myDocCount }} 篇<template v-if="detail.myPendingCount > 0">
- ,其中 {{ detail.myPendingCount }} 篇待索引</template
- >。{{ detail.builtin ? '内置库的文档由管理员维护,普通用户清单不外露(检索可用)。' : '' }}
- </p>
- </section>
- </div>
- </BasicModal>
- <!-- 我的上传文档(列表/上传/下载按上传者隔离) -->
- <KnowledgeDocModal v-model:open="docsOpen" :kb="docsKb" />
- </div>
- </template>
- <script lang="ts" setup name="AiKnowledgeIndex">
- /**
- * 知识库管理页:卡片混合列表,卡片头标签区分「内置 / 自定义」—— 版式对齐研判方案页(aiExpert)。
- *
- * 对接 KnowledgeBaseController(/knowledge-bases/**,Result 包装):列表本身就是「内置 + 自己的」
- * (后端过滤,内置在前),页面不再分组,只靠卡片角标区分来源。
- *
- * 操作语义:
- * - 管理文档(主操作):打开「我的上传文档」弹窗 —— 文档按上传者隔离,
- * 内置库普通用户打开也是空列表(管理员维护的文档清单不外露,检索不受影响);
- * - 编辑/删除:仅自建卡片出现,内置的按钮根本不渲染(后端另有双重校验)。
- */
- import { computed, onMounted, reactive, ref } from 'vue';
- import { Button, Form, FormItem, Input, Modal, Spin, Textarea, Tooltip } from 'ant-design-vue';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import { BasicModal } from '@/core/components/Modal';
- import { EmptyState } from '@/core/components/EmptyState';
- import * as knowledgeApi from '../../api/knowledgeApi';
- import type { KnowledgeBase, SaveKnowledgeBaseParams } from '../../api/types';
- import KnowledgeDocModal from '../../components/KnowledgeDocModal.vue';
- const AButton = Button;
- const ASpin = Spin;
- const ATooltip = Tooltip;
- const AForm = Form;
- const AFormItem = FormItem;
- const AInput = Input;
- const ATextarea = Textarea;
- const { showMessage } = useMessage();
- const bases = ref<KnowledgeBase[]>([]);
- const loading = ref(false);
- /** 新建/编辑(名称 + 描述) */
- const formOpen = ref(false);
- const submitting = ref(false);
- const editing = ref<KnowledgeBase | null>(null);
- const form = reactive({ name: '', description: '' });
- /** 知识库详情(只读弹框) */
- const detailOpen = ref(false);
- const detail = ref<KnowledgeBase | null>(null);
- /** 我的上传文档弹窗 */
- const docsOpen = ref(false);
- const docsKb = ref<KnowledgeBase | null>(null);
- /** 知识库名称搜索(纯前端过滤,不改接口与加载逻辑) */
- const keyword = ref('');
- const filteredBases = computed(() => {
- const k = keyword.value.trim().toLowerCase();
- if (!k) return bases.value;
- return bases.value.filter((kb) => (kb.name || '').toLowerCase().includes(k));
- });
- const builtinBases = computed(() => bases.value.filter((kb) => kb.builtin));
- const mineBases = computed(() => bases.value.filter((kb) => !kb.builtin));
- const summary = computed(() => {
- const parts: string[] = [];
- if (builtinBases.value.length) parts.push(`内置 ${builtinBases.value.length}`);
- if (mineBases.value.length) parts.push(`我的 ${mineBases.value.length}`);
- return parts.length ? parts.join(' · ') : '尚未创建知识库';
- });
- onMounted(load);
- async function load() {
- loading.value = true;
- try {
- bases.value = (await knowledgeApi.listKnowledgeBases()) || [];
- } catch (e: any) {
- showMessage(e?.message || '加载知识库列表失败', 'error');
- } finally {
- loading.value = false;
- }
- }
- function openCreate() {
- editing.value = null;
- form.name = '';
- form.description = '';
- formOpen.value = true;
- }
- function openEdit(kb: KnowledgeBase) {
- editing.value = kb;
- form.name = kb.name;
- form.description = kb.description || '';
- formOpen.value = true;
- }
- async function submit() {
- const name = form.name.trim();
- if (!name) {
- showMessage('请输入知识库名称', 'error');
- return;
- }
- const payload: SaveKnowledgeBaseParams = { name, description: form.description?.trim() || '' };
- submitting.value = true;
- try {
- if (editing.value) {
- await knowledgeApi.updateKnowledgeBase(editing.value.id, payload);
- } else {
- await knowledgeApi.createKnowledgeBase(payload);
- }
- showMessage('已保存', 'success');
- formOpen.value = false;
- await load();
- } catch (e: any) {
- showMessage(e?.message || '保存失败', 'error');
- } finally {
- submitting.value = false;
- }
- }
- function openDetail(kb: KnowledgeBase) {
- detail.value = kb;
- detailOpen.value = true;
- }
- function openDocs(kb: KnowledgeBase) {
- docsKb.value = kb;
- docsOpen.value = true;
- }
- function onDelete(kb: KnowledgeBase) {
- Modal.confirm({
- title: '删除知识库',
- content: `确认删除「${kb.name}」?库内全部文档(含文件与向量索引)、下载记录会一并清理,绑定它的研判方案将自动解绑。`,
- okText: '删除',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- try {
- await knowledgeApi.deleteKnowledgeBase(kb.id);
- showMessage('已删除', 'success');
- await load();
- } catch (err: any) {
- showMessage(err?.message || '删除失败', 'error');
- }
- },
- });
- }
- </script>
- <style lang="less" scoped>
- /* ======================================================================
- * 版式对齐研判方案页(aiExpert):页底 #f4f6fa · 白卡圆角 14 · 描边 #e7ebf3
- * 标题蓝条 · 主按钮 #2a50ec · 卡片网格 auto-fill 320px
- * ==================================================================== */
- .ai-page.ai-kb {
- display: flex;
- flex-direction: column;
- gap: 8px;
- width: 100%;
- height: 100%;
- min-height: 0;
- margin: 0;
- padding: 8px 10px;
- background: #f4f6fa;
- border: 0;
- border-radius: 0;
- box-sizing: border-box;
- overflow: hidden;
- }
- .ai-kb {
- &__card-title {
- position: relative;
- flex: 0 0 auto;
- padding-left: 11px;
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- white-space: nowrap;
- &::before {
- content: '';
- position: absolute;
- top: 4px;
- bottom: 4px;
- left: 0;
- width: 3px;
- background: #2a50ec;
- border-radius: 2px;
- }
- }
- &__summary {
- max-width: 200px;
- overflow: hidden;
- color: #98a2b3;
- font-size: 11.5px;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__search {
- display: flex;
- flex: 0 1 auto;
- align-items: center;
- gap: 8px;
- width: 240px;
- min-width: 160px;
- height: 32px;
- padding: 0 11px;
- color: #98a2b3;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 8px;
- cursor: text;
- transition:
- border-color 0.15s ease,
- box-shadow 0.15s ease;
- &:focus-within {
- border-color: #2a50ec;
- box-shadow: 0 0 0 2px rgb(42 80 236 / 12%);
- }
- input {
- flex: 1;
- min-width: 0;
- color: #111827;
- font-size: 12px;
- font-family: inherit;
- background: transparent;
- border: 0;
- outline: none;
- &::placeholder {
- color: #98a2b3;
- }
- }
- }
- &__spacer {
- flex: 1 1 auto;
- }
- &__actions {
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 8px;
- :deep(.ant-btn) {
- height: 30px;
- padding: 0 14px;
- color: #5b6577;
- font-size: 12px;
- font-weight: 500;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 8px;
- box-shadow: none;
- &:hover,
- &:focus {
- color: #2a50ec;
- background: #f8faff;
- border-color: #bfcdfb;
- }
- }
- :deep(.ant-btn-primary) {
- color: #fff;
- background: #2a50ec;
- border-color: #2a50ec;
- &:hover,
- &:focus {
- color: #fff;
- background: #4b6bf0;
- border-color: #4b6bf0;
- }
- &:active {
- background: #1e3ed0;
- border-color: #1e3ed0;
- }
- }
- :deep(.ant-btn[disabled]) {
- color: rgb(0 0 0 / 25%);
- background: #f5f5f5;
- border-color: #e7ebf3;
- }
- }
- &__panel {
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- gap: 0;
- min-height: 0;
- padding: 0;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 14px;
- box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
- overflow: hidden;
- }
- &__panel-head {
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 10px;
- min-height: 52px;
- padding: 10px 14px;
- border-bottom: 1px solid #eff2f7;
- box-sizing: border-box;
- }
- &__panel-count {
- color: #98a2b3;
- font-size: 11.5px;
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
- }
- &__panel-body {
- flex: 1 1 auto;
- min-height: 0;
- padding: 14px;
- overflow-y: auto;
- overscroll-behavior: contain;
- box-sizing: border-box;
- }
- &__grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: 12px;
- }
- &__card {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 12px;
- box-sizing: border-box;
- transition:
- border-color 0.15s ease,
- box-shadow 0.15s ease;
- &:hover {
- border-color: #bfcdfb;
- box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
- transform: none;
- }
- }
- &__card-head {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- }
- &__card-icon {
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- color: #2a50ec;
- background: rgb(42 80 236 / 8%);
- border-radius: 9px;
- }
- &__card-heading {
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
- }
- &__name-row {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- }
- &__name {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 1.5;
- letter-spacing: 0;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__card-sub {
- overflow: hidden;
- color: #98a2b3;
- font-size: 11.5px;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__chip {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 5px;
- max-width: 100%;
- height: 20px;
- padding: 0 7px;
- overflow: hidden;
- color: #5b6577;
- font-size: 11px;
- font-weight: 500;
- line-height: 18px;
- white-space: nowrap;
- text-overflow: ellipsis;
- background: #f4f6fa;
- border: 1px solid #e7ebf3;
- border-radius: 6px;
- &.is-primary {
- color: #2a50ec;
- background: rgb(42 80 236 / 7%);
- border-color: rgb(42 80 236 / 18%);
- }
- }
- &__chip-dot {
- flex: 0 0 auto;
- width: 5px;
- height: 5px;
- border-radius: 50%;
- }
- &__desc {
- display: -webkit-box;
- min-height: calc(12.5px * 1.7 * 2);
- margin: 0;
- overflow: hidden;
- color: #5b6577;
- font-size: 12.5px;
- line-height: 1.7;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
- }
- &__chips {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- }
- &__card-ops {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-top: auto;
- padding-top: 10px;
- border-top: 1px solid #eff2f7;
- }
- &__card-ops-sp {
- flex: 1 1 auto;
- }
- &__run {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- height: 26px;
- padding: 0 10px;
- color: #fff;
- font-size: 11.5px;
- font-weight: 500;
- font-family: inherit;
- white-space: nowrap;
- background: #2a50ec;
- border: 1px solid #2a50ec;
- border-radius: 6px;
- cursor: pointer;
- transition:
- background-color 0.15s ease,
- border-color 0.15s ease;
- &:hover {
- background: #4b6bf0;
- border-color: #4b6bf0;
- }
- }
- &__iconbtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- padding: 0;
- color: #98a2b3;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 6px;
- cursor: pointer;
- transition:
- color 0.15s ease,
- background-color 0.15s ease,
- border-color 0.15s ease;
- &:hover {
- color: #2a50ec;
- background: #f8faff;
- border-color: #bfcdfb;
- }
- &.is-danger:hover {
- color: #e5484d;
- background: #fff5f5;
- border-color: rgb(229 72 77 / 35%);
- }
- }
- }
- /* ── 知识库详情弹框(只读):头 + 描述 + 使用方式 ── */
- .akb-detail {
- display: flex;
- flex-direction: column;
- gap: 12px;
- &__head {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- &__badge {
- flex: 0 0 auto;
- padding: 0 7px;
- font-size: 11px;
- line-height: 18px;
- border-radius: 99px;
- &.is-builtin {
- color: #5b6577;
- background: #f4f6fa;
- }
- &.is-custom {
- color: #2a50ec;
- background: rgb(42 80 236 / 8%);
- }
- }
- &__name {
- overflow: hidden;
- color: #111827;
- font-size: 15px;
- font-weight: 700;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__desc {
- margin: 0;
- color: #5b6577;
- font-size: 13px;
- line-height: 1.7;
- }
- &__panel {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px 14px;
- background: #fff;
- border: 1px solid #e7ebf3;
- border-radius: 10px;
- }
- &__label {
- color: #111827;
- font-size: 12.5px;
- font-weight: 700;
- }
- &__text {
- margin: 0;
- color: #5b6577;
- font-size: 12.5px;
- line-height: 1.7;
- }
- }
- @media (max-width: 1180px) {
- .ai-kb__summary {
- display: none;
- }
- }
- </style>
|