feat: 前端初始调整及知识库模块添加

master
5 days ago
parent 121d515402
commit 3e1c7283ce

@ -10,7 +10,8 @@ VITE_APP_TITLE = FastapiAdmin
VITE_API_URL = /
# 代理目标:本机后端(若出现 ENOTFOUND说明上面域名在当前网络/DNS 下不可解析,改用本地或可用地址)
VITE_API_BASE_URL = http://127.0.0.1:8001
# VITE_API_BASE_URL = http://127.0.0.1:8001
VITE_API_BASE_URL = http://192.168.5.5:8011
# VITE_API_BASE_URL = https://service.fastapiadmin.com
# 是否删除控制台输出
@ -19,3 +20,6 @@ VITE_DROP_CONSOLE = false
# WebSocket 端点AI对话功能需要配置
VITE_APP_WS_ENDPOINT = ws://127.0.0.1:8001
# VITE_APP_WS_ENDPOINT = wss://service.fastapiadmin.com
# Dify 知识库后端代理目标地址
VITE_DIFY_API_BASE_URL = https://p30-dify.ngsk.tech:7001/

@ -69,6 +69,8 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@file-viewer/preset-all": "^2.1.17",
"@file-viewer/vue3": "^2.1.17",
"@iconify-json/line-md": "^1.2.16",
"@iconify-json/ri": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4",
@ -89,6 +91,7 @@
"crypto-js": "^4.2.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.20",
"docx-preview": "^0.3.7",
"dompurify": "^3.4.3",
"echarts": "^6.0.0",
"element-plus": "~2.13.7",
@ -96,6 +99,7 @@
"file-saver": "^2.0.5",
"highlight.js": "^11.11.1",
"js-beautify": "^1.15.4",
"jszip": "^3.10.1",
"markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.3.0",
"mitt": "^3.0.1",

File diff suppressed because it is too large Load Diff

@ -17,10 +17,10 @@
<FaThemeSvg :src="loginIcon" size="100%" />
</div>
<div class="text-wrap">
<!-- <div class="text-wrap">
<h1>{{ $t("login.leftView.title") }}</h1>
<p>{{ $t("login.leftView.subTitle") }}</p>
</div>
</div> -->
<!-- 几何装饰元素 -->
<div class="geometric-decorations">

@ -146,18 +146,18 @@
</ElButton>
</div>
<div class="login-secondary-actions grid grid-cols-2 gap-2">
<!-- <div class="login-secondary-actions grid grid-cols-2 gap-2">
<ElButton class="login-secondary-btn" plain @click="$emit('openMobile')">
{{ $t("login.mobileLogin") }}
</ElButton>
<ElButton class="login-secondary-btn" plain @click="$emit('openQr')">
{{ $t("login.qrLogin") }}
</ElButton>
</div>
</div> -->
</div>
</ElForm>
<FaLoginThirdPartySection @oauth="$emit('oauth', $event)" />
<!-- <FaLoginThirdPartySection @oauth="$emit('oauth', $event)" /> -->
<FaLoginAuthLinkRow
:hint="$t('login.noAccount')"

@ -38,7 +38,7 @@ import { headerBarConfig } from "./modules/headerBar";
const appConfig: SystemConfig = {
// 系统信息
systemInfo: {
name: "FastapiAdmin", // 系统名称
name: "AI可研报告编制审查系统", // 系统名称
},
// 系统主题
systemThemeStyles: {

@ -4,10 +4,10 @@
<template v-else>
<!-- 左列主内容区 | 右列侧边栏 -->
<ElRow :gutter="20">
<ElCol :xs="24" :md="18">
<ElCol>
<Banner class="mb-5" />
<ElRow :gutter="20">
<!-- <ElRow :gutter="20">
<ElCol :xs="24" :md="16">
<ElRow :gutter="20">
<ElCol :xs="24" :sm="24" :md="24">
@ -123,10 +123,10 @@
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
<NewUser />
</ElCol>
</ElRow>
</ElRow> -->
</ElCol>
<ElCol :xs="24" :md="6" class="flex flex-col gap-5">
<!-- <ElCol :xs="24" :md="6" class="flex flex-col gap-5">
<QuickLinks class="mb-5" />
<FaDataListCard
class="mb-5"
@ -138,10 +138,10 @@
@more="handleMore"
/>
<TodoList class="mb-5" />
</ElCol>
</ElCol> -->
</ElRow>
<ElRow :gutter="20">
<!-- <ElRow :gutter="20">
<ElCol :xs="24" :sm="6" :md="5" class="mb-5">
<FaImageCard
:imageUrl="imageCards.imageUrl"
@ -173,7 +173,7 @@
<ElCol :xs="24" :sm="12" :md="14" class="mb-5">
<AboutProject />
</ElCol>
</ElRow>
</ElRow> -->
</template>
</div>
</template>

@ -0,0 +1,340 @@
<!-- 创建知识库页 对应源代码 CreateKnowledge.tsx -->
<template>
<div :class="styles.page">
<ElSteps :active="currentStep" :class="styles.steps" class="create_knowledge_steps">
<ElStep title="向量数据库" />
<ElStep title="文本分段与清洗" />
<ElStep title="处理并完成" />
</ElSteps>
<ElForm class="form-body" label-position="top">
<KnowledgeInfo
v-if="currentStep === 0"
:file-list="fileList"
:allowed-extensions="allowedExtensions"
:on-file-list-change="setFileList"
:set-file-ids="setFileIds"
:knowledge-base-info="knowledgeBaseInfo"
:set-knowledge-base-info="setKnowledgeBaseInfo"
/>
<SegmentSetting
v-else-if="currentStep === 1"
:file-list="fileList"
:file-ids="fileIds"
:rerank-model="rerankModel"
:embedding-model="embeddingModel"
:indexing-estimate="indexingEstimate"
:set-indexing-estimate="setIndexingEstimate"
:set-rerank-model="setRerankModel"
:set-embedding-model="setEmbeddingModel"
:rerank-enabled="rerankEnabled"
:top-k="topK"
:score-threshold="scoreThreshold"
:score-threshold-enabled="scoreThresholdEnabled"
:set-rerank-enabled="setRerankEnabled"
:set-top-k="setTopK"
:set-score-threshold="setScoreThreshold"
:set-score-threshold-enabled="setScoreThresholdEnabled"
/>
<InformationVerification
v-else-if="currentStep === 2"
:file-list="fileList"
:knowledge-base-info="knowledgeBaseInfo"
:rerank-model="rerankModel"
:embedding-model="embeddingModel"
:indexing-estimate="indexingEstimate"
:rerank-enabled="rerankEnabled"
:top-k="topK"
:score-threshold="scoreThreshold"
:score-threshold-enabled="scoreThresholdEnabled"
:indexing-status-list="indexingStatusList"
/>
</ElForm>
<div :class="styles.footer">
<ElButton
v-if="!isClickComplete && currentStep > 0"
:disabled="loading"
@click="handlePrev"
>
上一步
</ElButton>
<ElButton
v-if="!isClickComplete && currentStep < stepItems.length - 1"
type="primary"
:disabled="isNextDisabled()"
:loading="loading"
@click="handleNext"
>
下一步
</ElButton>
<template v-if="currentStep === 2">
<ElButton
v-if="!isClickComplete"
type="primary"
:loading="loading"
@click="createKnowledge"
>
创建并保存
</ElButton>
<ElButton v-else type="primary" @click="goBack">
完成
</ElButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "CreateKnowledge" });
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import KnowledgeInfo from "./components/KnowledgeInfo.vue";
import SegmentSetting from "./components/SegmentSetting.vue";
import InformationVerification from "./components/InformationVerification.vue";
import { useDifyGet, useDifyPost, useRequest } from "./useDifyApis";
import { knowledgeBase } from "./api";
import {
decodeInputValue,
getInitKnowledgeQueryData,
getQueryIndexingEstimateJson,
initIndexingEstimate,
type IndexingEstimate,
type IndexingStatusItem,
type KnowledgeBaseInfoType,
type ModelType,
type UploadFile as UploadFileType,
} from "./types";
import styles from "./style/createKnowledge.module.scss";
const router = useRouter();
const stepItems = [
{ title: "向量数据库" },
{ title: "文本分段与清洗" },
{ title: "处理并完成" },
];
const currentStep = ref(0);
const knowledgeBaseInfo = ref<KnowledgeBaseInfoType>({
name: "",
description: "",
});
const indexingEstimate = ref<IndexingEstimate>(initIndexingEstimate);
const fileList = ref<UploadFileType[]>([]);
const fileIds = ref<string[]>([]);
const rerankModel = ref<ModelType>({
currentProvider: "",
currentModel: "",
});
const embeddingModel = ref<ModelType>({
currentProvider: "",
currentModel: "",
});
const rerankEnabled = ref(true);
const topK = ref(3);
const scoreThreshold = ref(0.5);
const scoreThresholdEnabled = ref(false);
const indexingStatusList = ref<IndexingStatusItem[]>([]);
const isClickComplete = ref(false);
const loading = ref(false);
const { doRequest } = useRequest();
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
// 线
const { result: supportTypeList, doGet } = useDifyGet({
url: knowledgeBase.supportType,
});
//
const { doPost: initKnowledgeQueryDataPost } = useDifyPost({
url: knowledgeBase.initKnowledgeQueryData,
});
//
const { doGet: indexingStatusGet } = useDifyGet();
const finishedIndexingStatus = ["completed", "error", "paused", "stopped"];
const allowedExtensions = computed(
() => (supportTypeList.value as any)?.allowed_extensions || []
);
function handlePrev() {
currentStep.value = Math.max(currentStep.value - 1, 0);
}
function handleNext() {
currentStep.value = Math.min(currentStep.value + 1, stepItems.length - 1);
}
function goBack() {
router.push("/platform/ai/knowledge");
}
function setFileList(list: UploadFileType[]) {
fileList.value = list;
}
function setFileIds(ids: string[]) {
fileIds.value = ids;
}
function setKnowledgeBaseInfo(info: KnowledgeBaseInfoType) {
knowledgeBaseInfo.value = info;
}
function setIndexingEstimate(estimate: IndexingEstimate) {
indexingEstimate.value = estimate;
}
function setRerankModel(model: ModelType) {
rerankModel.value = model;
}
function setEmbeddingModel(model: ModelType) {
embeddingModel.value = model;
}
function setRerankEnabled(value: boolean) {
rerankEnabled.value = value;
}
function setTopK(value: number) {
topK.value = value;
}
function setScoreThreshold(value: number) {
scoreThreshold.value = value;
}
function setScoreThresholdEnabled(value: boolean) {
scoreThresholdEnabled.value = value;
}
function isNextDisabled() {
if (currentStep.value === 0 && fileIds.value.length === 0) return true;
return false;
}
//
async function pollIndexingStatus(url: string) {
const resp = await indexingStatusGet({ url });
const statusList = ((resp as any)?.data || []) as IndexingStatusItem[];
indexingStatusList.value = statusList;
const isFinished =
statusList.length > 0 &&
statusList.every((item) => finishedIndexingStatus.includes(item.indexing_status));
if (!isFinished) {
pollingTimer = setTimeout(() => {
pollIndexingStatus(url);
}, 1000);
} else {
isClickComplete.value = true;
loading.value = false;
}
}
async function createKnowledge() {
try {
loading.value = true;
const queryIndexingEstimateJsonResult = getQueryIndexingEstimateJson(
fileIds.value,
[
{
id: "remove_extra_spaces",
enabled: indexingEstimate.value.pre_processing_rules.includes(
"remove_extra_spaces"
),
},
{
id: "remove_urls_emails",
enabled: indexingEstimate.value.pre_processing_rules.includes(
"remove_urls_emails"
),
},
],
{
separator: decodeInputValue(indexingEstimate.value.segmentIdentifier),
max_tokens: indexingEstimate.value.maximumSegmentLength,
chunk_overlap: indexingEstimate.value.segmentOverlapLength,
}
);
const data = getInitKnowledgeQueryData({
queryIndexingEstimateJsonResult,
top_k: topK.value,
score_threshold_enabled: scoreThresholdEnabled.value,
score_threshold: scoreThreshold.value,
embedding_model: embeddingModel.value.currentModel,
embedding_model_provider: embeddingModel.value.currentProvider,
reranking_provider_name: rerankModel.value.currentProvider,
reranking_model_name: rerankModel.value.currentModel,
});
const resp: any = await initKnowledgeQueryDataPost({
data,
});
if (resp?.dataset && resp.dataset?.id && resp.batch) {
const datasetId = resp.dataset.id;
const batchId = resp.batch;
indexingStatusList.value = [];
const statusUrl = knowledgeBase.indexingStatus(datasetId, batchId);
pollIndexingStatus(statusUrl);
await doRequest({
url: knowledgeBase.queryKnowledgeRule(datasetId),
method: "patch",
data: {
name: knowledgeBaseInfo.value.name,
description: knowledgeBaseInfo.value.description,
},
});
} else {
loading.value = false;
}
} catch (err) {
console.log(err);
loading.value = false;
}
}
onMounted(() => {
doGet();
});
watch(fileList, (list) => {
if (!knowledgeBaseInfo.value.name && list.length > 0) {
knowledgeBaseInfo.value = {
...knowledgeBaseInfo.value,
name: list[0]?.name ?? "",
};
}
});
onUnmounted(() => {
if (pollingTimer) {
clearTimeout(pollingTimer);
}
});
</script>
<style lang="scss" scoped>
.create_knowledge_steps {
width: 100%;
}
.form-body {
flex: 1;
min-height: 0;
overflow: auto;
}
</style>

@ -0,0 +1,416 @@
<!-- 文档列表页 对应源代码 Documents.tsx -->
<template>
<div :class="styles.page">
<div :class="styles.header">
<!-- <ElButton text :icon="ArrowLeft" @click="handleBack" /> -->
<div>
<h4 :class="styles.title">文档</h4>
<div :class="styles.description">知识库的所有文件都在这里显示</div>
</div>
</div>
<!-- 添加文件 -->
<AddDocument
v-if="addDocumentModalOpen === 'add'"
:set-add-document-modal-open="setAddDocumentModalOpen"
:dataset-id="datasetId"
:fetch-document-list="() => fetchDocumentList()"
/>
<!-- 文档预览 -->
<PreviewDocument
v-else-if="addDocumentModalOpen === 'preview'"
:set-add-document-modal-open="setAddDocumentModalOpen"
:dataset-id="datasetId"
:record="previewRecord"
/>
<!-- 文档列表 -->
<template v-else>
<div :class="styles.toolbar">
<div :class="styles.toolbarLeft">
<ElSelect :class="styles.typeSelect" :model-value="'all'" disabled>
<ElOption label="全部" value="all" />
</ElSelect>
<ElInput
v-model="keyword"
:class="styles.searchInput"
clearable
placeholder="搜索"
@clear="() => { keyword = ''; debounceFetch(''); }"
@input="(val: string) => debounceFetch(val)"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElButton :icon="Refresh" :class="styles.syncIcon" @click="() => fetchDocumentList()" />
</div>
<div :class="styles.toolbarRight">
<ElButton type="primary" :icon="Plus" @click="() => setAddDocumentModalOpen('add')">
添加文件
</ElButton>
</div>
</div>
<div :class="styles.tableCard">
<ElTable
v-loading="documentListLoading || doDocumentsStatusLoading"
:data="documentList"
row-key="id"
height="100%"
>
<ElTableColumn type="selection" width="42" />
<ElTableColumn label="#" width="60" prop="position">
<template #default="{ row, $index }">
{{ row.position || $index + 1 }}
</template>
</ElTableColumn>
<ElTableColumn label="名称" prop="name" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<div :class="styles.nameCell">
<span :class="styles.fileIcon">
<ElIcon>
<Document v-if="!isMarkdown(row)" />
<Memo v-else />
</ElIcon>
</span>
<span
:class="styles.nameText"
@click="() => openPreview(row)"
>
{{ row.name || "-" }}
</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="分段模式" prop="doc_form" width="140">
<template #default="{ row }">
<ElTag :class="styles.modeTag" effect="plain" size="small">
{{ DOC_FORM_LABEL[row.doc_form as string] || row.doc_form || "-" }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="字符数" prop="word_count" width="110">
<template #default="{ row }">
{{ formatCount(row.word_count) }}
</template>
</ElTableColumn>
<ElTableColumn label="召回次数" prop="hit_count" width="130">
<template #default="{ row }">
{{ formatCount(row.hit_count) }}
</template>
</ElTableColumn>
<ElTableColumn label="上传时间" prop="created_at" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="状态" prop="display_status" width="120">
<template #default="{ row }">
<span :class="[styles.statusAvailable, !row.enabled ? styles.statusAvailableDisabled : '']">
<span :class="[styles.statusDot, !row.enabled ? styles.statusDotDisabled : '']" />
{{ STATUS_LABEL[row.display_status as string] || STATUS_LABEL[row.indexing_status as string] || row.display_status || '-' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="160">
<template #default="{ row }">
<ElSwitch
size="small"
:model-value="row.enabled"
@change="(val: any) => handleStatusChange(row, Boolean(val))"
/>
<ElDropdown class="action-dropdown" trigger="click" @command="(cmd: string) => handleActionCommand(cmd, row)">
<ElIcon :class="styles.actionIcon"><MoreFilled /></ElIcon>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="rename">重命名</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
</ElTable>
</div>
<div :class="styles.footer">
<ElPagination
:current-page="pagination.page"
:page-size="pagination.limit"
:total="total"
:page-sizes="[10, 25, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(page: number) => fetchDocumentList(page, pagination.limit)"
@size-change="(size: number) => fetchDocumentList(1, size)"
/>
</div>
</template>
<ElDialog v-model="renameModalOpen" title="重命名文档" width="480px">
<ElInput
v-model="renameValue"
placeholder="请输入新名称"
@keyup.enter="submitRename"
/>
<template #footer>
<ElButton @click="() => (renameModalOpen = false)">取消</ElButton>
<ElButton
type="primary"
:loading="doRenameDocumentLoading"
:disabled="!renameValue.trim() || renameValue.trim() === String(renameRecord?.name || '')"
@click="submitRename"
>
确定
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "Documents" });
import { ref, computed, watch } from "vue";
import { useRoute } from "vue-router";
import {
Search,
Refresh,
Plus,
MoreFilled,
Document,
Memo,
ArrowLeft,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { useDebounceFn } from "@vueuse/core";
import AddDocument from "./components/AddDocument.vue";
import PreviewDocument from "./components/docPreview/index.vue";
import {
useDifyDelete,
useDifyGet,
useDifyPatch,
useDifyPost,
} from "./useDifyApis";
import { knowledgeBase } from "./api";
import type { DocumentModalOpenType } from "./types";
import styles from "./style/documents.module.scss";
type DocumentItem = {
id: string;
position: number;
name: string;
word_count: number;
hit_count: number;
created_at: number;
indexing_status: string;
display_status: string;
enabled: boolean;
doc_form: string;
data_source_detail_dict?: {
upload_file?: {
extension?: string;
};
};
};
const STATUS_LABEL: Record<string, string> = {
available: "可用",
disabled: "已禁用",
indexing: "索引中",
error: "异常",
};
const DOC_FORM_LABEL: Record<string, string> = {
text_model: "通用 · QA",
hierarchical_model: "父子分段",
qa_model: "问答模式",
};
function formatDateTime(timestamp: number) {
if (!timestamp) return "-";
const date = new Date(timestamp * 1000);
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function formatCount(value: number) {
if (!Number.isFinite(value)) return "0";
if (value >= 1000) {
const text = (value / 1000).toFixed(value % 1000 === 0 ? 0 : 1);
return `${text}k`;
}
return String(value);
}
const route = useRoute();
const datasetId = computed(() => String(route.params.dataset_id || ""));
const pagination = ref({ page: 1, limit: 10 });
const keyword = ref("");
const addDocumentModalOpen = ref<DocumentModalOpenType>("documents");
const previewRecord = ref<DocumentItem | null>(null);
//
const {
result: documentListResult,
loading: documentListLoading,
doGet: doGetDocumentList,
} = useDifyGet();
//
const { doPatch: doDocumentsStatus, loading: doDocumentsStatusLoading } = useDifyPatch();
//
const { doPost: doRenameDocument, loading: doRenameDocumentLoading } = useDifyPost();
//
const { doDelete: doDeleteDocument } = useDifyDelete({
url: knowledgeBase.documents(datasetId.value),
});
const renameModalOpen = ref(false);
const renameValue = ref("");
const renameRecord = ref<DocumentItem | null>(null);
const documentList = computed<DocumentItem[]>(() => {
return Array.isArray((documentListResult.value as any)?.data)
? (documentListResult.value as any).data
: [];
});
const total = computed(() => Number((documentListResult.value as any)?.total) || 0);
function setAddDocumentModalOpen(open: DocumentModalOpenType) {
addDocumentModalOpen.value = open;
}
function openPreview(record: DocumentItem) {
previewRecord.value = record;
setAddDocumentModalOpen("preview");
}
function openRenameModal(record: DocumentItem) {
renameRecord.value = record;
renameValue.value = record.name || "";
renameModalOpen.value = true;
}
function confirmDeleteDocument(record: DocumentItem) {
if (!datasetId.value || !record?.id) return;
ElMessageBox.confirm("此操作不可逆,请谨慎操作。", "确定要删除该文档吗?", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
confirmButtonClass: "el-button--danger",
})
.then(async () => {
try {
await doDeleteDocument({
config: {
params: {
document_id: record.id,
},
},
});
ElMessage.success("删除成功");
} catch (e: any) {
ElMessage.error(e?.message || "删除失败");
} finally {
fetchDocumentList();
}
})
.catch(() => {});
}
async function submitRename() {
const nextName = renameValue.value.trim();
if (!datasetId.value || !renameRecord.value?.id || !nextName) return;
try {
await doRenameDocument({
url: knowledgeBase.renameDocument(datasetId.value, renameRecord.value.id),
data: {
name: nextName,
},
});
renameModalOpen.value = false;
renameRecord.value = null;
} catch {
ElMessage.error("操作失败");
} finally {
fetchDocumentList();
}
}
function handleStatusChange(record: DocumentItem, enabled: boolean) {
if (!datasetId.value) return;
doDocumentsStatus({
url: knowledgeBase.documentStatus(datasetId.value, enabled ? "enable" : "disable"),
config: {
params: {
document_id: record.id,
},
},
})
.catch(() => {
ElMessage.error("操作失败");
})
.finally(() => {
fetchDocumentList();
});
}
function handleActionCommand(cmd: string, record: DocumentItem) {
if (cmd === "rename") openRenameModal(record);
if (cmd === "delete") confirmDeleteDocument(record);
}
function isMarkdown(row: DocumentItem): boolean {
const extension =
row.data_source_detail_dict?.upload_file?.extension ||
row.name?.split(".").pop() ||
"";
return ["md", "markdown"].includes(String(extension).toLowerCase());
}
function fetchDocumentList(
page = pagination.value.page,
limit = pagination.value.limit,
key = keyword.value.trim()
) {
if (!datasetId.value) return;
keyword.value = key;
pagination.value = { page, limit };
doGetDocumentList({
url: knowledgeBase.documents(datasetId.value),
params: { page, limit, keyword: key },
});
}
const debounceFetch = useDebounceFn((val: string) => {
fetchDocumentList(pagination.value.page, pagination.value.limit, val);
}, 500);
watch(
datasetId,
() => {
fetchDocumentList(1, pagination.value.limit);
},
{ immediate: true }
);
const router = useRouter();
const handleBack = () => {
router.back();
};
</script>
<style lang="scss" scoped>
.action-dropdown {
margin-left: 12px;
}
</style>

@ -0,0 +1,42 @@
// 知识库
export const knowledgeBase = {
pageList: "/console/api/datasets",
login: "/console/api/login",
supportType: "/console/api/files/support-type",
upload: "/console/api/files/upload",
indexingEstimate: "/console/api/datasets/indexing-estimate",
// 文本嵌入模型
textEmbedding: "/console/api/workspaces/current/models/model-types/text-embedding",
// 排序模型
rerank: "/console/api/workspaces/current/models/model-types/rerank",
// 初始化知识库查询数据
initKnowledgeQueryData: "/console/api/datasets/init",
// 文件创建解析
indexingStatus: (dataset_id: string, batch_id: string) =>
`/console/api/datasets/${dataset_id}/batch/${batch_id}/indexing-status`,
// 文档
documents: (dataset_id: string) => `/console/api/datasets/${dataset_id}/documents`,
// 文件状态设置
documentStatus: (dataset_id: string, status: "disable" | "enable") =>
`/console/api/datasets/${dataset_id}/documents/status/${status}/batch`,
// 文件重命名
renameDocument: (dataset_id: string, document_id: string) =>
`/console/api/datasets/${dataset_id}/documents/${document_id}/rename`,
// 读取文档规则
readCurrentDocumentRule: "/console/api/datasets/process-rule",
// 新增文档保存文档
createAndSaveDocument: (dataset_id: string) => `/console/api/datasets/${dataset_id}/documents`,
// 查询当前知识库规则
queryKnowledgeRule: (dataset_id: string) => `/console/api/datasets/${dataset_id}`,
// 获取完整文件
downloadDocument: (dataset_id: string, document_id: string) =>
`/console/api/datasets/${dataset_id}/documents/${document_id}/download`,
};

@ -0,0 +1,381 @@
<!-- 新增文档组件 对应源代码 AddDocument.tsx -->
<template>
<div :class="styles.page" style="height: calc(100vh - 200px)">
<div class="add-document-header">
<ElButton text @click="() => setAddDocumentModalOpen('documents')">
<ElIcon><ArrowLeft /></ElIcon>
</ElButton>
<ElSteps :active="currentStep" :class="styles.steps" class="add_steps" size="small">
<ElStep title="上传文件" />
<ElStep title="文本分段与清洗" />
<ElStep title="处理并完成" />
</ElSteps>
</div>
<ElForm class="form-body">
<div v-if="currentStep === 0" :class="styles.content">
<UploadFile
type="local"
:file-list="fileList"
:allowed-extensions="allowedExtensions"
:on-file-list-change="setFileList"
:set-file-ids="setFileIds"
/>
</div>
<div v-else-if="currentStep === 1" :class="[styles.content, styles.segmentContent]">
<div :class="styles.segmentLayout">
<div :class="styles.segmentLeft">
<SegmenteInfo
:indexing-estimate="indexingEstimate"
:set-indexing-estimate="setIndexingEstimate"
:preview-indexing-estimate="previewIndexingEstimate"
:file-list="fileList"
:selected-file-uid="selectedFileUid"
:set-selected-file-uid="setSelectedFileUid"
:set-current-file-index="setCurrentFileIndex"
/>
</div>
<SegmentedPreview
:selected-filename="selectedFilename"
:selected-ext="selectedExt"
:selected-icon-style="selectedIconStyle"
:loading-indexing-estimate="loadingIndexingEstimate"
:indexing-estimate-preview-chunks="indexingEstimatePreviewChunks"
/>
</div>
</div>
<div v-else :class="styles.content">
<UploadFile
type="online"
:file-list="fileList"
:indexing-status-list="indexingStatusList"
/>
</div>
</ElForm>
<div :class="styles.footer">
<ElButton v-if="!isClickComplete && currentStep > 0" :disabled="loading" @click="handlePrev">
上一步
</ElButton>
<ElButton
v-if="!isClickComplete && currentStep < stepItems.length - 1"
type="primary"
:disabled="currentStep === 0 && fileIds.length === 0"
:loading="loading"
@click="handleNext"
>
下一步
</ElButton>
<template v-if="currentStep === 2">
<ElButton v-if="!isClickComplete" type="primary" :loading="loading" @click="addDocument">
新增文档并保存到知识库
</ElButton>
<ElButton v-else type="primary" @click="handleComplete"> </ElButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "AddDocument" });
import { ref, computed, watch, onUnmounted } from "vue";
import { ArrowLeft } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import SegmenteInfo from "./SegmenteInfo.vue";
import SegmentedPreview from "./SegmentedPreview.vue";
import UploadFile from "./UploadFile.vue";
import { getFileIconStyle, type FileIconStyle } from "./fileStyles";
import { useDifyGet, useDifyPost } from "../useDifyApis";
import { knowledgeBase } from "../api";
import {
decodeInputValue,
getCreateKnowledgeJson,
getInitKnowledgeQueryData,
getQueryIndexingEstimateJson,
initIndexingEstimate,
type IndexingEstimate,
type IndexingStatusItem,
type UploadFile as UploadFileType,
} from "../types";
import styles from "../style/createKnowledge.module.scss";
import type { DocumentModalOpenType } from "../types";
interface Props {
setAddDocumentModalOpen: (open: DocumentModalOpenType) => void;
datasetId: string;
fetchDocumentList: () => void;
}
const props = defineProps<Props>();
const stepItems = [{ title: "上传文件" }, { title: "文本分段与清洗" }, { title: "处理并完成" }];
const currentStep = ref(0);
const fileList = ref<UploadFileType[]>([]);
const fileIds = ref<string[]>([]);
const indexingEstimate = ref<IndexingEstimate>(initIndexingEstimate);
const indexingStatusList = ref<IndexingStatusItem[]>([]);
const isClickComplete = ref(false);
const loading = ref(false);
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
const selectedFileUid = ref<string>(fileList.value[0]?.uid ?? "");
const currentFileIndex = ref(0);
const selectedFile = computed(() =>
fileList.value.find((file) => file.uid === selectedFileUid.value)
);
const selectedFilename = computed(() => selectedFile.value?.name);
const selectedExt = computed(() => {
const name = selectedFilename.value;
if (!name || !name.includes(".")) return "";
return name.split(".").pop()?.toLowerCase() || "";
});
const selectedIconStyle = computed<FileIconStyle>(() => getFileIconStyle(selectedExt.value));
//
const { result: supportTypeList } = useDifyGet({
url: knowledgeBase.supportType,
moreOperations: {
immediate: true,
},
});
//
const {
result: indexingEstimateResult,
doPost: postIndexingEstimate,
loading: loadingIndexingEstimate,
} = useDifyPost({
url: knowledgeBase.indexingEstimate,
});
//
const { result: knowledgeRuleResult } = useDifyGet({
url: knowledgeBase.queryKnowledgeRule(props.datasetId),
moreOperations: {
immediate: true,
},
});
//
const { doPost: createKnowledgeAndSaveDocument } = useDifyPost({
url: knowledgeBase.createAndSaveDocument(props.datasetId),
});
//
const { doGet: indexingStatusGet } = useDifyGet();
const finishedIndexingStatus = ["completed", "error", "paused", "stopped"];
const allowedExtensions = computed(() => (supportTypeList.value as any)?.allowed_extensions || []);
const indexingEstimatePreviewChunks = computed(() => {
const resp = indexingEstimateResult.value as any;
const payload = resp?.data ?? resp;
return Array.isArray(payload?.preview) ? payload.preview : [];
});
watch(
fileList,
(list) => {
if (!list.length) {
selectedFileUid.value = "";
currentFileIndex.value = 0;
return;
}
if (selectedFileUid.value && list.some((file) => file.uid === selectedFileUid.value)) {
return;
}
currentFileIndex.value = 0;
selectedFileUid.value = list[0]?.uid ?? "";
},
{ deep: true }
);
watch(
[currentFileIndex, selectedFileUid],
() => {
if (selectedFileUid.value) {
previewIndexingEstimate();
}
},
{ immediate: true }
);
function handlePrev() {
currentStep.value = Math.max(currentStep.value - 1, 0);
}
function handleNext() {
currentStep.value = Math.min(currentStep.value + 1, stepItems.length - 1);
}
function handleComplete() {
props.setAddDocumentModalOpen("documents");
props.fetchDocumentList();
}
//
function previewIndexingEstimate() {
const fileId = fileIds.value[currentFileIndex.value];
if (!fileId) return;
postIndexingEstimate({
data: getQueryIndexingEstimateJson(
[fileId],
[
{
id: "remove_extra_spaces",
enabled: indexingEstimate.value.pre_processing_rules.includes("remove_extra_spaces"),
},
{
id: "remove_urls_emails",
enabled: indexingEstimate.value.pre_processing_rules.includes("remove_urls_emails"),
},
],
{
separator: decodeInputValue(indexingEstimate.value.segmentIdentifier),
max_tokens: indexingEstimate.value.maximumSegmentLength,
chunk_overlap: indexingEstimate.value.segmentOverlapLength,
}
),
});
}
function setFileList(list: UploadFileType[]) {
fileList.value = list;
}
function setFileIds(ids: string[]) {
fileIds.value = ids;
}
function setIndexingEstimate(estimate: IndexingEstimate) {
indexingEstimate.value = estimate;
}
function setSelectedFileUid(uid: string) {
selectedFileUid.value = uid;
}
function setCurrentFileIndex(index: number) {
currentFileIndex.value = index;
}
//
async function pollIndexingStatus(url: string) {
const resp = await indexingStatusGet({ url });
const statusList = ((resp as any)?.data || []) as IndexingStatusItem[];
indexingStatusList.value = statusList;
const isFinished =
statusList.length > 0 &&
statusList.every((item) => finishedIndexingStatus.includes(item.indexing_status));
if (!isFinished) {
pollingTimer = setTimeout(() => {
pollIndexingStatus(url);
}, 1000);
} else {
isClickComplete.value = true;
loading.value = false;
}
}
//
function addDocument() {
const knowledgeRule = knowledgeRuleResult.value as any;
if (!knowledgeRule) {
ElMessage.warning("知识库检索规则加载中,请稍后重试");
return;
}
const retrievalModelDict = knowledgeRule?.retrieval_model_dict ?? {};
const externalRetrievalModel = knowledgeRule?.external_retrieval_model ?? {};
const rerankingModel = retrievalModelDict?.reranking_model ?? {};
const topK = retrievalModelDict?.top_k ?? externalRetrievalModel?.top_k ?? 3;
const scoreThresholdEnabled =
retrievalModelDict?.score_threshold_enabled ??
externalRetrievalModel?.score_threshold_enabled ??
false;
const scoreThreshold =
retrievalModelDict?.score_threshold ?? externalRetrievalModel?.score_threshold ?? 0.5;
const queryIndexingEstimateJsonResult = getQueryIndexingEstimateJson(
fileIds.value,
[
{
id: "remove_extra_spaces",
enabled: indexingEstimate.value.pre_processing_rules.includes("remove_extra_spaces"),
},
{
id: "remove_urls_emails",
enabled: indexingEstimate.value.pre_processing_rules.includes("remove_urls_emails"),
},
],
{
separator: decodeInputValue(indexingEstimate.value.segmentIdentifier),
max_tokens: indexingEstimate.value.maximumSegmentLength,
chunk_overlap: indexingEstimate.value.segmentOverlapLength,
}
);
const data = getInitKnowledgeQueryData({
queryIndexingEstimateJsonResult,
top_k: Number(topK),
score_threshold_enabled: Boolean(scoreThresholdEnabled),
score_threshold: Number(scoreThreshold),
embedding_model: String(knowledgeRule?.embedding_model ?? ""),
embedding_model_provider: String(knowledgeRule?.embedding_model_provider ?? ""),
reranking_provider_name: String(rerankingModel?.reranking_provider_name ?? ""),
reranking_model_name: String(rerankingModel?.reranking_model_name ?? ""),
});
const createKnowledgeJson = getCreateKnowledgeJson(data);
loading.value = true;
indexingStatusList.value = [];
createKnowledgeAndSaveDocument({
data: createKnowledgeJson,
})
.then((resp: any) => {
const batchId = resp?.batch;
if (batchId) {
pollIndexingStatus(knowledgeBase.indexingStatus(props.datasetId, batchId));
} else {
loading.value = false;
}
})
.catch(() => {
loading.value = false;
ElMessage.error("新增知识库文档失败");
});
}
onUnmounted(() => {
if (pollingTimer) {
clearTimeout(pollingTimer);
}
});
</script>
<style lang="scss" scoped>
.add-document-header {
display: flex;
gap: 12px;
.add_steps {
flex: 1;
}
}
.form-body {
flex: 1;
min-height: 0;
overflow: auto;
}
</style>

@ -0,0 +1,77 @@
<!-- 文件图标组件 替代源代码中的 react-file-icon -->
<template>
<svg
:width="size"
:height="size * 1.27"
viewBox="0 0 36 46"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<!-- 文件主体 -->
<path
d="M30 0H6C2.7 0 0 2.7 0 6v34c0 3.3 2.7 6 6 6h24c3.3 0 6-2.7 6-6V6c0-3.3-2.7-6-6-6z"
:fill="color"
/>
<!-- 折角 -->
<path
d="M36 13L23 0v9c0 2.2 1.8 4 4 4h9z"
:fill="foldColor"
/>
<!-- 扩展名标签底色 -->
<rect
x="0"
y="30"
width="36"
height="12"
:fill="labelColor"
/>
<!-- 扩展名文字 -->
<text
x="18"
y="39"
text-anchor="middle"
font-size="8"
font-weight="600"
:fill="labelTextColor"
style="text-transform: uppercase"
>
{{ displayExtension }}
</text>
</svg>
</template>
<script setup lang="ts">
defineOptions({ name: "FileIcon" });
interface Props {
extension?: string;
color?: string;
gradient?: string;
labelColor?: string;
labelTextColor?: string;
type?: string;
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
extension: "file",
color: "#9ca3af",
gradient: "",
labelColor: "#6b7280",
labelTextColor: "#ffffff",
type: "",
size: 22,
});
// gradient 使使 color
const foldColor = computed(() => {
if (props.gradient) return props.gradient;
//
return props.color;
});
const displayExtension = computed(() => {
return (props.extension || "file").slice(0, 4);
});
</script>

@ -0,0 +1,178 @@
<!-- 信息确认组件 对应源代码 InformationVerification.tsx -->
<template>
<div :class="[styles.content, styles.segmentContent]">
<div :class="styles.titleBlock">
<h2 :class="styles.sectionTitle">🎉 即将创建知识库</h2>
<p :class="styles.sectionDesc">
请确认知识库信息是否正确包括名称描述文件等核对完成后点击"完成并确认"
</p>
</div>
<div :class="[styles.fieldCard, styles.fieldCardStatic]">
<div :class="[styles.iconBox, styles.iconBoxOrange]">
<ElIcon><Reading /></ElIcon>
</div>
<ElFormItem label="知识库名称" :class="styles.fullFormItem">
<div :class="styles.staticValue" :title="knowledgeBaseInfo.name || '-'">
{{ knowledgeBaseInfo.name || "-" }}
</div>
</ElFormItem>
</div>
<div :class="[styles.fieldCard, styles.fieldCardStatic]">
<div :class="[styles.iconBox, styles.iconBoxOrange]">
<ElIcon><ChatDotRound /></ElIcon>
</div>
<ElFormItem label="知识库描述" :class="styles.fullFormItem">
<div :class="styles.staticParagraph" :title="knowledgeBaseInfo.description || '-'">
{{ knowledgeBaseInfo.description || "-" }}
</div>
</ElFormItem>
</div>
<UploadFile
type="online"
:file-list="fileList"
:indexing-status-list="indexingStatusList"
/>
<div :class="styles.verificationBlock">
<div :class="styles.verificationBlockTitle">配置确认</div>
<div :class="styles.verificationGrid">
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">分段模式</div>
<div :class="styles.verificationValue">自定义</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">最大分段长度</div>
<div :class="styles.verificationValue">
{{ indexingEstimate.maximumSegmentLength }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">分段重叠长度</div>
<div :class="styles.verificationValue">
{{ indexingEstimate.segmentOverlapLength }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">分段标识符</div>
<div :class="styles.verificationValue">
{{ formatSegmentIdentifier(indexingEstimate.segmentIdentifier) }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">文本预处理规则</div>
<div :class="styles.verificationValue">
{{ toRuleText(indexingEstimate.pre_processing_rules) }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">Embedding 模型</div>
<div :class="styles.verificationValue">
{{ embeddingModel.currentModel ? embeddingModel.currentModel : "-" }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">Rerank</div>
<div :class="styles.verificationValue">
{{ rerankEnabled ? "开启" : "关闭" }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">Rerank 模型</div>
<div :class="styles.verificationValue">
{{ rerankEnabled ? (rerankModel.currentModel ? rerankModel.currentModel : "-") : "未开启" }}
</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">TopK</div>
<div :class="styles.verificationValue">{{ topK }}</div>
</div>
<div :class="styles.verificationItem">
<div :class="styles.verificationLabel">分数阈值</div>
<div :class="styles.verificationValue">
{{ scoreThresholdEnabled ? scoreThreshold : "未开启" }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "InformationVerification" });
import { Reading, ChatDotRound } from "@element-plus/icons-vue";
import UploadFile from "./UploadFile.vue";
import {
decodeInputValue,
type IndexingEstimate,
type IndexingStatusItem,
type KnowledgeBaseInfoType,
type ModelType,
type UploadFile as UploadFileType,
} from "../types";
import styles from "../style/createKnowledge.module.scss";
interface Props {
fileList: UploadFileType[];
knowledgeBaseInfo: KnowledgeBaseInfoType;
rerankModel: ModelType;
embeddingModel: ModelType;
indexingEstimate: IndexingEstimate;
rerankEnabled: boolean;
topK: number;
scoreThreshold: number;
scoreThresholdEnabled: boolean;
indexingStatusList: IndexingStatusItem[];
}
defineProps<Props>();
function toRuleText(rules: IndexingEstimate["pre_processing_rules"]): string {
if (!rules?.length) return "无";
const labelMap: Record<IndexingEstimate["pre_processing_rules"][number], string> = {
remove_extra_spaces: "替换掉连续的空格、换行符和制表符",
remove_urls_emails: "移除所有 URL 和邮箱地址",
};
return rules.map((rule) => labelMap[rule] ?? rule).join("、");
}
function formatSegmentIdentifier(segmentIdentifier: string): string {
const decoded = decodeInputValue(segmentIdentifier);
const printable =
decoded === "\n\n"
? "空行 (\\n\\n)"
: decoded === "\n"
? "换行 (\\n)"
: decoded === "\t"
? "制表符 (\\t)"
: segmentIdentifier;
return printable;
}
</script>
<style lang="scss" scoped>
.staticValue {
width: 100%;
color: #111827;
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.staticParagraph {
width: 100%;
color: #111827;
font-size: 14px;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
</style>

@ -0,0 +1,77 @@
<!-- 知识库信息组件 对应源代码 KnowledgeInfo.tsx -->
<template>
<div :class="styles.content">
<div :class="styles.titleBlock">
<h2 :class="styles.sectionTitle">🎉 知识库创建</h2>
<p :class="styles.sectionDesc">为您的知识库起了个名称您也可以随时修改</p>
</div>
<div :class="styles.fieldCard">
<div :class="styles.iconBox">
<ElIcon><Coin /></ElIcon>
</div>
<ElFormItem label="知识库名称" :class="styles.fullFormItem">
<ElInput
:model-value="knowledgeBaseInfo.name"
placeholder="智能体.docx..."
@update:model-value="
(value) =>
setKnowledgeBaseInfo({
...knowledgeBaseInfo,
name: value,
})
"
/>
</ElFormItem>
</div>
<div :class="styles.fieldCard">
<div :class="styles.iconBox">
<ElIcon><ChatDotRound /></ElIcon>
</div>
<ElFormItem label="知识库描述" :class="styles.fullFormItem">
<ElInput
:model-value="knowledgeBaseInfo.description"
type="textarea"
:rows="3"
placeholder="知识库描述"
@update:model-value="
(value) =>
setKnowledgeBaseInfo({
...knowledgeBaseInfo,
description: value,
})
"
/>
</ElFormItem>
</div>
<UploadFile
type="local"
:file-list="fileList"
:allowed-extensions="allowedExtensions"
:on-file-list-change="onFileListChange"
:set-file-ids="setFileIds"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "KnowledgeInfo" });
import { Coin, ChatDotRound } from "@element-plus/icons-vue";
import UploadFile from "./UploadFile.vue";
import type { UploadFile as UploadFileType, KnowledgeBaseInfoType } from "../types";
import styles from "../style/createKnowledge.module.scss";
interface Props {
fileList: UploadFileType[];
allowedExtensions: string[];
onFileListChange: (fileList: UploadFileType[]) => void;
setFileIds: (fileIds: string[]) => void;
knowledgeBaseInfo: KnowledgeBaseInfoType;
setKnowledgeBaseInfo: (knowledgeBaseInfo: KnowledgeBaseInfoType) => void;
}
defineProps<Props>();
</script>

@ -0,0 +1,517 @@
<!-- 分段设置组件 对应源代码 SegmentSetting.tsx -->
<template>
<div :class="[styles.content, styles.segmentContent]">
<h3 :class="styles.segmentTitle">分段设置</h3>
<div :class="styles.segmentLayout">
<div :class="styles.segmentLeft">
<SegmenteInfo
:indexing-estimate="indexingEstimate"
:set-indexing-estimate="setIndexingEstimate"
:preview-indexing-estimate="previewIndexingEstimate"
:file-list="fileList"
:selected-file-uid="selectedFileUid"
:set-selected-file-uid="setSelectedFileUid"
:set-current-file-index="setCurrentFileIndex"
/>
<ElFormItem label="Embedding 模型" :class="styles.embeddingItem">
<ElSelect
:class="styles.embeddingSelect"
placeholder="搜索模型"
filterable
:model-value="toModelSelectValue(embeddingModel)"
:loading="loadingTextEmbedding"
@change="(value: any) => setEmbeddingModel(parseModelSelectValue(String(value ?? '')))"
>
<ElOptionGroup
v-for="group in embeddingOptions"
:key="group.label"
:label="group.label"
>
<ElOption
v-for="opt in group.options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<div :class="styles.embeddingOption">
<img
v-if="opt.icon"
:class="styles.embeddingOptionIcon"
:src="opt.icon"
alt=""
/>
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
</div>
</ElOption>
</ElOptionGroup>
</ElSelect>
</ElFormItem>
<div :class="styles.retrievalSection">
<h3 :class="styles.segmentTitle">检索设置</h3>
<div :class="styles.settingCard">
<div :class="styles.settingHeader">
<div :class="styles.settingIcon">
<ElIcon><Search /></ElIcon>
</div>
<div>
<div :class="styles.settingName">混合检索</div>
<div :class="styles.settingDesc">
同时执行全文检索和向量检索并应用重排序步骤从两类查询结果中选择匹配用户问题的最佳结果用户可以选择设置权重或配置重新排序模型
</div>
</div>
</div>
<div :class="styles.retrievalForm">
<div :class="styles.retrievalRerankRow">
<div :class="styles.retrievalLabel">
<span>
Rerank 模型
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</div>
<ElSwitch :model-value="rerankEnabled" @update:model-value="(v: any) => setRerankEnabled(Boolean(v))" />
</div>
<ElSelect
v-if="rerankEnabled"
:class="styles.retrievalModelSelect"
placeholder="搜索模型"
filterable
:model-value="toModelSelectValue(rerankModel)"
:loading="loadingRerank"
@change="(value: any) => setRerankModel(parseModelSelectValue(String(value ?? '')))"
>
<ElOptionGroup
v-for="group in rerankOptions"
:key="group.label"
:label="group.label"
>
<ElOption
v-for="opt in group.options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<div :class="styles.embeddingOption">
<img
v-if="opt.icon"
:class="styles.embeddingOptionIcon"
:src="opt.icon"
alt=""
/>
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
</div>
</ElOption>
</ElOptionGroup>
</ElSelect>
<div :class="styles.retrievalRow">
<div :class="styles.retrievalControl">
<div :class="styles.retrievalControlHeader">
<span>
Top K
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</div>
<div :class="styles.retrievalControlBody">
<ElInputNumber
:class="styles.retrievalNumberInput"
:min="1"
:max="20"
:model-value="topK"
@update:model-value="(v: any) => setTopK(Number(v ?? 1))"
/>
<ElSlider
:class="styles.retrievalSlider"
:min="1"
:max="20"
:model-value="topK"
@update:model-value="(v: any) => setTopK(Number(v))"
/>
</div>
</div>
<div :class="styles.retrievalControl">
<div :class="styles.retrievalControlHeader">
<span>
Score 阈值
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
<ElSwitch
size="small"
:model-value="scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThresholdEnabled(Boolean(v))"
/>
</div>
<div :class="styles.retrievalControlBody">
<ElInputNumber
:class="styles.retrievalNumberInput"
:min="0"
:max="1"
:step="0.01"
:model-value="scoreThreshold"
:disabled="!scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThreshold(Number(v ?? 0))"
/>
<ElSlider
:class="styles.retrievalSlider"
:min="0"
:max="1"
:step="0.01"
:model-value="scoreThreshold"
:disabled="!scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThreshold(Number(v))"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<SegmentedPreview
:selected-filename="selectedFilename"
:selected-ext="selectedExt"
:selected-icon-style="selectedIconStyle"
:loading-indexing-estimate="loadingIndexingEstimate"
:indexing-estimate-preview-chunks="indexingEstimatePreviewChunks"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "SegmentSetting" });
import { watch } from "vue";
import { Search, InfoFilled } from "@element-plus/icons-vue";
import SegmenteInfo from "./SegmenteInfo.vue";
import SegmentedPreview from "./SegmentedPreview.vue";
import { getFileIconStyle, type FileIconStyle } from "./fileStyles";
import { useDifyGet, useDifyPost } from "../useDifyApis";
import { knowledgeBase } from "../api";
import {
decodeInputValue,
getQueryIndexingEstimateJson,
type IndexingEstimate,
type ModelType,
type UploadFile,
} from "../types";
import styles from "../style/createKnowledge.module.scss";
type IndexingEstimatePreviewChunk = {
content?: string;
summary?: string | null;
child_chunks?: unknown;
};
type LocalizedText = {
zh_Hans?: string;
en_US?: string;
[key: string]: string | undefined;
};
type TextEmbeddingModel = {
model: string;
label?: LocalizedText;
model_type?: string;
status?: string;
deprecated?: boolean;
};
type TextEmbeddingProvider = {
provider: string;
label?: LocalizedText;
icon_small?: LocalizedText;
status?: string;
models?: TextEmbeddingModel[];
};
type ModelOption = {
key: string;
value: string;
label: string;
icon?: string;
};
type ModelOptionGroup = {
label: string;
options: ModelOption[];
};
interface Props {
fileList: UploadFile[];
fileIds: string[];
rerankModel: ModelType;
embeddingModel: ModelType;
indexingEstimate: IndexingEstimate;
rerankEnabled: boolean;
topK: number;
scoreThreshold: number;
scoreThresholdEnabled: boolean;
setRerankEnabled: (value: boolean) => void;
setTopK: (value: number) => void;
setScoreThreshold: (value: number) => void;
setScoreThresholdEnabled: (value: boolean) => void;
setRerankModel: (model: ModelType) => void;
setEmbeddingModel: (model: ModelType) => void;
setIndexingEstimate: (estimate: IndexingEstimate) => void;
}
const props = defineProps<Props>();
const selectedFileUid = ref<string>(props.fileList[0]?.uid ?? "");
const currentFileIndex = ref(0);
const selectedFile = computed(() =>
props.fileList.find((file) => file.uid === selectedFileUid.value)
);
const selectedFilename = computed(() => selectedFile.value?.name);
const selectedExt = computed(() => {
const name = selectedFilename.value;
if (!name || !name.includes(".")) return "";
return name.split(".").pop()?.toLowerCase() || "";
});
const selectedIconStyle = computed<FileIconStyle>(() =>
getFileIconStyle(selectedExt.value)
);
//
const {
result: indexingEstimateResult,
doPost: postIndexingEstimate,
loading: loadingIndexingEstimate,
} = useDifyPost({
url: knowledgeBase.indexingEstimate,
});
// textEmbedding
const { result: textModeldingResult, loading: loadingTextEmbedding } = useDifyGet({
url: knowledgeBase.textEmbedding,
moreOperations: {
immediate: true,
},
});
// rerank
const { result: rerankResult, loading: loadingRerank } = useDifyGet({
url: knowledgeBase.rerank,
moreOperations: {
immediate: true,
},
});
watch(
() => props.fileList,
(fileList) => {
if (!fileList?.length) {
selectedFileUid.value = "";
return;
}
if (selectedFileUid.value && fileList.some((f) => f.uid === selectedFileUid.value)) {
return;
}
selectedFileUid.value = fileList[0]?.uid ?? "";
},
{ immediate: true, deep: true }
);
watch(
[currentFileIndex, selectedFileUid],
() => {
if (selectedFileUid.value) {
previewIndexingEstimate();
}
},
{ immediate: true }
);
const indexingEstimatePreviewChunks = computed<IndexingEstimatePreviewChunk[]>(() => {
const resp = indexingEstimateResult.value as any;
const payload = resp?.data ?? resp;
return Array.isArray(payload?.preview) ? payload.preview : [];
});
function pickText(text?: LocalizedText): string {
return text?.zh_Hans ?? text?.en_US ?? Object.values(text ?? {})[0] ?? "";
}
function formatIconUrl(icon?: LocalizedText): string {
const url = pickText(icon);
return url || "";
}
function toModelSelectValue(model: ModelType): string | undefined {
if (!model?.currentProvider || !model?.currentModel) return undefined;
return `${model.currentProvider}::${model.currentModel}`;
}
function parseModelSelectValue(value: string): ModelType {
const [currentProvider, ...rest] = String(value ?? "").split("::");
const currentModel = rest.join("::");
return {
currentProvider: currentProvider ?? "",
currentModel: currentModel ?? "",
};
}
//
function previewIndexingEstimate() {
const fileId = props.fileIds[currentFileIndex.value];
if (!fileId) return;
postIndexingEstimate({
data: getQueryIndexingEstimateJson(
[fileId],
[
{
id: "remove_extra_spaces",
enabled: props.indexingEstimate.pre_processing_rules.includes(
"remove_extra_spaces"
),
},
{
id: "remove_urls_emails",
enabled: props.indexingEstimate.pre_processing_rules.includes(
"remove_urls_emails"
),
},
],
{
separator: decodeInputValue(props.indexingEstimate.segmentIdentifier),
max_tokens: props.indexingEstimate.maximumSegmentLength,
chunk_overlap: props.indexingEstimate.segmentOverlapLength,
}
),
});
}
function setCurrentFileIndex(index: number) {
currentFileIndex.value = index;
}
function setSelectedFileUid(uid: string) {
selectedFileUid.value = uid;
}
// textEmbedding
const embeddingOptions = computed<ModelOptionGroup[]>(() => {
const resp = textModeldingResult.value as any;
const embeddingProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
? resp.data
: [];
return embeddingProviders
.filter((p) => (p?.status ?? "active") === "active")
.map((provider) => {
const providerLabel = pickText(provider.label) || provider.provider;
const iconPath = formatIconUrl(provider.icon_small);
const providerIcon = iconPath ? "/p30" + iconPath : "";
const models = (provider.models ?? [])
.filter((m) => (m?.status ?? "active") === "active")
.filter((m) => !m?.deprecated)
.filter((m) => (m?.model_type ?? "text-embedding") === "text-embedding")
.map((model) => {
const modelLabel = pickText(model.label) || model.model;
return {
key: `${provider.provider}::${model.model}`,
value: `${provider.provider}::${model.model}`,
label: modelLabel,
icon: providerIcon,
};
});
return {
label: providerLabel,
options: models,
};
})
.filter((group) => group.options.length > 0);
});
// rerank
const rerankOptions = computed<ModelOptionGroup[]>(() => {
const resp = rerankResult.value as any;
const rerankProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
? resp.data
: [];
return rerankProviders
.filter((p) => (p?.status ?? "active") === "active")
.map((provider) => {
const providerLabel = pickText(provider.label) || provider.provider;
const iconPath = formatIconUrl(provider.icon_small);
const providerIcon = iconPath ? "/p30" + iconPath : "";
const models = (provider.models ?? [])
.filter((m) => (m?.status ?? "active") === "active")
.filter((m) => !m?.deprecated)
.filter((m) => (m?.model_type ?? "rerank") === "rerank")
.map((model) => {
const modelLabel = pickText(model.label) || model.model;
return {
key: `${provider.provider}::${model.model}`,
value: `${provider.provider}::${model.model}`,
label: modelLabel,
icon: providerIcon,
};
});
return {
label: providerLabel,
options: models,
};
})
.filter((group) => group.options.length > 0);
});
//
watch(
[embeddingOptions, () => props.embeddingModel],
() => {
const firstValue = embeddingOptions.value?.[0]?.options?.[0]?.value;
if (!firstValue) return;
let exists = false;
for (const group of embeddingOptions.value) {
for (const opt of group.options) {
if (opt.value === toModelSelectValue(props.embeddingModel)) {
exists = true;
}
}
}
if (
!props.embeddingModel?.currentProvider ||
!props.embeddingModel?.currentModel ||
!exists
) {
props.setEmbeddingModel(parseModelSelectValue(firstValue));
}
},
{ deep: true }
);
watch(
[rerankOptions, () => props.rerankModel],
() => {
const firstValue = rerankOptions.value?.[0]?.options?.[0]?.value;
if (!firstValue) return;
let exists = false;
for (const group of rerankOptions.value) {
for (const opt of group.options) {
if (opt.value === toModelSelectValue(props.rerankModel)) {
exists = true;
}
}
}
if (
!props.rerankModel?.currentProvider ||
!props.rerankModel?.currentModel ||
!exists
) {
props.setRerankModel(parseModelSelectValue(firstValue));
}
},
{ deep: true }
);
</script>

@ -0,0 +1,186 @@
<!-- 分段信息组件 对应源代码 SegmenteInfo.tsx -->
<template>
<div :class="styles.settingCard">
<div :class="styles.settingHeader">
<div :class="styles.settingIcon">
<ElIcon><Setting /></ElIcon>
</div>
<div>
<div :class="styles.settingName">通用</div>
<div :class="styles.settingDesc">
通用文本分段模式检索和回想时保持相同的分段粒度
</div>
</div>
</div>
<div :class="styles.formRow">
<ElFormItem :class="styles.compactFormItem">
<template #label>
<span>
分段标识符
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</template>
<ElInput
:model-value="indexingEstimate.segmentIdentifier"
@update:model-value="
(value) =>
setIndexingEstimate({
...indexingEstimate,
segmentIdentifier: value,
})
"
/>
</ElFormItem>
<ElFormItem :class="styles.compactFormItem">
<template #label>
<span>
分段最大长度
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</template>
<ElInputNumber
:model-value="indexingEstimate.maximumSegmentLength"
@update:model-value="
(value) =>
setIndexingEstimate({
...indexingEstimate,
maximumSegmentLength: value ?? 0,
})
"
/>
</ElFormItem>
<ElFormItem :class="styles.compactFormItem">
<template #label>
<span>
分段重叠长度
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</template>
<ElInputNumber
:model-value="indexingEstimate.segmentOverlapLength"
@update:model-value="
(value) =>
setIndexingEstimate({
...indexingEstimate,
segmentOverlapLength: value ?? 0,
})
"
/>
</ElFormItem>
</div>
<div :class="styles.divider" />
<div :class="styles.ruleTitle">文本预处理规则</div>
<ElCheckboxGroup
:model-value="indexingEstimate.pre_processing_rules"
@update:model-value="
(checkedValue) =>
setIndexingEstimate({
...indexingEstimate,
pre_processing_rules: checkedValue as IndexingEstimate['pre_processing_rules'],
})
"
>
<ElCheckbox value="remove_extra_spaces">
替换掉连续的空格换行符和制表符
</ElCheckbox>
<ElCheckbox value="remove_urls_emails" :class="styles.urlCheckbox">
删除所有 URL 和电子邮件地址
</ElCheckbox>
</ElCheckboxGroup>
<div :class="styles.segmentActions">
<ElButton type="primary" @click="previewIndexingEstimate">
<ElIcon><Search /></ElIcon>
预览块
</ElButton>
<ElButton link :class="styles.resetBtn" @click="() => setIndexingEstimate(initIndexingEstimate)">
重置
</ElButton>
</div>
</div>
<div :class="createStyles.filePreviewSection">
<div :class="createStyles.filePreviewTitle">文件预览</div>
<div v-if="fileList?.length" :class="createStyles.filePreviewList">
<div
v-for="(file, index) in fileList"
:key="file.uid"
:class="[
createStyles.filePreviewItem,
selectedFileUid === file.uid ? createStyles.filePreviewItemSelected : '',
]"
@click="
() => {
setCurrentFileIndex(index);
setSelectedFileUid(file.uid);
}
"
>
<div :class="createStyles.filePreviewIcon">
<FileIcon
:extension="getExt(file.name) || 'file'"
v-bind="getIconStyle(file.name)"
/>
</div>
<div :class="createStyles.filePreviewMeta">
<div :class="createStyles.filePreviewName" :title="file.name || '未命名文件'">
{{ file.name || "未命名文件" }}
</div>
<div :class="createStyles.filePreviewSub">
<span v-if="file.size" :class="createStyles.filePreviewSize">
{{ formatBytes(file.size) }}
</span>
</div>
</div>
</div>
</div>
<div v-else :class="createStyles.filePreviewEmpty">暂无文件</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "SegmenteInfo" });
import { Setting, InfoFilled, Search } from "@element-plus/icons-vue";
import FileIcon from "./FileIcon.vue";
import { getFileIconStyle } from "./fileStyles";
import type { IndexingEstimate, UploadFile } from "../types";
import { initIndexingEstimate } from "../types";
import styles from "../style/common.module.scss";
import createStyles from "../style/createKnowledge.module.scss";
interface Props {
indexingEstimate: IndexingEstimate;
setIndexingEstimate: (estimate: IndexingEstimate) => void;
previewIndexingEstimate: () => void;
fileList: UploadFile[];
selectedFileUid: string;
setSelectedFileUid: (uid: string) => void;
setCurrentFileIndex: (index: number) => void;
}
defineProps<Props>();
function getExt(filename: string): string {
if (!filename) return "";
if (!filename.includes(".")) return "";
return filename.split(".").pop()?.toLowerCase() || "";
}
function getIconStyle(filename: string) {
return getFileIconStyle(getExt(filename));
}
function formatBytes(bytes?: number) {
if (!bytes || bytes <= 0) return "";
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(1)} MB`;
}
</script>

@ -0,0 +1,74 @@
<!-- 文本分段预览组件 对应源代码 SegmentedPreview.tsx -->
<template>
<div :class="styles.segmentRight">
<div :class="styles.previewHeader">
<div :class="styles.previewTitle">
<template v-if="selectedFilename">
<span :class="styles.previewFileIcon">
<FileIcon
:extension="selectedExt || 'file'"
v-bind="selectedIconStyle"
/>
</span>
<span :class="styles.previewFileName" :title="selectedFilename">
{{ selectedFilename }}
</span>
</template>
</div>
</div>
<div :class="styles.previewBody">
<div v-loading="loadingIndexingEstimate" class="preview-loading-wrapper">
<div v-if="indexingEstimatePreviewChunks.length" :class="styles.previewChunkList">
<div v-for="(chunk, index) in indexingEstimatePreviewChunks" :key="index">
<div :class="styles.previewChunkHeader">
<ElIcon :class="styles.previewChunkIcon"><Grid /></ElIcon>
<div :class="styles.previewChunkMeta">
Chunk-{{ index + 1 }} · {{ chunk.content?.length ?? 0 }} 字符
</div>
</div>
<div :class="styles.previewChunkContent">{{ chunk.content ?? "" }}</div>
</div>
</div>
<div v-else :class="styles.previewEmptyWrapper">
<div :class="styles.previewEmpty">
<ElIcon :class="styles.previewEmptyIcon"><Search /></ElIcon>
<div :class="styles.previewEmptyText">点击左侧的"预览块"</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "SegmentedPreview" });
import { Grid, Search } from "@element-plus/icons-vue";
import FileIcon from "./FileIcon.vue";
import type { FileIconStyle } from "./fileStyles";
import styles from "../style/common.module.scss";
type IndexingEstimatePreviewChunk = {
content?: string;
summary?: string | null;
child_chunks?: unknown;
};
interface Props {
selectedFilename?: string;
selectedExt: string;
selectedIconStyle: FileIconStyle;
loadingIndexingEstimate: boolean;
indexingEstimatePreviewChunks: IndexingEstimatePreviewChunk[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
.preview-loading-wrapper {
height: 100%;
min-height: 320px;
}
</style>

@ -0,0 +1,209 @@
<!-- 上传文件组件 对应源代码 UploadFile.tsx -->
<!-- 包含 LocalUploadFile OnlineUploadFile 两个子组件 -->
<template>
<!-- 本地上传文件 -->
<div v-if="type === 'local'" :class="createStyles.fieldCard">
<div :class="createStyles.iconBox">
<ElIcon><UploadFilled /></ElIcon>
</div>
<div v-loading="uploading" :class="createStyles.fullFormItem">
<ElFormItem label="文件上传" :class="createStyles.fullFormItem">
<div :class="createStyles.uploadBlock">
<ElUpload
drag
multiple
:accept="accept"
:file-list="(fileList as any)"
:before-upload="handleBeforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
:show-file-list="true"
>
<div :class="createStyles.uploadMain">
<span>拖拽文件或文件夹至此或者</span>
<span :class="createStyles.uploadLink">选择文件</span>
</div>
<template #tip>
<div :class="createStyles.uploadDesc">
已支持
{{ allowedExtensions.map((item) => item.toUpperCase()).join("、") }}每个文件不超过 15MB
</div>
</template>
</ElUpload>
</div>
</ElFormItem>
</div>
</div>
<!-- 展示已上传到线上的文件嵌入状态 -->
<div v-else :class="commonStyles.verificationBlock">
<div :class="commonStyles.verificationBlockTitle">待嵌入文件</div>
<div v-if="!fileList.length" :class="commonStyles.filePreviewEmpty"></div>
<div v-else :class="commonStyles.filePreviewList">
<div
v-for="(file, index) in fileList"
:key="file.uid"
:class="commonStyles.filePreviewItemStatic"
>
<div :class="commonStyles.filePreviewIcon">
<FileIcon
:extension="getExt(file.name) || 'file'"
v-bind="getIconStyle(file.name)"
/>
</div>
<div :class="commonStyles.filePreviewMeta">
<div :class="commonStyles.filePreviewName">{{ file.name ?? "" }}</div>
<ElProgress
v-if="indexingStatusList[index]"
:percentage="getProgress(index)"
:show-text="false"
:status="isReady(index) ? 'success' : undefined"
:class="commonStyles.fileIndexingProgress"
:stroke-width="6"
/>
</div>
<div :class="commonStyles.fileStatusIcon">
<ElIcon v-if="isReady(index)" :class="commonStyles.fileStatusIconDone">
<CircleCheckFilled />
</ElIcon>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "UploadFile" });
import { UploadFilled, CircleCheckFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import FileIcon from "./FileIcon.vue";
import { getFileIconStyle } from "./fileStyles";
import { useDifyPost } from "../useDifyApis";
import { knowledgeBase } from "../api";
import type { UploadFile, IndexingStatusItem } from "../types";
import createStyles from "../style/createKnowledge.module.scss";
import commonStyles from "../style/common.module.scss";
interface Props {
type: "local" | "online";
fileList: UploadFile[];
allowedExtensions?: string[];
indexingStatusList?: IndexingStatusItem[];
onFileListChange?: (fileList: UploadFile[]) => void;
setFileIds?: (fileIds: string[]) => void;
}
const props = withDefaults(defineProps<Props>(), {
type: "local",
allowedExtensions: () => [],
indexingStatusList: () => [],
onFileListChange: () => {},
setFileIds: () => {},
});
//
const accept = computed(() => {
if (props.type !== "local") return "";
return props.allowedExtensions.map((item) => `.${item}`).join(",");
});
const uploading = ref(false);
const { doPost: uploadFile } = useDifyPost({
url: knowledgeBase.upload,
});
function getExt(filename: string): string {
if (!filename) return "";
if (!filename.includes(".")) return "";
return filename.split(".").pop()?.toLowerCase() || "";
}
function getIconStyle(filename: string) {
return getFileIconStyle(getExt(filename));
}
function handleBeforeUpload(file: File): boolean {
if (props.type !== "local") return false;
const extension = file.name.split(".").pop() || "";
const isSupported = props.allowedExtensions.includes(extension);
if (!isSupported) {
ElMessage.error(`仅支持 ${props.allowedExtensions.join("、")} 文件`);
return false;
}
return false;
}
// 线
async function handleUpload(file: File) {
const formData = new FormData();
formData.append("file", file);
return uploadFile({
config: {
params: {
source: "datasets",
},
},
data: formData,
});
}
//
async function uploadFiles(fileList: UploadFile[]) {
try {
const files = fileList.map((item) => item.originFileObj || item.raw);
const results = await Promise.all(
files.map((file) => handleUpload(file as File))
);
return results.map((item: any) => item.id);
} catch (error) {
ElMessage.error("文件上传失败,请稍后重试!");
return null;
}
}
async function handleFileChange(file: any, fileList: any) {
if (props.type !== "local") return;
//
const extension = file.name?.split(".").pop() || "";
if (!props.allowedExtensions.includes(extension)) {
return;
}
props.onFileListChange(fileList as UploadFile[]);
uploading.value = true;
const ids = await uploadFiles(fileList as UploadFile[]);
uploading.value = false;
if (ids) props.setFileIds(ids as string[]);
}
// 线
function getIndexingProgress(status?: IndexingStatusItem): number {
if (!status) return 0;
if (status.indexing_status === "completed") return 100;
const progressMap: Record<string, number> = {
parsing: 10,
cleaning: 30,
splitting: 50,
indexing: 80,
};
const stageBase = progressMap[status.indexing_status] ?? 0;
if (status.total_segments > 0) {
const segProgress = status.completed_segments / status.total_segments;
return Math.floor(stageBase + segProgress * 20);
}
return stageBase;
}
function getProgress(index: number): number {
if (props.type !== "online") return 0;
const status = props.indexingStatusList[index];
const progress = getIndexingProgress(status);
return isReady(index) ? 100 : progress;
}
function isReady(index: number): boolean {
if (props.type !== "online") return false;
return props.indexingStatusList[index]?.indexing_status === "completed";
}
</script>

@ -0,0 +1,36 @@
<!-- docx 文件预览 对应源代码 docPreview/DocxPreview.tsx -->
<template>
<div :class="styles.docxPreviewContainer">
<div ref="containerRef" :class="styles.docxPreviewBody" />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { renderAsync } from "docx-preview";
import styles from "../../style/doc.module.scss";
interface Props {
file: Blob | ArrayBuffer | null | undefined;
}
const props = defineProps<Props>();
const containerRef = ref<HTMLDivElement | null>(null);
async function renderDocx() {
if (!props.file || !containerRef.value) return;
containerRef.value.innerHTML = "";
await renderAsync(props.file, containerRef.value);
}
onMounted(() => {
renderDocx();
});
watch(
() => props.file,
() => {
renderDocx();
}
);
</script>

@ -0,0 +1,80 @@
<!-- Excel / CSV 文件预览 对应源代码 docPreview/ExcelPreview.tsx -->
<template>
<div v-if="error" :class="styles.docPreviewLoading">
<ElEmpty :description="error" />
</div>
<div v-else-if="!sheetNames.length" :class="styles.docPreviewLoading">
<ElEmpty description="暂无内容" />
</div>
<div v-else :class="styles.excelPreviewContainer">
<ElTabs v-model="activeSheet">
<ElTabPane
v-for="name in sheetNames"
:key="name"
:label="name"
:name="name"
>
<div :class="styles.excelSheet" v-html="htmlMap[name] || ''" />
</ElTabPane>
</ElTabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import * as XLSX from "xlsx";
import styles from "../../style/doc.module.scss";
interface Props {
file: ArrayBuffer;
extension: string;
}
const props = defineProps<Props>();
const htmlMap = ref<Record<string, string>>({});
const sheetNames = ref<string[]>([]);
const activeSheet = ref<string>("");
const error = ref<string>("");
function parseWorkbook() {
error.value = "";
try {
const ext = String(props.extension || "").toLowerCase();
const workbook =
ext === "csv"
? XLSX.read(new TextDecoder().decode(props.file), { type: "string" })
: XLSX.read(props.file, { type: "array" });
const names = Array.isArray(workbook.SheetNames) ? workbook.SheetNames : [];
const nextHtmlMap: Record<string, string> = {};
names.forEach((name) => {
const sheet = workbook.Sheets[name];
if (sheet) {
nextHtmlMap[name] = XLSX.utils.sheet_to_html(sheet);
}
});
sheetNames.value = names;
htmlMap.value = nextHtmlMap;
activeSheet.value =
activeSheet.value && names.includes(activeSheet.value)
? activeSheet.value
: names[0] || "";
} catch {
sheetNames.value = [];
htmlMap.value = {};
activeSheet.value = "";
error.value = "解析失败";
}
}
onMounted(() => {
parseWorkbook();
});
watch(
() => [props.file, props.extension],
() => {
parseWorkbook();
}
);
</script>

@ -0,0 +1,36 @@
<!-- HTML 文件预览 对应源代码 docPreview/HtmlPreview.tsx -->
<template>
<div :class="styles.htmlPreviewContainer">
<div :class="styles.htmlPreviewBody" v-html="html" />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import DOMPurify from "dompurify";
import styles from "../../style/doc.module.scss";
interface Props {
file: ArrayBuffer;
}
const props = defineProps<Props>();
const html = ref<string>("");
function sanitizeHtml() {
const content = new TextDecoder().decode(props.file);
html.value = DOMPurify.sanitize(content);
}
onMounted(() => {
sanitizeHtml();
});
watch(
() => props.file,
() => {
sanitizeHtml();
}
);
</script>

@ -0,0 +1,33 @@
<!-- PDF 文件预览 基于 @file-viewer/vue3 渲染 -->
<template>
<div :class="styles.pdfPreviewContainer">
<FileViewer
v-if="file"
:file="file"
:filename="filename"
:options="options"
style="height: 100%"
/>
<ElSkeleton v-else :rows="8" animated style="padding: 24px" />
</div>
</template>
<script setup lang="ts">
import allPreset from "@file-viewer/preset-all";
import { FileViewer } from "@file-viewer/vue3";
import styles from "../../style/doc.module.scss";
interface Props {
file: ArrayBuffer | null | undefined;
filename?: string;
}
defineProps<Props>();
const options = {
preset: allPreset,
rendererMode: "replace" as const,
theme: "light" as const,
toolbar: { position: "bottom-right" as const },
};
</script>

@ -0,0 +1,72 @@
<!-- 文本 / Markdown 文件预览 对应源代码 docPreview/TextPreview.tsx -->
<template>
<div :class="styles.textPreviewContainer">
<!-- Markdown 渲染 -->
<div v-if="isMarkdown" :class="styles.markdownPreviewBody" v-html="html" />
<!-- 纯文本 -->
<pre v-else :class="styles.textPreviewBody">{{ content }}</pre>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import styles from "../../style/doc.module.scss";
interface Props {
file: ArrayBuffer;
extension: string;
}
const props = defineProps<Props>();
const markdownExtensions = ["md", "markdown", "mdx"];
// markdown-it highlight.js
const md = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
highlight(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return (
'<pre><code class="hljs language-' +
lang +
'">' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
"</code></pre>"
);
} catch {
// fall through
}
}
return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + "</code></pre>";
},
});
const content = ref<string>("");
const html = ref<string>("");
const isMarkdown = computed(() =>
markdownExtensions.includes(String(props.extension || "").toLowerCase())
);
function decodeAndRender() {
content.value = new TextDecoder().decode(props.file);
if (isMarkdown.value) {
html.value = md.render(content.value);
}
}
onMounted(() => {
decodeAndRender();
});
watch(
() => [props.file, props.extension],
() => {
decodeAndRender();
}
);
</script>

@ -0,0 +1,182 @@
<!-- 文档预览主组件 对应源代码 docPreview/index.tsx -->
<template>
<div :class="styles.docPreviewCard">
<div :class="styles.docPreviewHeader">
<ElButton text :icon="ArrowLeft" @click="handleBack" />
<div :class="styles.docPreviewHeaderTitle">
<span :class="styles.docPreviewFileName" :title="filename || '-'">
{{ filename || "-" }}
</span>
<span v-if="extension" :class="styles.docPreviewFileExt">
{{ extension.toUpperCase() }}
</span>
</div>
</div>
<div ref="bodyRef" :class="styles.docPreviewBody">
<!-- 加载中 -->
<div v-if="loading" :class="styles.docPreviewLoading">
<ElIcon class="is-loading" :size="32"><Loading /></ElIcon>
<span :class="styles.docPreviewLoadingTip">文件正在加载中请稍等...</span>
</div>
<!-- 错误 -->
<div v-else-if="fileError" :class="styles.docPreviewLoading">
<ElEmpty :description="fileError" />
</div>
<!-- 预览内容 -->
<template v-else-if="fileBuffer">
<PdfPreview v-if="extension === 'pdf'" :file="fileBuffer" :filename="filename" />
<DocxPreview v-else-if="extension === 'docx'" :file="fileBuffer" />
<ExcelPreview
v-else-if="['xlsx', 'xls', 'csv'].includes(extension)"
:file="fileBuffer"
:extension="extension"
/>
<TextPreview
v-else-if="['md', 'markdown', 'mdx', 'txt', 'properties', 'vtt'].includes(extension)"
:file="fileBuffer"
:extension="extension"
/>
<HtmlPreview v-else-if="['html', 'htm'].includes(extension)" :file="fileBuffer" />
<div v-else :class="styles.docPreviewLoading">
<ElEmpty description="暂不支持该文件类型预览" />
</div>
</template>
</div>
<ElButton
v-if="fileBuffer && !loading"
:class="styles.docPreviewBackTop"
circle
:icon="Top"
@click="handleScrollToTop"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, shallowRef } from "vue";
import { ArrowLeft, Top, Loading } from "@element-plus/icons-vue";
import { useRequest } from "../../useDifyApis";
import { knowledgeBase } from "../../api";
import PdfPreview from "./PdfPreview.vue";
import DocxPreview from "./DocxPreview.vue";
import ExcelPreview from "./ExcelPreview.vue";
import TextPreview from "./TextPreview.vue";
import HtmlPreview from "./HtmlPreview.vue";
import styles from "../../style/doc.module.scss";
interface Props {
setAddDocumentModalOpen: (type: "documents" | "add" | "preview") => void;
datasetId: string;
record: any | null;
}
const props = defineProps<Props>();
const { doRequest } = useRequest();
const loading = ref(false);
const fileBuffer = shallowRef<ArrayBuffer | null>(null);
const fileError = ref<string>("");
const bodyRef = ref<HTMLElement | null>(null);
let requestedKey = "";
const filename = computed(() => String(props.record?.name || ""));
const documentId = computed(() => String(props.record?.id || "").trim());
const extension = computed(() => {
const fromRecord =
props.record?.data_source_detail_dict?.upload_file?.extension || "";
const ext = String(fromRecord || filename.value.split(".").pop() || "")
.trim()
.toLowerCase();
return ext;
});
//
function getPreviewPath(url: string): string {
try {
const previewUrl = new URL(url);
return `${previewUrl.pathname}${previewUrl.search}`;
} catch {
return url;
}
}
async function loadFile() {
fileBuffer.value = null;
fileError.value = "";
if (!props.record) {
fileError.value = "未选择文件";
return;
}
if (!props.datasetId) {
fileError.value = "缺少知识库 ID";
return;
}
if (!documentId.value) {
fileError.value = "缺少文档 ID";
return;
}
const requestKey = `${props.datasetId}-${documentId.value}`;
if (requestedKey === requestKey && fileBuffer.value) return;
requestedKey = requestKey;
loading.value = true;
try {
const resp: any = await doRequest({
url: knowledgeBase.downloadDocument(props.datasetId, documentId.value),
method: "get",
});
const previewUrl = String(((resp as any)?.data ?? resp)?.url || "");
if (!previewUrl) {
fileError.value = "文件地址获取失败";
return;
}
const fileResp: any = await doRequest({
url: getPreviewPath(previewUrl),
method: "get",
responseType: "arraybuffer",
});
const data = (fileResp as any)?.data ?? fileResp;
const buffer =
data instanceof ArrayBuffer
? data
: data?.buffer instanceof ArrayBuffer
? data.buffer
: null;
if (!buffer) {
fileError.value = "文件获取失败";
return;
}
fileBuffer.value = buffer;
} catch (e: any) {
fileError.value = e?.message || "文件加载失败";
} finally {
loading.value = false;
}
}
function handleBack() {
props.setAddDocumentModalOpen("documents");
}
function handleScrollToTop() {
if (bodyRef.value) {
bodyRef.value.scrollTo({ top: 0, behavior: "smooth" });
}
}
onMounted(() => {
loadFile();
});
</script>

@ -0,0 +1,114 @@
// 文件图标默认样式表 —— 对应 react-file-icon 的 defaultStyles
// 提供各类文件扩展名对应的颜色配置
export type FileIconStyle = {
type?: string;
color?: string;
gradient?: string;
labelColor?: string;
labelTextColor?: string;
};
export const defaultStyles: Record<string, FileIconStyle> = {
// PDF
pdf: { type: "pdf", color: "#D32F2F", labelColor: "#D32F2F", labelTextColor: "#fff" },
// Word
doc: { type: "doc", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
docx: { type: "docx", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
docm: { type: "docm", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
dot: { type: "dot", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
dotx: { type: "dotx", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
dotm: { type: "dotm", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
pages: { type: "pages", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
// PowerPoint
ppt: { type: "ppt", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
pptx: { type: "pptx", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
pptm: { type: "pptm", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
pot: { type: "pot", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
potx: { type: "potx", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
potm: { type: "potm", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
key: { type: "key", color: "#EF6C00", labelColor: "#EF6C00", labelTextColor: "#fff" },
// Excel
xls: { type: "xls", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
xlsx: { type: "xlsx", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
xlsm: { type: "xlsm", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
xlt: { type: "xlt", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
xltx: { type: "xltx", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
xltm: { type: "xltm", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
numbers: { type: "numbers", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
// 压缩
zip: { type: "zip", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
rar: { type: "rar", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
"7z": { type: "7z", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
gz: { type: "gz", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
tar: { type: "tar", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
// 代码
js: { type: "js", color: "#FBC02D", labelColor: "#FBC02D", labelTextColor: "#fff" },
jsx: { type: "jsx", color: "#FBC02D", labelColor: "#FBC02D", labelTextColor: "#fff" },
ts: { type: "ts", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
tsx: { type: "tsx", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
json: { type: "json", color: "#F9A825", labelColor: "#F9A825", labelTextColor: "#fff" },
html: { type: "html", color: "#E53935", labelColor: "#E53935", labelTextColor: "#fff" },
css: { type: "css", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
scss: { type: "scss", color: "#C2185B", labelColor: "#C2185B", labelTextColor: "#fff" },
less: { type: "less", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
vue: { type: "vue", color: "#41B883", labelColor: "#41B883", labelTextColor: "#fff" },
py: { type: "py", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
java: { type: "java", color: "#E53935", labelColor: "#E53935", labelTextColor: "#fff" },
c: { type: "c", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
cpp: { type: "cpp", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
h: { type: "h", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
cs: { type: "cs", color: "#6A1B9A", labelColor: "#6A1B9A", labelTextColor: "#fff" },
go: { type: "go", color: "#00ACC1", labelColor: "#00ACC1", labelTextColor: "#fff" },
rs: { type: "rs", color: "#E53935", labelColor: "#E53935", labelTextColor: "#fff" },
rb: { type: "rb", color: "#C2185B", labelColor: "#C2185B", labelTextColor: "#fff" },
php: { type: "php", color: "#6A1B9A", labelColor: "#6A1B9A", labelTextColor: "#fff" },
sh: { type: "sh", color: "#37474F", labelColor: "#37474F", labelTextColor: "#fff" },
// 文本/文档
txt: { type: "txt", color: "#9E9E9E", labelColor: "#9E9E9E", labelTextColor: "#fff" },
md: { type: "md", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
markdown: { type: "markdown", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
rtf: { type: "rtf", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
csv: { type: "csv", color: "#2E7D32", labelColor: "#2E7D32", labelTextColor: "#fff" },
log: { type: "log", color: "#9E9E9E", labelColor: "#9E9E9E", labelTextColor: "#fff" },
// 图片
png: { type: "png", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
jpg: { type: "jpg", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
jpeg: { type: "jpeg", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
gif: { type: "gif", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
bmp: { type: "bmp", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
svg: { type: "svg", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
webp: { type: "webp", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
ico: { type: "ico", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
tiff: { type: "tiff", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
// 音视频
mp3: { type: "mp3", color: "#E91E63", labelColor: "#E91E63", labelTextColor: "#fff" },
wav: { type: "wav", color: "#E91E63", labelColor: "#E91E63", labelTextColor: "#fff" },
flac: { type: "flac", color: "#E91E63", labelColor: "#E91E63", labelTextColor: "#fff" },
mp4: { type: "mp4", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
avi: { type: "avi", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
mov: { type: "mov", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
wmv: { type: "wmv", color: "#7B1FA2", labelColor: "#7B1FA2", labelTextColor: "#fff" },
// 其他
xml: { type: "xml", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
yaml: { type: "yaml", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
yml: { type: "yml", color: "#1565C0", labelColor: "#1565C0", labelTextColor: "#fff" },
sql: { type: "sql", color: "#6A1B9A", labelColor: "#6A1B9A", labelTextColor: "#fff" },
db: { type: "db", color: "#6A1B9A", labelColor: "#6A1B9A", labelTextColor: "#fff" },
file: { type: "file", color: "#9E9E9E", labelColor: "#9E9E9E", labelTextColor: "#fff" },
};
// 获取扩展名对应的图标样式,找不到时使用 txt 作为默认
export function getFileIconStyle(ext: string): FileIconStyle {
const key = String(ext || "").toLowerCase();
return defaultStyles[key] ?? defaultStyles.txt ?? { color: "#9E9E9E", labelColor: "#9E9E9E", labelTextColor: "#fff" };
}

@ -0,0 +1,651 @@
<!-- 知识库列表页 对应源代码 aiKnowledge/index.tsx -->
<template>
<div class="knowledge-page">
<div :class="styles.toolbar">
<ElInput
v-model="keyword"
clearable
placeholder="请输入知识库名称"
class="search-input"
@clear="() => { keyword = ''; fetchPageList(1, pagination.limit, ''); }"
@keyup.enter="handleSearch"
/>
<ElButton type="primary" @click="handleSearch"></ElButton>
<ElButton :icon="Refresh" :class="styles.syncIcon" @click="() => fetchPageList()" />
</div>
<div v-loading="pageListLoading" class="list-wrapper">
<div v-if="safePageList.length > 0" :class="styles.container">
<div :class="styles.grid">
<div v-if="knowledgeBaseIndexAfterRequest === 1">
<button type="button" :class="styles.createCard" @click="goCreate">
<span :class="styles.createIcon">
<ElIcon><Plus /></ElIcon>
</span>
<span :class="styles.createTitle">创建知识库</span>
</button>
</div>
<div
v-for="item in safePageList"
:key="item.id"
>
<ElCard
shadow="hover"
:class="styles.card"
class="knowledge-card"
@click="goDocuments(item.id)"
>
<ElDropdown
trigger="click"
placement="bottom-end"
@command="(cmd: string) => handleCardCommand(cmd, item)"
>
<button
type="button"
:class="styles.cardMore"
:disabled="deleteSubmittingId === item.id"
@click.stop
>
<ElIcon><MoreFilled /></ElIcon>
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">
<ElIcon><Edit /></ElIcon>
编辑
</ElDropdownItem>
<ElDropdownItem command="delete" divided>
<ElIcon><Delete /></ElIcon>
删除
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<div :class="styles.cardHeader">
<div :class="styles.iconWrapper" :style="{ background: getBgColor(item) }">
📚
<span :class="styles.iconBadge">
<ElIcon><Reading /></ElIcon>
</span>
</div>
<div :class="styles.cardTitle">
<span :class="styles.cardName" :title="item.name">{{ item.name }}</span>
<div :class="styles.cardMeta">
{{ item.authorName }}
<span v-if="item.updatedAt > 0"> · {{ timeAgo(item.updatedAt) }}</span>
</div>
<div :class="styles.badgeRow">
<span v-if="getPermissionLabel(item.permission)" :class="styles.permissionBadge">
{{ getPermissionLabel(item.permission) }}
</span>
<span v-if="getTechniqueLabel(item.indexingTechnique)" :class="styles.techniqueBadge">
{{ getTechniqueLabel(item.indexingTechnique) }}
<span v-if="getSearchLabel(item.searchMethod)"> · {{ getSearchLabel(item.searchMethod) }}</span>
</span>
</div>
</div>
</div>
<div :class="styles.description">
<span v-if="item.description" :title="item.description">{{ item.description }}</span>
<span v-else :class="styles.descriptionEmpty">暂无描述</span>
</div>
<div :class="styles.cardFooter">
<ElIcon><Reading /></ElIcon>
<span>{{ item.documentCount }}</span>
<ElIcon :class="styles.footerAppCount"><Monitor /></ElIcon>
<span>{{ item.appCount }}</span>
<template v-if="item.updatedAt > 0">
<span :class="styles.footerDivider">/</span>
<span>更新于 {{ timeAgo(item.updatedAt) }}</span>
</template>
</div>
</ElCard>
</div>
</div>
<div :class="styles.pagination">
<ElPagination
:current-page="pagination.page"
:total="total"
:page-size="pagination.limit"
:page-sizes="[12, 24, 32, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(page: number) => fetchPageList(page, pagination.limit)"
@size-change="(size: number) => fetchPageList(1, size)"
/>
</div>
</div>
<ElEmpty v-else description="暂无知识库数据" />
</div>
<ElDialog
v-model="editModalOpen"
title="编辑知识库"
width="520px"
destroy-on-close
>
<ElForm ref="editFormRef" :model="editForm" label-position="top">
<ElFormItem label="名称" prop="name" :rules="nameRules">
<ElInput v-model="editForm.name" placeholder="请输入知识库名称" :maxlength="50" show-word-limit />
</ElFormItem>
<ElFormItem label="描述" prop="description" :rules="descRules">
<ElInput
v-model="editForm.description"
type="textarea"
:rows="3"
placeholder="请输入知识库描述"
:maxlength="200"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="closeEditModal"></ElButton>
<ElButton type="primary" :loading="editSubmitting" @click="submitEdit"></ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "AiKnowledge" });
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import {
Plus,
MoreFilled,
Edit,
Delete,
Reading,
Monitor,
Refresh,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type FormInstance } from "element-plus";
import { useDifyGet, useDifyPost, useRequest } from "./useDifyApis";
import { knowledgeBase } from "./api";
import { encryptPassword, getCookie } from "./types";
import styles from "./style/index.module.scss";
const PAGE_SIZE = 12;
type KnowledgeCardItem = {
id: string;
name: string;
description: string;
authorName: string;
permission: string;
indexingTechnique: string;
searchMethod: string;
documentCount: number;
appCount: number;
updatedAt: number;
tags: Array<string | number>;
iconBackground: string | null;
};
const INDEXING_TECHNIQUE_LABEL: Record<string, string> = {
high_quality: "高质量",
economy: "经济",
};
const SEARCH_METHOD_LABEL: Record<string, string> = {
semantic_search: "语义检索",
keyword_search: "关键词检索",
full_text_search: "全文检索",
hybrid_search: "混合检索",
};
const PERMISSION_LABEL: Record<string, string> = {
only_me: "仅自己",
all_team_members: "所有成员",
partial_members: "部分成员",
};
const ICON_BG_COLORS = [
"#e8f4ff",
"#fff3e0",
"#e8f5e9",
"#fce4ec",
"#ede7f6",
"#e0f2f1",
"#fff9c4",
"#fbe9e7",
];
function timeAgo(ts: number): string {
if (!ts) return "-";
const diff = Math.floor(Date.now() / 1000 - ts);
if (diff < 60) return "刚刚";
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
return `${Math.floor(diff / 86400)} 天前`;
}
function hashColor(id: string): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xffff;
return ICON_BG_COLORS[h % ICON_BG_COLORS.length] ?? "#e8f4ff";
}
const router = useRouter();
const pagination = ref({ page: 1, limit: PAGE_SIZE });
const keyword = ref("");
const knowledgeBaseIndexAfterRequest = ref(1);
const editModalOpen = ref(false);
const editingItem = ref<KnowledgeCardItem | null>(null);
const editSubmitting = ref(false);
const deleteSubmittingId = ref<string | null>(null);
const editForm = reactive({ name: "", description: "" });
const editFormRef = ref<FormInstance | null>(null);
const nameRules = [
{ required: true, message: "请输入知识库名称", trigger: "blur" },
{ max: 50, message: "最多 50 个字符", trigger: "blur" },
];
const descRules = [{ max: 200, message: "最多 200 个字符", trigger: "blur" }];
const { doRequest } = useRequest();
//
const { doPost: loginDo } = useDifyPost({
url: knowledgeBase.login,
data: {
email: "gdut@gdut.com",
password: encryptPassword("gdut1234"),
language: "zh-Hans",
remember_me: true,
},
});
//
const { doGet: pageList, loading: pageListLoading, result: pageListResult } = useDifyGet({
url: knowledgeBase.pageList,
params: { limit: PAGE_SIZE, page: 1 },
});
const safePageList = computed<KnowledgeCardItem[]>(() => {
const rawList = Array.isArray((pageListResult.value as any)?.data)
? (pageListResult.value as any).data
: [];
if ((pageListResult.value as any)?.page) {
knowledgeBaseIndexAfterRequest.value =
(pageListResult.value as any).page || pagination.value.page || 1;
}
return rawList
.filter((item: any) => item && typeof item === "object" && item.id && item.name)
.map((item: any) => ({
id: String(item.id),
name: String(item.name),
description:
typeof item.description === "string" && item.description.trim()
? item.description
: "",
authorName:
typeof item.author_name === "string" && item.author_name.trim()
? item.author_name
: "-",
permission: typeof item.permission === "string" ? item.permission : "",
indexingTechnique:
typeof item.indexing_technique === "string" ? item.indexing_technique : "",
searchMethod:
typeof item.retrieval_model_dict?.search_method === "string"
? item.retrieval_model_dict.search_method
: "",
documentCount: Number.isFinite(Number(item.document_count))
? Number(item.document_count)
: 0,
appCount: Number.isFinite(Number(item.app_count)) ? Number(item.app_count) : 0,
updatedAt: Number.isFinite(Number(item.updated_at)) ? Number(item.updated_at) : 0,
tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [],
iconBackground: item.icon_info?.icon_background ?? null,
}));
});
const total = computed(() => Number((pageListResult.value as any)?.total) || 0);
async function fetchPageList(
page = pagination.value.page,
limit = pagination.value.limit,
searchKeyword = keyword.value
) {
pagination.value = { page, limit };
const trimmedKeyword = searchKeyword.trim();
await pageList({
params: {
page,
limit,
...(trimmedKeyword ? { keyword: trimmedKeyword } : {}),
},
});
}
function handleSearch() {
fetchPageList(1, pagination.value.limit);
}
function goCreate() {
router.push("/platform/ai/knowledge/createKnowledge");
}
function goDocuments(id: string) {
router.push(`/platform/ai/knowledge/documents/${id}`);
}
function getBgColor(item: KnowledgeCardItem): string {
return item.iconBackground ?? hashColor(item.id);
}
function getTechniqueLabel(value: string): string {
return INDEXING_TECHNIQUE_LABEL[value] ?? value;
}
function getSearchLabel(value: string): string {
return SEARCH_METHOD_LABEL[value] ?? value;
}
function getPermissionLabel(value: string): string {
return PERMISSION_LABEL[value] ?? value;
}
function openEditModal(item: KnowledgeCardItem) {
editingItem.value = item;
editForm.name = item.name;
editForm.description = item.description;
editModalOpen.value = true;
}
function closeEditModal() {
editModalOpen.value = false;
editingItem.value = null;
}
async function submitEdit() {
if (!editingItem.value) return;
try {
await editFormRef.value?.validate();
} catch {
return;
}
const payload = {
name: String(editForm.name ?? "").trim(),
description: String(editForm.description ?? "").trim(),
};
editSubmitting.value = true;
try {
await doRequest({
url: knowledgeBase.queryKnowledgeRule(editingItem.value.id),
method: "patch",
data: payload,
});
ElMessage.success("编辑成功");
closeEditModal();
await fetchPageList(pagination.value.page, pagination.value.limit);
} catch (e: any) {
ElMessage.error(e?.message || "编辑失败");
} finally {
editSubmitting.value = false;
}
}
function confirmDelete(item: KnowledgeCardItem) {
ElMessageBox.confirm("此操作不可逆,请谨慎操作。", "确定要删除该知识库吗?", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
confirmButtonClass: "el-button--danger",
})
.then(async () => {
deleteSubmittingId.value = item.id;
try {
await doRequest({
url: knowledgeBase.queryKnowledgeRule(item.id),
method: "delete",
});
ElMessage.success("删除成功");
await fetchPageList(1, pagination.value.limit);
} catch (e: any) {
ElMessage.error(e?.message || "删除失败");
} finally {
deleteSubmittingId.value = null;
}
})
.catch(() => {});
}
function handleCardCommand(cmd: string, item: KnowledgeCardItem) {
if (cmd === "edit") openEditModal(item);
if (cmd === "delete") confirmDelete(item);
}
onMounted(async () => {
const DIFY_TIMEOUT_KEY = "currentDifyTimeOut";
const EXPIRE_TIME = 60 * 60 * 1000;
const refreshExpireTime = () => {
localStorage.setItem(DIFY_TIMEOUT_KEY, String(Date.now() + EXPIRE_TIME));
};
const loginAndLoad = async (page = 1, limit = PAGE_SIZE) => {
const res = await loginDo();
if ((res as any).result === "success") {
refreshExpireTime();
await fetchPageList(page, limit);
}
};
const init = async () => {
const csrfToken = getCookie("csrf_token");
const timeout = Number(localStorage.getItem(DIFY_TIMEOUT_KEY));
const needLogin =
!csrfToken || !timeout || Number.isNaN(timeout) || Date.now() >= timeout;
try {
if (needLogin) {
await loginAndLoad();
return;
}
await fetchPageList(1, PAGE_SIZE);
} catch {
try {
await loginAndLoad();
} catch (loginError) {
console.error("Dify登录失败", loginError);
}
}
};
init();
});
</script>
<style lang="scss" scoped>
.search-input {
width: 280px;
}
.list-wrapper {
min-height: 200px;
padding: 0 5px;
}
.knowledge-card {
cursor: pointer;
border-radius: 12px;
height: 100%;
border: 1px solid #e5e7eb;
position: relative;
margin-bottom: 16px;
transition: all 0.2s;
:deep(.el-card__body) {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
}
.knowledge-card:hover {
:deep(.cardMore) {
opacity: 1;
pointer-events: auto;
}
}
.cardMore {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(229, 231, 235, 0.9);
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 1;
padding: 0;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
pointer-events: none;
}
:deep(.el-icon) {
font-size: 18px;
color: #6b7280;
}
}
.cardHeader {
display: flex;
gap: 12px;
align-items: flex-start;
}
.iconWrapper {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
position: relative;
}
.iconBadge {
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
:deep(.el-icon) {
font-size: 9px;
color: #fff;
}
}
.cardTitle {
flex: 1;
min-width: 0;
}
.cardName {
font-size: 15px;
font-weight: 600;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #111827;
}
.cardMeta {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.badgeRow {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.permissionBadge {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
border-radius: 4px;
padding: 1px 6px;
}
.techniqueBadge {
font-size: 11px;
color: #6b7280;
}
.description {
font-size: 13px;
color: #374151;
margin: 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.descriptionEmpty {
color: #d1d5db;
}
.cardFooter {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #6b7280;
border-top: 1px solid #f3f4f6;
padding-top: 8px;
}
.footerAppCount {
margin-left: 4px;
}
.footerDivider {
color: #d1d5db;
margin: 0 2px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

@ -0,0 +1,292 @@
// common.module.less
// createKnowledge.module.scss
.segmentRight {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #f0f0f0;
}
.previewTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.previewFileName {
flex: 1;
min-width: 0;
color: #6b7280;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewFileIcon {
width: 18px;
height: 22px;
flex: 0 0 18px;
display: flex;
align-items: center;
justify-content: center;
}
.previewFileIcon :deep(svg) {
width: 18px;
height: 22px;
}
.previewBody {
flex: 1;
min-height: 0;
display: block;
padding: 18px;
overflow: auto;
}
.previewEmptyWrapper {
height: 100%;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.previewEmpty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #9ca3af;
}
.previewEmptyIcon {
font-size: 34px;
color: #9ca3af;
}
.previewEmptyText {
color: #9ca3af;
font-size: 13px;
}
.previewChunkList {
display: flex;
flex-direction: column;
gap: 18px;
}
.previewChunkHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.previewChunkIcon {
color: #9ca3af;
font-size: 14px;
}
.previewChunkMeta {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewChunkContent {
color: #111827;
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-word;
}
.settingCard {
padding: 20px 22px 24px;
border: 2px solid #4096ff;
border-radius: 6px;
background: #fff;
}
.settingHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.settingIcon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #4096ff;
color: #fff;
font-size: 16px;
flex-shrink: 0;
}
.settingName {
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.settingDesc {
margin-top: 14px;
color: #9ca3af;
font-size: 14px;
font-weight: 600;
}
.formRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 22px;
}
.compactFormItem {
margin-bottom: 0;
}
.infoIcon {
color: #9ca3af;
}
.divider {
height: 1px;
margin: 20px 0;
background: #f0f0f0;
}
.ruleTitle {
margin-bottom: 10px;
color: #374151;
font-weight: 700;
}
.urlCheckbox {
margin-left: 28px;
}
.segmentActions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.resetBtn {
padding: 0;
}
.filePreviewEmpty {
padding: 14px 12px;
border: 1px dashed #e5e7eb;
border-radius: 8px;
background: #fff;
color: #9ca3af;
font-size: 13px;
}
.filePreviewList {
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.verificationBlock {
margin-top: 18px;
}
.verificationBlockTitle {
margin-bottom: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.filePreviewItemStatic {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.fileStatusIcon {
width: 18px;
flex: 0 0 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.fileStatusIconDone {
color: #16a34a;
}
.filePreviewIcon {
width: 22px;
height: 28px;
flex: 0 0 22px;
display: flex;
align-items: center;
justify-content: center;
}
.filePreviewIcon :deep(svg) {
width: 22px;
height: 28px;
}
.filePreviewMeta {
flex: 1;
min-width: 0;
}
.filePreviewName {
color: #111827;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fileIndexingProgress {
margin-top: 6px;
}

@ -0,0 +1,676 @@
.page {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 18px 10px;
background: #fff;
}
.formBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.steps {
max-width: 430px;
margin: 0 auto;
}
.content {
padding: 0 30px;
padding-top: 20px;
}
.titleBlock {
margin-bottom: 30px;
}
.sectionTitle {
margin: 0 0 6px;
color: #1f2937;
font-size: 18px;
font-weight: 700;
}
.sectionDesc {
margin: 0;
color: #9ca3af;
font-size: 14px;
}
.fieldCard {
display: flex;
align-items: center;
gap: 20px;
padding: 12px;
margin-bottom: 20px;
border-radius: 12px;
background: #fafafa;
}
.iconBox {
width: 58px;
height: 58px;
flex: 0 0 58px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04);
color: #4096ff;
font-size: 22px;
}
.iconBoxOrange {
color: #fa8c16;
}
.fullFormItem {
flex: 1;
margin-bottom: 0;
}
.fullFormItem :deep(.el-form-item__label) {
padding-bottom: 4px;
}
.uploadBlock {
// margin-top: 28px;
}
.uploadTitle {
margin: 0 0 14px;
color: #344054;
font-size: 18px;
font-weight: 700;
}
.uploadBlock :deep(.el-upload),
.uploadBlock :deep(.el-upload-dragger) {
width: 100%;
}
.uploadBlock :deep(.el-upload-dragger) {
min-height: 128px;
display: flex;
align-items: center;
border: 1px dashed #d9dfe8;
border-radius: 10px;
background: #fbfcfe;
}
.uploadBlock :deep(.el-upload-dragger:hover) {
border-color: #1677ff;
}
.uploadMain {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: #344054;
font-weight: 700;
}
.uploadIcon {
color: #344054;
font-size: 26px;
}
.uploadLink {
color: #155eef;
cursor: pointer;
}
.uploadDesc {
color: #667085;
line-height: 1.55;
word-break: break-word;
}
.segmentContent {
max-width: 1220px;
margin-right: auto;
margin-left: auto;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.segmentTitle {
margin: 0 0 16px;
font-size: 15px;
font-weight: 700;
}
.segmentLayout {
display: flex;
align-items: stretch;
gap: 18px;
flex: 1;
min-height: 0;
}
.segmentLeft {
flex: 0 0 520px;
max-width: 520px;
height: 100%;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
}
.segmentRight {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #f0f0f0;
}
.previewTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.previewFileName {
flex: 1;
min-width: 0;
color: #6b7280;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewFileIcon {
width: 18px;
height: 22px;
flex: 0 0 18px;
display: flex;
align-items: center;
justify-content: center;
}
.previewFileIcon :deep(svg) {
width: 18px;
height: 22px;
}
.previewBody {
flex: 1;
min-height: 0;
display: block;
padding: 18px;
overflow: auto;
}
.previewEmptyWrapper {
height: 100%;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.previewEmpty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #9ca3af;
}
.previewEmptyIcon {
font-size: 34px;
color: #9ca3af;
}
.previewEmptyText {
color: #9ca3af;
font-size: 13px;
}
.previewChunkList {
display: flex;
flex-direction: column;
gap: 18px;
}
.previewChunkHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.previewChunkIcon {
color: #9ca3af;
font-size: 14px;
}
.previewChunkMeta {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewChunkContent {
color: #111827;
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-word;
}
.settingCard {
padding: 20px 22px 24px;
border: 2px solid #4096ff;
border-radius: 6px;
background: #fff;
overflow: auto;
}
.settingHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.settingIcon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #4096ff;
color: #fff;
font-size: 16px;
flex-shrink: 0;
}
.settingName {
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.settingDesc {
margin-top: 14px;
color: #9ca3af;
font-size: 14px;
font-weight: 600;
}
.formRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 22px;
}
.compactFormItem {
margin-bottom: 0;
}
.infoIcon {
color: #9ca3af;
}
.divider {
height: 1px;
margin: 20px 0;
background: #f0f0f0;
}
.ruleTitle {
margin-bottom: 10px;
color: #374151;
font-weight: 700;
}
.urlCheckbox {
margin-left: 28px;
}
.segmentActions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.resetBtn {
padding: 0;
}
.embeddingItem {
margin-top: 20px;
}
.embeddingSelect {
width: 100%;
}
.embeddingGroupLabel {
color: #6b7280;
font-weight: 700;
}
.embeddingOption {
display: flex;
align-items: center;
gap: 12px;
}
.embeddingOptionIcon {
width: 22px;
height: 22px;
flex: 0 0 22px;
border-radius: 6px;
object-fit: contain;
}
.embeddingOptionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: #111827;
}
.retrievalSection {
margin-top: 22px;
}
.retrievalForm {
margin-top: 16px;
}
.retrievalRerankRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.retrievalLabel {
color: #374151;
font-weight: 700;
}
.retrievalModelSelect {
margin-top: 12px;
width: 100%;
}
.retrievalRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.retrievalControl {
padding: 12px 12px 10px;
border-radius: 10px;
background: #fafafa;
}
.retrievalControlHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #374151;
font-weight: 700;
}
.retrievalControlBody {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.retrievalNumberInput {
width: 72px;
flex: 0 0 72px;
}
.retrievalSlider {
flex: 1;
margin: 0;
}
.filePreviewSection {
margin-top: 14px;
}
.filePreviewTitle {
margin-bottom: 8px;
color: #374151;
font-weight: 700;
font-size: 13px;
}
.filePreviewEmpty {
padding: 14px 12px;
border: 1px dashed #e5e7eb;
border-radius: 8px;
background: #fff;
color: #9ca3af;
font-size: 13px;
}
.filePreviewList {
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.verificationBlock {
margin-top: 18px;
}
.verificationBlockTitle {
margin-bottom: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
}
.filePreviewItemStatic {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.fileStatusIcon {
width: 18px;
flex: 0 0 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.fileStatusIconDone {
color: #16a34a;
}
.fileStatusIconPending {
color: #9ca3af;
}
.verificationGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 22px;
padding: 12px 12px;
border-radius: 10px;
background: #fafafa;
}
.verificationItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.verificationLabel {
color: #6b7280;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.verificationValue {
flex: 1;
min-width: 0;
text-align: right;
color: #111827;
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filePreviewItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.filePreviewItem:hover {
border-color: #4096ff;
background: #f5faff;
}
.filePreviewItemSelected {
border: 2px solid #4096ff;
background: #f5faff;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.05);
}
.filePreviewItemSelected:hover {
border-color: #4096ff;
}
.filePreviewIcon {
width: 22px;
height: 28px;
flex: 0 0 22px;
display: flex;
align-items: center;
justify-content: center;
}
.filePreviewIcon :deep(svg) {
width: 22px;
height: 28px;
}
.filePreviewMeta {
flex: 1;
min-width: 0;
}
.filePreviewName {
color: #111827;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.filePreviewSub {
display: flex;
align-items: center;
gap: 10px;
margin-top: 2px;
}
.fileIndexingProgress {
margin-top: 6px;
}
.filePreviewSize {
color: #9ca3af;
font-size: 11px;
}
.emptyStep {
min-height: 420px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 10px;
}
@media (max-width: 1060px) {
.segmentLayout {
flex-direction: column;
}
.segmentLeft {
flex: 1;
max-width: none;
}
.retrievalRow {
grid-template-columns: 1fr;
}
}

@ -0,0 +1,370 @@
// style/doc.module.less
.docPreviewCard {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border-radius: 12px;
border: 1px solid #edf0f5;
overflow: hidden;
}
.docPreviewHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #eef2f7;
background: #fafbfc;
flex-shrink: 0;
}
.docPreviewHeaderTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.docPreviewFileName {
font-size: 15px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.docPreviewFileExt {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
border-radius: 4px;
padding: 2px 6px;
flex-shrink: 0;
}
.docPreviewBody {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.docPreviewLoading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 80px 20px;
color: #6b7280;
}
.docPreviewLoadingTip {
font-size: 13px;
color: #9ca3af;
}
.docPreviewError {
color: #ef4444;
font-size: 13px;
padding: 24px;
text-align: center;
}
.docPreviewBackTop {
position: absolute;
right: 24px;
bottom: 24px;
z-index: 10;
}
// ============ docx ============
.docxPreviewContainer {
width: 100%;
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 24px 0;
}
.docxPreviewBody {
max-width: 900px;
margin: 0 auto;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 48px 64px;
min-height: 100%;
:deep(.docx-wrapper) {
background: transparent;
padding: 0;
.docx {
box-shadow: none;
margin-bottom: 0;
padding: 0;
}
}
}
// ============ PDF ============
.pdfPreviewContainer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #525659;
// padding: 16px;
iframe {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
}
// ============ Excel ============
.excelPreviewContainer {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: #fff;
:deep(.el-tabs) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.el-tabs__header) {
margin: 0;
padding: 0 16px;
flex-shrink: 0;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.el-tab-pane) {
height: 100%;
overflow: auto;
}
}
.excelSheet {
padding: 12px 16px;
overflow: auto;
table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
}
td {
border: 1px solid #e4e7ed;
padding: 4px 8px;
white-space: nowrap;
color: #333;
}
}
// ============ / Markdown ============
.textPreviewContainer {
width: 100%;
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 24px 0;
}
.textPreviewBody {
max-width: 900px;
margin: 0 auto;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 32px 48px;
min-height: 100%;
font-size: 13px;
line-height: 1.8;
color: #333;
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.markdownPreviewBody {
max-width: 900px;
margin: 0 auto;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 32px 48px;
min-height: 100%;
// Markdown
:deep(h1) {
font-size: 24px;
font-weight: 700;
margin: 24px 0 16px;
color: #1a1a1a;
}
:deep(h2) {
font-size: 20px;
font-weight: 700;
margin: 20px 0 14px;
color: #2c2c2c;
}
:deep(h3) {
font-size: 17px;
font-weight: 600;
margin: 18px 0 12px;
color: #3a3a3a;
}
:deep(h4) {
font-size: 15px;
font-weight: 600;
margin: 14px 0 10px;
color: #444;
}
:deep(h5) {
font-size: 14px;
font-weight: 600;
margin: 12px 0 8px;
color: #555;
}
:deep(h6) {
font-size: 13px;
font-weight: 600;
margin: 10px 0 8px;
color: #666;
}
:deep(p) {
font-size: 14px;
line-height: 1.8;
margin: 0 0 12px;
color: #333;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
:deep(th) {
border: 1px solid #d0d5dd;
padding: 8px 12px;
background: #f9fafb;
text-align: left;
font-weight: 600;
}
:deep(td) {
border: 1px solid #e4e7ed;
padding: 8px 12px;
vertical-align: top;
}
:deep(ul),
:deep(ol) {
margin: 0 0 12px;
padding-left: 24px;
}
:deep(li) {
font-size: 14px;
line-height: 1.8;
margin-bottom: 4px;
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 8px 0;
}
:deep(blockquote) {
margin: 12px 0;
padding: 8px 16px;
border-left: 4px solid #d1d5db;
background: #f9fafb;
color: #6b7280;
}
:deep(pre) {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
font-size: 13px;
line-height: 1.6;
}
:deep(code) {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
:deep(:not(pre) > code) {
background: #f3f4f6;
color: #dc2626;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(a) {
color: #1677ff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(hr) {
border: none;
border-top: 1px solid #e4e7ed;
margin: 20px 0;
}
}
// ============ HTML ============
.htmlPreviewContainer {
width: 100%;
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 24px 0;
}
.htmlPreviewBody {
max-width: 900px;
margin: 0 auto;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 32px 48px;
min-height: 100%;
}

@ -0,0 +1,196 @@
.page {
min-height: calc(100vh - 90px);
padding: 22px 20px 14px;
background: #f7f9fc;
color: #1f2937;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.title {
margin: 0 0 8px !important;
color: #111827 !important;
font-size: 18px !important;
line-height: 1.2 !important;
}
.description {
font-size: 13px;
line-height: 20px;
color: #6b7280;
}
.learnMore {
height: auto;
padding: 0 0 0 4px;
font-size: 13px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.toolbarLeft,
.toolbarRight {
display: flex;
align-items: center;
gap: 10px;
}
.toolbarLeft {
min-width: 0;
flex: 1;
}
.typeSelect {
width: 150px;
}
.searchInput {
width: 220px;
}
.sortButton {
min-width: 144px;
color: #5f6b7a;
}
.tableCard {
flex: 1;
min-height: 0;
background: #fff;
border-radius: 12px;
border: 1px solid #edf0f5;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.03);
:deep(.el-table-wrapper),
:deep(.el-scrollbar),
:deep(.el-table__inner-wrapper),
:deep(.el-table),
:deep(.el-table__body-wrapper) {
height: 100%;
}
:deep(.el-table) {
color: #334155;
border-radius: 12px;
}
:deep(.el-table__header-wrapper th) {
height: 42px;
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #eef2f7;
color: #69758a;
font-size: 12px;
font-weight: 600;
}
:deep(.el-table__body-wrapper td) {
height: 38px;
padding: 6px 12px;
border-bottom: 1px solid #f0f3f8;
color: #40506a;
font-size: 13px;
}
:deep(.el-table__body tr:hover > td) {
background: #f8fbff;
}
:deep(.el-table-column--selection) {
width: 42px;
min-width: 42px;
}
}
.nameCell {
max-width: 100%;
color: #243247;
font-weight: 500;
}
.nameText {
cursor: pointer;
&:hover {
color: #1677ff;
}
}
.fileIcon {
width: 16px;
height: 16px;
border-radius: 3px;
background: #2f80ed;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
:deep(.el-icon) {
font-size: 11px;
}
}
.modeTag {
margin: 0;
height: 22px;
line-height: 20px;
border-color: #d8dee8;
border-radius: 5px;
background: #fff;
color: #59677e;
font-size: 12px;
}
.statusAvailable {
display: inline-flex;
align-items: center;
gap: 7px;
color: #16a34a;
font-weight: 500;
}
.statusDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #35d27f;
}
.statusAvailableDisabled {
color: #999999;
}
.statusDotDisabled {
background: #999999;
}
.actionIcon {
color: #4b5b73;
cursor: pointer;
font-size: 15px;
&:hover {
color: #1677ff;
}
}
.footer {
padding-top: 14px;
display: flex;
justify-content: right;
}

@ -0,0 +1,225 @@
.container {
padding: 20px;
}
.grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
grid-auto-rows: 202px;
}
.toolbar {
display: flex;
gap: 12px;
padding: 25px 20px 0 25px;
:deep(.el-input__wrapper) {
width: 280px;
}
}
.createCard {
width: 100%;
min-height: 202px;
height: 100%;
border: 2px dashed #d8dee9;
border-radius: 20px;
background: #fff;
box-shadow: 0 10px 24px rgba(31, 41, 55, 0.04);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
color: #7f89a6;
transition: all 0.2s;
&:hover {
border-color: #9aa6c1;
color: #5f6b89;
}
}
.createIcon {
width: 60px;
height: 60px;
border-radius: 24px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
:deep(.el-icon) {
font-size: 42px;
color: currentColor;
}
}
.createTitle {
font-weight: 600;
line-height: 1;
}
.card {
border-radius: 12px;
height: 100%;
border: 1px solid #e5e7eb;
position: relative;
&:hover {
.cardMore {
opacity: 1;
pointer-events: auto;
}
}
}
.cardMore {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(229, 231, 235, 0.9);
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.6;
pointer-events: none;
}
:deep(.el-icon) {
font-size: 18px;
color: #6b7280;
}
}
.cardHeader {
display: flex;
gap: 12px;
align-items: flex-start;
}
.iconWrapper {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
position: relative;
}
.iconBadge {
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
:deep(.el-icon) {
font-size: 9px;
color: #fff;
}
}
.cardTitle {
flex: 1;
min-width: 0;
}
.cardName {
font-size: 15px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardMeta {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.badgeRow {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.permissionBadge {
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
border-radius: 4px;
padding: 1px 6px;
}
.techniqueBadge {
font-size: 11px;
color: #6b7280;
}
.description {
font-size: 13px;
color: #374151;
margin: 0;
flex: 1;
span {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.descriptionEmpty {
color: #d1d5db;
}
.cardFooter {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #6b7280;
border-top: 1px solid #f3f4f6;
padding-top: 8px;
}
.footerAppCount {
margin-left: 4px;
}
.footerDivider {
color: #d1d5db;
margin: 0 2px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}

@ -0,0 +1,248 @@
// 知识库模块类型定义与工具函数
// 文档页视图模式:'documents' 列表 / 'add' 添加文件 / 'preview' 预览文档
export type DocumentModalOpenType = "documents" | "add" | "preview";
export type KnowledgeBaseInfoType = {
name: string;
description: string;
};
// 上传文件类型 —— 对应源代码 antd 的 UploadFile
export type UploadFile = {
uid: string;
name: string;
status?: string;
size?: number;
url?: string;
response?: any;
originFileObj?: File | Blob;
raw?: File | Blob;
};
export type PreProcessingRule = "remove_extra_spaces" | "remove_urls_emails";
export interface IndexingEstimate {
segmentIdentifier: string;
maximumSegmentLength: number;
segmentOverlapLength: number;
pre_processing_rules: PreProcessingRule[];
}
export type ModelType = {
currentProvider: string;
currentModel: string;
};
export type IndexingStatusItem = {
id: string;
indexing_status: string;
completed_segments: number;
total_segments: number;
};
type KnowledgeQueryData = {
queryIndexingEstimateJsonResult: ReturnType<typeof getQueryIndexingEstimateJson>;
top_k: number;
score_threshold_enabled: boolean;
score_threshold: number;
embedding_model: string;
embedding_model_provider: string;
reranking_provider_name: string;
reranking_model_name: string;
};
type preProcessingRulesType = {
id: PreProcessingRule;
enabled: boolean;
}[];
type SegmentationType = {
separator: string;
max_tokens: number;
chunk_overlap: number;
};
export const initIndexingEstimate: IndexingEstimate = {
segmentIdentifier: "\\n\\n",
maximumSegmentLength: 1000,
segmentOverlapLength: 100,
pre_processing_rules: ["remove_extra_spaces"] as PreProcessingRule[],
};
export const decodeInputValue = (value: string) => {
return value
.replace(/\\t/g, "\t")
.replace(/\\r/g, "\r")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\");
};
// 文件预览
export const getQueryIndexingEstimateJson = (
file_ids: string[],
pre_processing_rules: preProcessingRulesType,
segmentation: SegmentationType
) => {
return {
info_list: {
data_source_type: "upload_file", // 显示上传的文件
file_info_list: {
file_ids,
},
},
indexing_technique: "high_quality",
process_rule: {
rules: {
pre_processing_rules,
segmentation,
},
mode: "custom",
},
doc_form: "text_model",
doc_language: "Chinese Simplified",
};
};
// 数据库初始化
export const getInitKnowledgeQueryData = ({
queryIndexingEstimateJsonResult,
top_k,
score_threshold_enabled,
score_threshold,
embedding_model,
embedding_model_provider,
reranking_provider_name,
reranking_model_name,
}: KnowledgeQueryData) => {
const {
info_list,
process_rule,
doc_form,
doc_language,
indexing_technique,
} = queryIndexingEstimateJsonResult;
return {
data_source: {
type: "upload_file",
info_list: {
data_source_type: "upload_file",
file_info_list: {
file_ids: info_list.file_info_list.file_ids,
},
},
},
indexing_technique,
process_rule,
doc_form,
doc_language,
retrieval_model: {
search_method: "hybrid_search",
reranking_enable: false,
reranking_model: {
reranking_provider_name,
reranking_model_name,
},
top_k,
score_threshold_enabled,
score_threshold,
reranking_mode: "reranking_model",
weights: {
weight_type: "customized",
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: "",
embedding_model_name: "",
},
keyword_setting: {
keyword_weight: 0.3,
},
},
},
embedding_model,
embedding_model_provider,
};
};
// 数据库文档新增
export const getCreateKnowledgeJson = (data: ReturnType<typeof getInitKnowledgeQueryData>) => {
const summaryIndexSetting = {
enable: null,
model_name: null,
model_provider_name: null,
summary_prompt: null,
};
return {
...data,
process_rule: {
...data.process_rule,
summary_index_setting: summaryIndexSetting,
},
summary_index_setting: summaryIndexSetting,
};
};
// ============ 工具函数 ============
// 获取 Cookie
export const getCookie = (name: string): string => {
if (typeof document === "undefined") return "";
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || "";
}
return "";
};
// 密码加密
/**
* Field Encoding Utilities
* Provides Base64 encoding for sensitive fields (password, verification code)
* during transmission from frontend to backend.
*
* Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
* Real security relies on HTTPS for transport layer encryption.
*/
/**
* Encode sensitive field using Base64
* @param plaintext - The plain text to encode
* @returns Base64 encoded text
*/
function encryptField(plaintext: string): string {
try {
// Base64 encode the plaintext
// btoa works with ASCII, so we need to handle UTF-8 properly
const utf8Bytes = new TextEncoder().encode(plaintext)
const base64 = btoa(String.fromCharCode(...utf8Bytes))
return base64
} catch (error) {
console.error('Field encoding failed:', error)
// If encoding fails, throw error to prevent sending plaintext
throw new Error('Encoding failed. Please check your input.')
}
}
// 来自dify的加密函数用于加密密码和验证码
/**
* Encrypt password field for login
* @param password - Plain password
* @returns Encrypted password or original if encryption disabled
*/
export function encryptPassword(password: string): string {
return encryptField(password)
}
/**
* Encrypt verification code for email code login
* @param code - Plain verification code
* @returns Encrypted code or original if encryption disabled
*/
export function encryptVerificationCode(code: string): string {
return encryptField(code)
}

@ -0,0 +1,301 @@
// Dify API 请求封装 —— Vue Composables 版本
// 对应源代码 useDifyApis.ts将 React Hooks 转换为 Vue Composables
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type Canceler,
} from "axios";
import { ref, onMounted } from "vue";
import { getCookie } from "./types";
export interface ApiResponse<T = any> {
code?: string | number;
message?: string;
status?: number;
data: T;
}
export interface UseGetOptions {
url?: string;
params?: object;
config?: AxiosRequestConfig;
moreOperations?: {
immediate?: boolean;
};
}
export interface UsePostOptions {
url?: string;
data?: object;
config?: AxiosRequestConfig;
moreOperations?: {
immediate?: boolean;
};
}
// 存储待取消请求
const pendingRequests = new Map<string, Canceler>();
// 获取请求唯一 Key
function getRequestKey(config: AxiosRequestConfig) {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data)].join("&");
}
// 添加请求
function addPendingRequest(config: AxiosRequestConfig) {
const requestKey = getRequestKey(config);
if (pendingRequests.has(requestKey)) {
config.cancelToken = new axios.CancelToken((cancel) => {
cancel(`重复请求被取消: ${requestKey}`);
});
} else {
config.cancelToken = new axios.CancelToken((cancel) => {
pendingRequests.set(requestKey, cancel);
});
}
}
// 删除请求
function removePendingRequest(config: AxiosRequestConfig) {
const requestKey = getRequestKey(config);
pendingRequests.delete(requestKey);
}
// 创建 axios
const service: AxiosInstance = axios.create({
baseURL: "/p30",
timeout: 45 * 1000,
});
// 请求拦截
service.interceptors.request.use(
(config) => {
removePendingRequest(config);
addPendingRequest(config);
config.headers = config.headers ?? {};
config.headers["x-csrf-token"] = getCookie("csrf_token") || "";
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截
service.interceptors.response.use(
(response: AxiosResponse<any, any>) => {
removePendingRequest(response.config);
// 成功直接返回 data
return response.data;
},
(error) => {
if (error.config) {
removePendingRequest(error.config);
}
if (axios.isCancel(error)) {
console.warn(error.message);
return Promise.reject(error);
}
const response = error.response;
// 统一处理 401
if (response?.status === 401) {
return Promise.reject({
code: response.data?.code,
message: response.data?.message,
status: response.status,
});
}
// 其它错误
return Promise.reject(response?.data ?? error);
}
);
export function useRequest() {
const result = ref<ApiResponse | null>(null);
const loading = ref(false);
const error = ref<any>(null);
const doRequest = async (
options: AxiosRequestConfig
): Promise<AxiosResponse<ApiResponse> | ApiResponse> => {
loading.value = true;
error.value = null;
try {
const resp = await service(options);
result.value = resp as ApiResponse;
return resp;
} catch (e) {
error.value = e;
throw e;
} finally {
loading.value = false;
}
};
return {
result,
loading,
error,
doRequest,
};
}
export function useDifyGet(options: UseGetOptions = {}) {
const { result, loading, error, doRequest } = useRequest();
const { url, params, config, moreOperations } = options;
const doGet = (override: Partial<UseGetOptions> = {}) => {
const finalUrl = override.url ?? url;
const finalParams = override.params ?? params;
const finalConfig = {
...config,
...override.config,
};
if (!finalUrl) {
throw new Error("useDifyGet: url is required");
}
return doRequest({
url: finalUrl,
method: "get",
params: finalParams,
...finalConfig,
});
};
if (moreOperations?.immediate && url) {
onMounted(() => {
doGet();
});
}
return {
result,
loading,
error,
doGet,
};
}
export function useDifyPost(options: UsePostOptions = {}) {
const { result, loading, error, doRequest } = useRequest();
const { url, data, config, moreOperations } = options;
const doPost = (override: Partial<UsePostOptions> = {}) => {
const finalUrl = override.url ?? url;
const finalData = override.data ?? data;
const finalConfig = {
...config,
...override.config,
};
if (!finalUrl) {
throw new Error("useDifyPost: url is required");
}
return doRequest({
url: finalUrl,
method: "post",
data: finalData,
...finalConfig,
});
};
if (moreOperations?.immediate && url) {
onMounted(() => {
doPost();
});
}
return {
result,
loading,
error,
doPost,
};
}
export function useDifyPatch(options: UsePostOptions = {}) {
const { result, loading, error, doRequest } = useRequest();
const { url, data, config, moreOperations } = options;
const doPatch = (override: Partial<UsePostOptions> = {}) => {
const finalUrl = override.url ?? url;
const finalData = override.data ?? data;
const finalConfig = {
...config,
...override.config,
};
if (!finalUrl) {
throw new Error("useDifyPatch: url is required");
}
return doRequest({
url: finalUrl,
method: "patch",
data: finalData,
...finalConfig,
});
};
if (moreOperations?.immediate && url) {
onMounted(() => {
doPatch();
});
}
return {
result,
loading,
error,
doPatch,
};
}
export function useDifyDelete(options: UsePostOptions = {}) {
const { result, loading, error, doRequest } = useRequest();
const { url, data, config, moreOperations } = options;
const doDelete = (override: Partial<UsePostOptions> = {}) => {
const finalUrl = override.url ?? url;
const finalData = override.data ?? data;
const finalConfig = {
...config,
...override.config,
};
if (!finalUrl) {
throw new Error("useDifyDelete: url is required");
}
return doRequest({
url: finalUrl,
method: "delete",
data: finalData,
...finalConfig,
});
};
if (moreOperations?.immediate && url) {
onMounted(() => {
doDelete();
});
}
return {
result,
loading,
error,
doDelete,
};
}

@ -43,33 +43,30 @@
</div>
<template v-if="authPanel === 'login'">
<template v-if="loginFlowMode === 'account'">
<FaLoginAccountForm
ref="accountFormRef"
v-model:is-passing="isPassing"
v-model:is-click-pass="isClickPass"
v-model:login-form="loginForm"
:rules="rules"
:captcha-state="captchaState"
:code-loading="codeLoading"
:demo-account-key="demoAccountKey"
:accounts="accounts"
:form-key="formKey"
:is-dark="isDark"
:drag-verify-text-color="dragVerifyTextColor"
:loading="loading"
@submit="handleSubmit"
@setup-account="setupAccount"
@get-captcha="getCaptcha"
@open-mobile="openMobileLogin"
@open-qr="openQrLogin"
@forget="setAuthPanel('forget')"
@register="setAuthPanel('register')"
@oauth="handleOAuthLogin"
/>
</template>
<FaLoginMobilePanel
<!-- <template v-if="loginFlowMode === 'account'"> -->
<FaLoginAccountForm
ref="accountFormRef"
v-model:is-passing="isPassing"
v-model:is-click-pass="isClickPass"
v-model:login-form="loginForm"
:rules="rules"
:captcha-state="captchaState"
:code-loading="codeLoading"
:demo-account-key="demoAccountKey"
:accounts="accounts"
:form-key="formKey"
:is-dark="isDark"
:drag-verify-text-color="dragVerifyTextColor"
:loading="loading"
@submit="handleSubmit"
@setup-account="setupAccount"
@get-captcha="getCaptcha"
@forget="setAuthPanel('forget')"
@register="setAuthPanel('register')"
/>
<!-- </template> -->
<!-- <FaLoginMobilePanel
v-else-if="loginFlowMode === 'mobile'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
@ -79,7 +76,7 @@
v-else-if="loginFlowMode === 'qr'"
@back="backToAccountLogin"
@register="setAuthPanel('register')"
/>
/> -->
</template>
<FaLoginRegisterPanel
@ -185,13 +182,13 @@ import AuthAPI, {
import type { TenantRegisterForm } from "@/api/module_system/auth";
import UserAPI, { type ForgetPasswordForm, type RegisterForm } from "@/api/module_system/user";
import { useConfigStore, useAppStore, useSettingsStore, useUserStore } from "@stores";
import { Auth, HttpError, startOAuthLogin } from "@utils";
import { Auth, HttpError } from "@utils";
import { ElMessage, ElNotification, type FormRules } from "element-plus";
import type { Account, AccountKey } from "./types";
import FaLoginAccountForm from "@/components/views/fa-login/forms/FaLoginAccountForm.vue";
import FaLoginForgetPanel from "@/components/views/fa-login/panels/FaLoginForgetPanel.vue";
import FaLoginMobilePanel from "@/components/views/fa-login/panels/FaLoginMobilePanel.vue";
import FaLoginQrPanel from "@/components/views/fa-login/panels/FaLoginQrPanel.vue";
// import FaLoginMobilePanel from "@/components/views/fa-login/panels/FaLoginMobilePanel.vue";
// import FaLoginQrPanel from "@/components/views/fa-login/panels/FaLoginQrPanel.vue";
import FaLoginRegisterPanel from "@/components/views/fa-login/panels/FaLoginRegisterPanel.vue";
import FaAuthTopBar from "@/components/views/fa-login/widgets/FaAuthTopBar.vue";
import { useLoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
@ -200,8 +197,8 @@ defineOptions({ name: "Login" });
type AuthPanel = "login" | "register" | "forget";
/** 登录区内:账号密码 ↔ 手机号 ↔ 扫码(扫码 / 手机号为演示交互) */
type LoginFlowMode = "account" | "mobile" | "qr";
/** 仅保留账号密码登录 */
type LoginFlowMode = "account";
const configStore = useConfigStore();
const settingStore = useSettingsStore();
@ -217,24 +214,12 @@ const loginFlowMode = ref<LoginFlowMode>("account");
const panelTitle = computed(() => {
if (authPanel.value === "register") return t("login.reg");
if (authPanel.value === "forget") return t("login.resetPassword");
if (
authPanel.value === "login" &&
(loginFlowMode.value === "mobile" || loginFlowMode.value === "qr")
) {
return t("login.qrLoginTitle");
}
return t("login.title");
});
const panelSubTitle = computed(() => {
if (authPanel.value === "register") return t("register.subTitle");
if (authPanel.value === "forget") return t("forgetPassword.subTitle");
if (authPanel.value === "login" && loginFlowMode.value === "mobile") {
return t("login.mobileLoginSubTitle");
}
if (authPanel.value === "login" && loginFlowMode.value === "qr") {
return t("login.qrLoginSubTitle");
}
return t("login.subTitle");
});
@ -252,70 +237,70 @@ function setAuthPanel(panel: AuthPanel) {
});
}
function openMobileLogin() {
loginFlowMode.value = "mobile";
}
function openQrLogin() {
loginFlowMode.value = "qr";
}
function backToAccountLogin() {
loginFlowMode.value = "account";
nextTick(() => {
getCaptcha();
loginForm.captcha = "";
accountFormRef.value?.resetDragVerify?.();
isPassing.value = false;
isClickPass.value = false;
});
}
function handleOAuthLogin(provider: OAuthProvider) {
startOAuthLogin(provider);
}
async function tryConsumeOAuthCallback() {
const q = route.query;
const oauthError = q.oauth_error as string | undefined;
const access = q.access_token as string | undefined;
const refresh = q.refresh_token as string | undefined;
if (!oauthError && !(access && refresh)) return;
const rest: Record<string, unknown> = { ...q };
delete rest.oauth_error;
delete rest.access_token;
delete rest.refresh_token;
delete rest.token_type;
if (oauthError) {
ElMessage.error(decodeURIComponent(oauthError));
await router.replace({ path: route.path, query: rest as LocationQuery });
return;
}
if (access && refresh) {
try {
Auth.setTokens(access, refresh, true);
userStore.setToken(access, refresh);
userStore.setLoginStatus(true);
ElNotification({
title: t("login.oauthNoticeTitle"),
message: t("login.oauthLoginSuccess"),
type: "success",
});
await router.replace(resolveRedirectTarget(rest as LocationQuery));
if (settingStore.showGuide) {
appStore.showGuide(true);
}
} catch (error) {
console.error("[Login] OAuth callback:", error);
ElMessage.error(t("login.oauthLoginFailed"));
await router.replace({ path: route.path, query: rest as LocationQuery });
}
}
}
// function openMobileLogin() {
// loginFlowMode.value = "mobile";
// }
// function openQrLogin() {
// loginFlowMode.value = "qr";
// }
// function backToAccountLogin() {
// loginFlowMode.value = "account";
// nextTick(() => {
// getCaptcha();
// loginForm.captcha = "";
// accountFormRef.value?.resetDragVerify?.();
// isPassing.value = false;
// isClickPass.value = false;
// });
// }
// function handleOAuthLogin(provider: OAuthProvider) {
// startOAuthLogin(provider);
// }
// async function tryConsumeOAuthCallback() {
// const q = route.query;
// const oauthError = q.oauth_error as string | undefined;
// const access = q.access_token as string | undefined;
// const refresh = q.refresh_token as string | undefined;
// if (!oauthError && !(access && refresh)) return;
// const rest: Record<string, unknown> = { ...q };
// delete rest.oauth_error;
// delete rest.access_token;
// delete rest.refresh_token;
// delete rest.token_type;
// if (oauthError) {
// ElMessage.error(decodeURIComponent(oauthError));
// await router.replace({ path: route.path, query: rest as LocationQuery });
// return;
// }
// if (access && refresh) {
// try {
// Auth.setTokens(access, refresh, true);
// userStore.setToken(access, refresh);
// userStore.setLoginStatus(true);
// ElNotification({
// title: t("login.oauthNoticeTitle"),
// message: t("login.oauthLoginSuccess"),
// type: "success",
// });
// await router.replace(resolveRedirectTarget(rest as LocationQuery));
// if (settingStore.showGuide) {
// appStore.showGuide(true);
// }
// } catch (error) {
// console.error("[Login] OAuth callback:", error);
// ElMessage.error(t("login.oauthLoginFailed"));
// await router.replace({ path: route.path, query: rest as LocationQuery });
// }
// }
// }
const dragVerifyTextColor = computed(() =>
isDark.value ? "rgba(255, 255, 255, 0.45)" : "var(--fa-gray-700)"
@ -570,13 +555,13 @@ let voteTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
setupAccount("super");
await configStore.getConfig(true);
await tryConsumeOAuthCallback();
// await tryConsumeOAuthCallback();
if (userStore.isLogin) {
await router.replace(resolveRedirectTarget(route.query));
return;
}
getCaptcha();
voteTimer = setTimeout(showVoteNotification, 500);
// voteTimer = setTimeout(showVoteNotification, 500);
});
onActivated(() => {

@ -88,6 +88,13 @@ export default ({ mode }: { mode: string }) => {
changeOrigin: true, // 是否跨域
// rewrite: (path: string) => path.replace(new RegExp("^" + env.VITE_APP_BASE_API), ""),
},
// Dify 知识库后端代理
"/p30": {
target: env.VITE_DIFY_API_BASE_URL,
secure: false,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/p30/, ""),
},
},
},
resolve: {

Loading…
Cancel
Save