新增知识库管理功能,包括知识库和文档的增删改查,优化文档上传和状态管理,更新相关API接口和前端页面,添加知识库和文档的视图组件。

This commit is contained in:
Chuck1sn
2025-06-27 16:51:48 +08:00
parent 2fb08968ee
commit 8ed0b795f3
25 changed files with 1578 additions and 46 deletions

View File

@@ -170,9 +170,8 @@ jooq {
}
forcedTypes {
forcedType {
name = "varchar"
includeExpression = ".*"
includeTypes = "INET"
isJsonConverter = true
includeTypes = "(?i:JSON|JSONB)"
}
}
}

View File

@@ -0,0 +1,35 @@
package com.zl.mjga.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import org.jooq.JSON;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder ->
builder
.serializationInclusion(JsonInclude.Include.USE_DEFAULTS)
.serializers(new JooqJsonSerializer());
}
private static class JooqJsonSerializer extends StdSerializer<JSON> {
public JooqJsonSerializer() {
super(JSON.class);
}
@Override
public void serialize(JSON value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeRawValue(value.data());
}
}
}

View File

@@ -8,6 +8,8 @@ import com.zl.mjga.repository.LibraryRepository;
import com.zl.mjga.service.RagService;
import com.zl.mjga.service.UploadService;
import jakarta.validation.Valid;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -37,7 +39,10 @@ public class LibraryController {
@GetMapping("/docs")
public List<LibraryDoc> queryLibraryDocs(@RequestParam Long libraryId) {
return libraryDocRepository.fetchByLibId(libraryId);
List<LibraryDoc> libraryDocs = libraryDocRepository.fetchByLibId(libraryId);
return libraryDocs.stream().sorted(
Comparator.comparing(LibraryDoc::getId).reversed()
).toList();
}
@GetMapping("/segments")
@@ -66,22 +71,19 @@ public class LibraryController {
@PutMapping("/doc")
public void updateLibraryDoc(@RequestBody @Valid DocUpdateDto docUpdateDto) {
LibraryDoc libraryDoc = new LibraryDoc();
libraryDoc.setId(docUpdateDto.id());
libraryDoc.setEnable(docUpdateDto.enable());
libraryDocRepository.merge(libraryDoc);
LibraryDoc exist = libraryDocRepository.fetchOneById(docUpdateDto.id());
exist.setEnable(docUpdateDto.enable());
libraryDocRepository.merge(exist);
}
@PostMapping(
value = "/doc/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
@PostMapping(value = "/doc/upload", produces = MediaType.TEXT_PLAIN_VALUE)
public String uploadLibraryDoc(
@RequestPart("libraryId") Long libraryId, @RequestPart("file") MultipartFile multipartFile)
@RequestPart("libraryId") String libraryId, @RequestPart("file") MultipartFile multipartFile)
throws Exception {
String objectName = uploadService.uploadLibraryDoc(multipartFile);
Long libraryDocId =
ragService.createLibraryDocBy(libraryId, objectName, multipartFile.getOriginalFilename());
ragService.createLibraryDocBy(
Long.valueOf(libraryId), objectName, multipartFile.getOriginalFilename());
ragService.embeddingAndCreateDocSegment(libraryDocId, objectName);
return objectName;
}

View File

@@ -1,6 +1,5 @@
package com.zl.mjga.dto.knowledge;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
public record DocUpdateDto(@NotNull Long id, @NotEmpty Boolean enable) {}
public record DocUpdateDto(@NotNull Long id, @NotNull Long libId, @NotNull Boolean enable) {}

2
frontend/.gitignore vendored
View File

@@ -186,3 +186,5 @@ compose.yaml
Dockerfile
Caddyfile
start.sh
.cursor

View File

@@ -44,6 +44,51 @@
}
}
},
"/knowledge/doc": {
"put": {
"tags": [
"library-controller"
],
"operationId": "updateLibraryDoc",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DocUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK"
}
}
},
"delete": {
"tags": [
"library-controller"
],
"operationId": "deleteLibraryDoc",
"parameters": [
{
"name": "libraryDocId",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/ai/llm": {
"put": {
"tags": [
@@ -192,6 +237,93 @@
}
}
},
"/knowledge/library": {
"post": {
"tags": [
"library-controller"
],
"operationId": "upsertLibrary",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LibraryUpsertDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK"
}
}
},
"delete": {
"tags": [
"library-controller"
],
"operationId": "deleteLibrary",
"parameters": [
{
"name": "libraryId",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/knowledge/doc/upload": {
"post": {
"tags": [
"library-controller"
],
"operationId": "uploadLibraryDoc",
"requestBody": {
"content": {
"application/json": {
"schema": {
"required": [
"file",
"libraryId"
],
"type": "object",
"properties": {
"libraryId": {
"type": "string"
},
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/iam/user": {
"get": {
"tags": [
@@ -963,6 +1095,97 @@
}
}
},
"/knowledge/segments": {
"get": {
"tags": [
"library-controller"
],
"operationId": "queryLibraryDocSegments",
"parameters": [
{
"name": "libraryDocId",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryDocSegment"
}
}
}
}
}
}
}
},
"/knowledge/libraries": {
"get": {
"tags": [
"library-controller"
],
"operationId": "queryLibraries",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Library"
}
}
}
}
}
}
}
},
"/knowledge/docs": {
"get": {
"tags": [
"library-controller"
],
"operationId": "queryLibraryDocs",
"parameters": [
{
"name": "libraryId",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryDoc"
}
}
}
}
}
}
}
},
"/iam/users": {
"get": {
"tags": [
@@ -1354,6 +1577,22 @@
}
}
},
"DocUpdateDto": {
"required": [
"enable",
"id"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"enable": {
"type": "boolean"
}
}
},
"LlmVm": {
"required": [
"apiKey",
@@ -1422,6 +1661,24 @@
}
}
},
"LibraryUpsertDto": {
"required": [
"name"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
},
"UserUpsertDto": {
"required": [
"enable",
@@ -1789,6 +2046,94 @@
}
}
},
"LibraryDocSegment": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"docId": {
"type": "integer",
"format": "int64"
},
"embeddingId": {
"type": "string"
},
"content": {
"type": "string"
},
"tokenUsage": {
"type": "integer",
"format": "int32"
}
}
},
"Library": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"createTime": {
"type": "string",
"format": "date-time"
}
}
},
"JSON": {
"type": "object"
},
"LibraryDoc": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"libId": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"identify": {
"type": "string"
},
"path": {
"type": "string"
},
"meta": {
"$ref": "#/components/schemas/JSON"
},
"enable": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": [
"SUCCESS",
"INDEXING"
]
},
"createTime": {
"type": "string",
"format": "date-time"
},
"updateTime": {
"type": "string",
"format": "date-time"
}
}
},
"UserQueryDto": {
"type": "object",
"properties": {

View File

@@ -20,6 +20,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/knowledge/doc": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateLibraryDoc"];
post?: never;
delete: operations["deleteLibraryDoc"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/ai/llm": {
parameters: {
query?: never;
@@ -100,6 +116,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/knowledge/library": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["upsertLibrary"];
delete: operations["deleteLibrary"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/knowledge/doc/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["uploadLibraryDoc"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/iam/user": {
parameters: {
query?: never;
@@ -484,6 +532,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/knowledge/segments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["queryLibraryDocSegments"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/knowledge/libraries": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["queryLibraries"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/knowledge/docs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["queryLibraryDocs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/iam/users": {
parameters: {
query?: never;
@@ -684,6 +780,11 @@ export interface components {
name: string;
group: string;
};
DocUpdateDto: {
/** Format: int64 */
id: number;
enable: boolean;
};
LlmVm: {
/** Format: int64 */
id: number;
@@ -705,6 +806,12 @@ export interface components {
id?: number;
name?: string;
};
LibraryUpsertDto: {
/** Format: int64 */
id?: number;
name: string;
description?: string;
};
UserUpsertDto: {
/** Format: int64 */
id?: number;
@@ -829,6 +936,42 @@ export interface components {
name: string;
isBound?: boolean;
};
LibraryDocSegment: {
/** Format: int64 */
id?: number;
/** Format: int64 */
docId?: number;
embeddingId?: string;
content?: string;
/** Format: int32 */
tokenUsage?: number;
};
Library: {
/** Format: int64 */
id?: number;
name?: string;
description?: string;
/** Format: date-time */
createTime?: string;
};
JSON: Record<string, never>;
LibraryDoc: {
/** Format: int64 */
id?: number;
/** Format: int64 */
libId?: number;
name?: string;
identify?: string;
path?: string;
meta?: components["schemas"]["JSON"];
enable?: boolean;
/** @enum {string} */
status?: "SUCCESS" | "INDEXING";
/** Format: date-time */
createTime?: string;
/** Format: date-time */
updateTime?: string;
};
UserQueryDto: {
username?: string;
/** Format: date-time */
@@ -973,6 +1116,48 @@ export interface operations {
};
};
};
updateLibraryDoc: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocUpdateDto"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteLibraryDoc: {
parameters: {
query: {
libraryDocId: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateLlm: {
parameters: {
query?: never;
@@ -1105,6 +1290,76 @@ export interface operations {
};
};
};
upsertLibrary: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LibraryUpsertDto"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteLibrary: {
parameters: {
query: {
libraryId: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
uploadLibraryDoc: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
libraryId: string;
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": string;
};
};
};
};
queryUserWithRolePermission: {
parameters: {
query: {
@@ -1782,6 +2037,70 @@ export interface operations {
};
};
};
queryLibraryDocSegments: {
parameters: {
query: {
libraryDocId: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["LibraryDocSegment"][];
};
};
};
};
queryLibraries: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Library"][];
};
};
};
};
queryLibraryDocs: {
parameters: {
query: {
libraryId: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["LibraryDoc"][];
};
};
};
};
queryUsers: {
parameters: {
query: {

View File

@@ -21,25 +21,25 @@
<script setup lang="ts">
defineProps({
href: {
type: String,
required: true
},
imageSrc: {
type: String,
required: true
},
imageAlt: {
type: String,
default: 'promotion'
},
label: {
type: String,
default: '官方教程'
},
text: {
type: String,
required: true
}
href: {
type: String,
required: true,
},
imageSrc: {
type: String,
required: true,
},
imageAlt: {
type: String,
default: "promotion",
},
label: {
type: String,
default: "官方教程",
},
text: {
type: String,
required: true,
},
});
</script>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-icon">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
<path d="m9 10 2 2 4-4" />
</svg>
</template>

View File

@@ -11,3 +11,4 @@ export { default as RoleIcon } from "./RoleIcon.vue";
export { default as SettingsIcon } from "./SettingsIcon.vue";
export { default as UsersIcon } from "./UsersIcon.vue";
export { default as PermissionIcon } from "./PermissionIcon.vue";
export { default as KnowledgeIcon } from "./KnowledgeIcon.vue";

View File

@@ -35,6 +35,7 @@ import { RouterLink, useRoute } from "vue-router";
import {
DepartmentIcon,
KnowledgeIcon,
LlmConfigIcon,
PermissionIcon,
PositionIcon,
@@ -113,6 +114,11 @@ const menuItems = [
path: Routes.LLMCONFIGVIEW.fullPath(),
icon: LlmConfigIcon,
},
{
title: "知识库管理",
path: Routes.KNOWLEDGEVIEW.fullPath(),
icon: KnowledgeIcon,
},
];
const route = useRoute();

View File

@@ -0,0 +1,93 @@
<template>
<BaseDialog :id="id" title="知识库管理" size="md" :closeModal="closeModal">
<!-- Modal body -->
<div class="p-4 md:p-5">
<div class="grid gap-4 mb-4 grid-cols-1">
<div class="col-span-full">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900">知识库名称</label>
<input type="text" id="name" v-model="formData.name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required />
</div>
<div class="col-span-full">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900">知识库描述</label>
<textarea id="description" v-model="formData.description" rows="3"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"></textarea>
</div>
</div>
<button type="submit" @click="handleSubmit"
class="w-auto text-sm px-4 py-2 text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-center self-start mt-5">
保存
</button>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import useAlertStore from "@/composables/store/useAlertStore";
import type { Library } from "@/types/KnowledgeTypes";
import type { LibraryUpsertModel } from "@/types/KnowledgeTypes";
import { ref, watch } from "vue";
import { z } from "zod";
import BaseDialog from "./BaseDialog.vue";
const alertStore = useAlertStore();
const { library, closeModal, onSubmit, id } = defineProps<{
library?: Library;
closeModal: () => void;
onSubmit: (data: LibraryUpsertModel) => Promise<void>;
id: string;
}>();
const formData = ref<LibraryUpsertModel>({
name: "",
description: "",
});
const updateFormData = (newLibrary: typeof library) => {
if (!newLibrary) {
formData.value = {
name: "",
description: "",
};
return;
}
formData.value = {
id: newLibrary.id,
name: newLibrary.name ?? "",
description: newLibrary.description ?? "",
};
};
watch(() => library, updateFormData, { immediate: true });
const handleSubmit = async () => {
const schema = z.object({
name: z.string().min(1, "知识库名称不能为空"),
description: z.string().optional(),
});
try {
const validatedData = schema.parse(formData.value);
await onSubmit({
...formData.value,
name: validatedData.name,
description: validatedData.description,
});
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
type: "error",
message: error.errors[0].message,
});
} else {
console.error("表单提交错误:", error);
alertStore.showAlert({
type: "error",
message: "表单提交失败,请重试",
});
}
}
};
</script>

View File

@@ -116,10 +116,9 @@ const handleFileChange = (event: Event) => {
throw err;
},
});
} catch (error) {
} finally {
(event.target as HTMLInputElement).value = "";
uploadLoading.value = false;
throw error;
}
};

View File

@@ -0,0 +1,51 @@
import client from "@/api/client";
import type {
DocQueryParams,
Library,
LibraryDoc,
LibraryDocSegment,
SegmentQueryParams,
} from "@/types/KnowledgeTypes";
import { ref } from "vue";
export const useKnowledgeQuery = () => {
const libraries = ref<Library[]>([]);
const docs = ref<LibraryDoc[]>([]);
const segments = ref<LibraryDocSegment[]>([]);
const fetchLibraries = async () => {
const { data } = await client.GET("/knowledge/libraries", {});
libraries.value = data || [];
};
const fetchLibraryDocs = async (params: DocQueryParams) => {
const { data } = await client.GET("/knowledge/docs", {
params: {
query: {
libraryId: params.libraryId,
},
},
});
docs.value = data || [];
};
const fetchDocSegments = async (params: SegmentQueryParams) => {
const { data } = await client.GET("/knowledge/segments", {
params: {
query: {
libraryDocId: params.libraryDocId,
},
},
});
segments.value = data || [];
};
return {
libraries,
fetchLibraries,
docs,
fetchLibraryDocs,
segments,
fetchDocSegments,
};
};

View File

@@ -0,0 +1,72 @@
import client from "@/api/client";
import type {
DocUpdateModel,
LibraryUpsertModel,
} from "@/types/KnowledgeTypes";
export const useKnowledgeUpsert = () => {
const upsertLibrary = async (library: LibraryUpsertModel) => {
await client.POST("/knowledge/library", {
body: {
id: library.id,
name: library.name,
description: library.description,
},
});
};
const deleteLibrary = async (libraryId: number) => {
await client.DELETE("/knowledge/library", {
params: {
query: {
libraryId,
},
},
});
};
const uploadDoc = async (libraryId: number, file: File) => {
await client.POST("/knowledge/doc/upload", {
body: {
libraryId: libraryId.toString(),
file: file as unknown as string,
},
bodySerializer: (body) => {
const formData = new FormData();
for (const [key, value] of Object.entries(body!)) {
formData.set(key, value as unknown as string);
}
return formData;
},
parseAs: "text",
});
};
const deleteDoc = async (libraryDocId: number) => {
await client.DELETE("/knowledge/doc", {
params: {
query: {
libraryDocId,
},
},
});
};
const updateDoc = async (doc: DocUpdateModel) => {
await client.PUT("/knowledge/doc", {
body: {
id: doc.id,
libId: doc.libId,
enable: doc.enable,
},
});
};
return {
upsertLibrary,
deleteLibrary,
uploadDoc,
deleteDoc,
updateDoc,
};
};

View File

@@ -133,9 +133,9 @@ export const UserRoutes = {
export const AiRoutes = {
LLMCONFIGVIEW: {
path: "llm/config",
name: "llm/config",
name: "llm-config",
fullPath: () => `${BaseRoutes.DASHBOARD.path}/llm/config`,
withParams: () => ({ name: "llm/config" }),
withParams: () => ({ name: "llm-config" }),
},
SCHEDULERVIEW: {
path: "scheduler",
@@ -143,6 +143,37 @@ export const AiRoutes = {
fullPath: () => `${BaseRoutes.DASHBOARD.path}/scheduler`,
withParams: () => ({ name: "scheduler" }),
},
KNOWLEDGEVIEW: {
path: "knowledge",
name: "knowledge",
fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge`,
withParams: () => ({ name: "knowledge" }),
},
KNOWLEDGEDOCVIEW: {
path: "knowledge/:libraryId",
name: "knowledge-docs",
fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge/:libraryId`,
withParams: <T extends { libraryId: string | number }>(params: T) => ({
name: "knowledge-docs",
params: { libraryId: params.libraryId.toString() },
}),
},
KNOWLEDGESEGMENTSVIEW: {
path: "knowledge/:libraryId/:docId",
name: "knowledge-segments",
fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge/:libraryId/:docId`,
withParams: <
T extends { libraryId: string | number; docId: string | number },
>(
params: T,
) => ({
name: "knowledge-segments",
params: {
libraryId: params.libraryId.toString(),
docId: params.docId.toString(),
},
}),
},
} as const;
export const Routes = {

View File

@@ -13,7 +13,7 @@ export const authGuard: NavigationGuard = (to) => {
};
}
if (to.path === Routes.LOGIN.path && userStore.user) {
return { path: `${Routes.DASHBOARD.path}/${Routes.USERVIEW.path}` };
return { path: Routes.USERVIEW.fullPath() };
}
};

View File

@@ -15,7 +15,7 @@ const routes: RouteRecordRaw[] = [
path: Routes.HOME.path,
name: Routes.HOME.name,
redirect: {
path: `${Routes.DASHBOARD.path}/${Routes.USERVIEW.path}`,
path: Routes.USERVIEW.fullPath(),
},
},
];
@@ -27,7 +27,7 @@ const router = createRouter({
router.onError((err) => {
console.error("router err:", err);
router.push(Routes.USERVIEW.name);
router.push(Routes.USERVIEW.fullPath());
return false;
});

View File

@@ -11,6 +11,30 @@ const aiRoutes: RouteRecordRaw[] = [
hasPermission: EPermission.READ_LLM_CONFIG_PERMISSION,
},
},
{
path: Routes.KNOWLEDGEVIEW.path,
name: Routes.KNOWLEDGEVIEW.name,
component: () => import("@/views/KnowledgeManagementPage.vue"),
meta: {
requiresAuth: true,
},
},
{
path: Routes.KNOWLEDGEDOCVIEW.path,
name: Routes.KNOWLEDGEDOCVIEW.name,
component: () => import("@/views/KnowledgeDocManagementPage.vue"),
meta: {
requiresAuth: true,
},
},
{
path: Routes.KNOWLEDGESEGMENTSVIEW.path,
name: Routes.KNOWLEDGESEGMENTSVIEW.name,
component: () => import("@/views/KnowledgeSegmentsPage.vue"),
meta: {
requiresAuth: true,
},
},
];
export default aiRoutes;

View File

@@ -12,6 +12,8 @@ const dashboardRoutes: RouteRecordRaw = {
requiresAuth: true,
},
children: [
...userManagementRoutes,
...aiRoutes,
{
path: Routes.OVERVIEW.path,
name: Routes.OVERVIEW.name,
@@ -28,8 +30,6 @@ const dashboardRoutes: RouteRecordRaw = {
requiresAuth: true,
},
},
...userManagementRoutes,
...aiRoutes,
{
path: Routes.NOTFOUND.path,
name: Routes.NOTFOUND.name,

View File

@@ -0,0 +1,35 @@
import type { components } from "@/api/types/schema";
export type Library = components["schemas"]["Library"];
export type LibraryDoc = components["schemas"]["LibraryDoc"];
export type LibraryDocSegment = components["schemas"]["LibraryDocSegment"];
export interface LibraryUpsertModel {
id?: number;
name: string;
description?: string;
}
export interface DocUpdateModel {
id: number;
libId: number;
enable: boolean;
}
export interface LibraryQueryParams {
page: number;
size: number;
}
export interface DocQueryParams {
libraryId: number;
}
export interface SegmentQueryParams {
libraryDocId: number;
}
export enum DocStatus {
SUCCESS = "SUCCESS",
INDEXING = "INDEXING",
}

View File

@@ -0,0 +1,238 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理', '文档管理']" :routes="[Routes.KNOWLEDGEVIEW.fullPath()]" />
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl">{{ currentLibrary?.name || '知识库' }} - 文档管理</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="doc in docs" :key="doc.id"
class="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<div class="p-4">
<div class="flex justify-between items-start">
<div>
<h5 class="text-xl font-semibold tracking-tight text-gray-900 mb-1 truncate">{{ doc.name }}</h5>
<div class="flex items-center mb-2">
<span :class="`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${
doc.status === DocStatus.SUCCESS ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
} mr-2`">
{{ doc.status === DocStatus.SUCCESS ? '解析完成' : '解析中' }}
</span>
<span :class="`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${
doc.enable ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`">
{{ doc.enable ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="flex space-x-2">
<label class="inline-flex items-center mb-5"
:class="!doc.enable ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'">
<input type="checkbox" class="sr-only peer" :checked="doc.enable" @change="handleToggleDocStatus(doc)">
<div
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:w-5 after:h-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600">
</div>
</label>
</div>
</div>
<!-- <div class="text-sm text-gray-600 mb-3">
<div class="truncate">{{ doc.path || '无' }}</div>
</div> -->
<div class="flex justify-between items-center">
<span class="text-xs text-gray-500">
上传时间: {{ formatDate(doc.createTime) }}
</span>
<div class="flex space-x-2">
<button @click="navigateToDocSegments(doc)" :class="
['text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-content',
doc.status !== DocStatus.SUCCESS ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer']"
:disabled="doc.status !== DocStatus.SUCCESS">
查看内容
</button>
<button @click="handleDeleteDoc(doc)"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5">
删除
</button>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center py-10">
<div v-if="docs.length === 0" class="text-gray-500 text-lg mb-4">暂无文档</div>
<div>
<input ref="fileInputRef" class="hidden" id="doc_file_input" type="file" @change="handleFileChange">
<TableButton variant="primary" size="md" @click="triggerFileInput">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
上传文档
</TableButton>
</div>
</div>
</div>
<!-- 删除确认对话框 -->
<ConfirmationDialog :id="'doc-delete-modal'" :title="`确定删除文档 '${selectedDoc?.name || ''}' 吗?`"
content="删除后将无法恢复,且其中的所有分段内容也将被删除。" :closeModal="() => {
docDeleteModal?.hide();
}" :onSubmit="handleDocDeleteSubmit" />
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { onMounted, ref, watchEffect } from "vue";
import { useRoute, useRouter } from "vue-router";
import { PlusIcon } from "@/components/icons";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import ConfirmationDialog from "@/components/modals/ConfirmationDialog.vue";
import TableButton from "@/components/tables/TableButton.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { useKnowledgeUpsert } from "@/composables/knowledge/useKnowledgeUpsert";
import useAlertStore from "@/composables/store/useAlertStore";
import { Routes } from "@/router/constants";
import type { Library, LibraryDoc } from "@/types/KnowledgeTypes";
import { DocStatus } from "@/types/KnowledgeTypes";
// 路由参数
const route = useRoute();
const router = useRouter();
const libraryId = ref<number>(
Number.parseInt(route.params.libraryId as string, 10),
);
// 文件输入引用
const fileInputRef = ref<HTMLInputElement | null>(null);
// 获取知识库信息
const { libraries, fetchLibraries } = useKnowledgeQuery();
const currentLibrary = ref<Library | undefined>();
// 获取文档列表
const { docs, fetchLibraryDocs } = useKnowledgeQuery();
const { uploadDoc, deleteDoc, updateDoc } = useKnowledgeUpsert();
// 模态框引用
const docDeleteModal = ref<ModalInterface>();
// 选中的文档
const selectedDoc = ref<LibraryDoc | undefined>();
// 提示store
const alertStore = useAlertStore();
// 格式化日期
const formatDate = (dateString?: string) => {
if (!dateString) return "未知";
return dayjs(dateString).format("YYYY-MM-DD HH:mm");
};
// 触发文件选择
const triggerFileInput = () => {
fileInputRef.value?.click();
};
// 处理文件选择
const handleFileChange = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await uploadDoc(libraryId.value, file);
alertStore.showAlert({
level: "success",
content: "文档上传成功",
});
// 清空文件选择框
(event.target as HTMLInputElement).value = "";
// 刷新文档列表
await fetchLibraryDocs({ libraryId: libraryId.value });
} finally {
// 清空文件选择框
(event.target as HTMLInputElement).value = "";
}
};
// 处理删除文档
const handleDeleteDoc = (doc: LibraryDoc) => {
selectedDoc.value = doc;
docDeleteModal.value?.show();
};
// 处理文档删除确认
const handleDocDeleteSubmit = async () => {
if (!selectedDoc.value?.id) return;
await deleteDoc(selectedDoc.value.id);
alertStore.showAlert({
level: "success",
content: "文档删除成功",
});
docDeleteModal.value?.hide();
await fetchLibraryDocs({ libraryId: libraryId.value });
};
// 处理切换文档状态
const handleToggleDocStatus = async (doc: LibraryDoc) => {
try {
doc.enable = !doc.enable;
await updateDoc({
id: doc.id!,
libId: doc.libId!,
enable: doc.enable,
});
alertStore.showAlert({
level: "success",
content: "操作成功",
});
} finally {
await fetchLibraryDocs({ libraryId: libraryId.value });
}
};
// 导航到文档分段页面
const navigateToDocSegments = (doc: LibraryDoc) => {
router.push(
Routes.KNOWLEDGESEGMENTSVIEW.withParams({
libraryId: libraryId.value,
docId: doc.id!,
}),
);
};
// 初始化
onMounted(async () => {
initFlowbite();
// 初始化模态框
const docDeleteElement = document.getElementById("doc-delete-modal");
if (docDeleteElement) {
docDeleteModal.value = new Modal(docDeleteElement);
}
// 获取知识库列表和文档列表
await fetchLibraries();
await fetchLibraryDocs({ libraryId: libraryId.value });
});
// 监听知识库列表变化,找到当前知识库
watchEffect(() => {
if (libraries.value && libraries.value.length > 0) {
currentLibrary.value = libraries.value.find(
(lib) => lib.id === libraryId.value,
);
}
});
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理']" />
<div class="mb-4">
<h1 class="text-2xl font-semibold text-gray-900">知识库管理</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="library in libraries" :key="library.id"
class="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<div class="p-4">
<div class="flex justify-between items-start">
<h5 class="text-xl font-semibold tracking-tight text-gray-900 mb-1 truncate">{{ library.name }}</h5>
<div class="flex space-x-2">
<button @click="handleEditLibrary(library)" class="text-gray-500 hover:text-blue-700 focus:outline-none">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
</button>
<button @click="handleDeleteLibrary(library)" class="text-gray-500 hover:text-red-700 focus:outline-none">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
</button>
</div>
</div>
<p class="text-sm text-gray-600 mb-3 line-clamp-2">
{{ library.description || '暂无描述' }}
</p>
<div class="flex justify-between items-center">
<span class="text-xs text-gray-500">
创建时间: {{ formatDate(library.createTime) }}
</span>
<button @click="navigateToLibraryDocs(library)"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5">
查看知识库
</button>
</div>
</div>
</div>
</div>
<div v-if="libraries.length === 0" class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg mb-4">暂无知识库</div>
<div>
<button @click="handleCreateLibraryClick"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 focus:outline-none">
创建知识库
</button>
</div>
</div>
</div>
<!-- 知识库表单对话框 -->
<LibraryFormDialog :id="'library-form-modal'" :library="selectedLibrary" :closeModal="() => {
libraryFormModal?.hide();
}" :onSubmit="handleLibraryFormSubmit" />
<!-- 删除确认对话框 -->
<ConfirmationDialog :id="'library-delete-modal'" :title="`确定删除知识库 '${selectedLibrary?.name || ''}' 吗?`"
content="删除后将无法恢复,且其中的所有文档也将被删除。" :closeModal="() => {
libraryDeleteModal?.hide();
}" :onSubmit="handleLibraryDeleteSubmit" />
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import ConfirmationDialog from "@/components/modals/ConfirmationDialog.vue";
import LibraryFormDialog from "@/components/modals/LibraryFormDialog.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { useKnowledgeUpsert } from "@/composables/knowledge/useKnowledgeUpsert";
import useAlertStore from "@/composables/store/useAlertStore";
import { Routes } from "@/router/constants";
import type { Library, LibraryUpsertModel } from "@/types/KnowledgeTypes";
// 获取知识库列表
const { libraries, fetchLibraries } = useKnowledgeQuery();
// 知识库操作
const { upsertLibrary, deleteLibrary } = useKnowledgeUpsert();
// 模态框引用
const libraryFormModal = ref<ModalInterface>();
const libraryDeleteModal = ref<ModalInterface>();
// 选中的知识库
const selectedLibrary = ref<Library | undefined>();
// 路由
const router = useRouter();
const alertStore = useAlertStore();
// 格式化日期
const formatDate = (dateString?: string) => {
if (!dateString) return "未知";
return dayjs(dateString).format("YYYY-MM-DD HH:mm");
};
// 处理创建知识库点击
const handleCreateLibraryClick = () => {
selectedLibrary.value = undefined;
libraryFormModal.value?.show();
};
// 处理编辑知识库
const handleEditLibrary = (library: Library) => {
selectedLibrary.value = library;
libraryFormModal.value?.show();
};
// 处理删除知识库
const handleDeleteLibrary = (library: Library) => {
selectedLibrary.value = library;
libraryDeleteModal.value?.show();
};
// 处理知识库表单提交
const handleLibraryFormSubmit = async (data: LibraryUpsertModel) => {
await upsertLibrary(data);
alertStore.showAlert({
level: "success",
content: data.id ? "知识库更新成功" : "知识库创建成功",
});
libraryFormModal.value?.hide();
await fetchLibraries();
};
// 处理知识库删除确认
const handleLibraryDeleteSubmit = async () => {
if (!selectedLibrary.value?.id) return;
await deleteLibrary(selectedLibrary.value.id);
alertStore.showAlert({
level: "success",
content: "知识库删除成功",
});
libraryDeleteModal.value?.hide();
await fetchLibraries();
};
// 导航到知识库文档页面
const navigateToLibraryDocs = (library: Library) => {
if (!library.id) return;
router.push(Routes.KNOWLEDGEDOCVIEW.withParams({ libraryId: library.id }));
};
// 初始化
onMounted(async () => {
initFlowbite();
// 初始化模态框
const libraryFormElement = document.getElementById("library-form-modal");
if (libraryFormElement) {
libraryFormModal.value = new Modal(libraryFormElement);
}
const libraryDeleteElement = document.getElementById("library-delete-modal");
if (libraryDeleteElement) {
libraryDeleteModal.value = new Modal(libraryDeleteElement);
}
// 获取知识库列表
await fetchLibraries();
});
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理', '文档管理', '内容管理']"
:routes="[Routes.KNOWLEDGEVIEW.fullPath(), Routes.KNOWLEDGEDOCVIEW.withParams({ libraryId: libraryId })]" />
<div class="mb-4">
<h1 class="text-2xl font-semibold text-gray-900 mb-2">{{ currentDoc?.name || '文档' }} - 分段内容</h1>
<p class="text-sm text-gray-500">
{{ segments.length }} 个分段
</p>
</div>
<!-- 分段列表 -->
<div v-if="segments.length === 0" class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg">暂无分段内容</div>
</div>
<div v-else class="space-y-4">
<div v-for="(segment, index) in segments" :key="segment.id"
class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<div class="flex justify-between items-start mb-2">
<h5 class="text-lg font-semibold text-gray-900">分段 #{{ index + 1 }}</h5>
<div class="text-xs text-gray-500">
ID: {{ segment.id }}
</div>
</div>
<div class="text-sm text-gray-500 mb-2">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
Embedding ID: {{ segment.embeddingId || '无' }}
</span>
<span
class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
Token 使用量: {{ segment.tokenUsage || 0 }}
</span>
</div>
</div>
<div class="border-t border-gray-200 pt-3 mt-3">
<h6 class="text-sm font-medium text-gray-900 mb-2">内容:</h6>
<pre
class="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-60 overflow-y-auto">{{ segment.content }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect } from "vue";
import { useRoute } from "vue-router";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { Routes } from "@/router/constants";
import type { Library, LibraryDoc } from "@/types/KnowledgeTypes";
// 路由参数
const route = useRoute();
const libraryId = ref<number>(
Number.parseInt(route.params.libraryId as string, 10),
);
const docId = ref<number>(Number.parseInt(route.params.docId as string, 10));
// 获取知识库信息
const { libraries, fetchLibraries } = useKnowledgeQuery();
const currentLibrary = ref<Library | undefined>();
// 获取文档信息
const { docs, fetchLibraryDocs } = useKnowledgeQuery();
const currentDoc = ref<LibraryDoc | undefined>();
// 获取分段列表
const { segments, fetchDocSegments } = useKnowledgeQuery();
// 初始化
onMounted(async () => {
// 获取知识库列表、文档列表和分段列表
await fetchLibraries();
await fetchLibraryDocs({ libraryId: libraryId.value });
await fetchDocSegments({ libraryDocId: docId.value });
});
// 监听知识库列表变化,找到当前知识库
watchEffect(() => {
if (libraries.value && libraries.value.length > 0) {
currentLibrary.value = libraries.value.find(
(lib) => lib.id === libraryId.value,
);
}
});
// 监听文档列表变化,找到当前文档
watchEffect(() => {
if (docs.value && docs.value.length > 0) {
currentDoc.value = docs.value.find((doc) => doc.id === docId.value);
}
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<div class="mb-4 col-span-full">
<div class="mb-4">
<Breadcrumbs :names="['角色管理']" />
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl">角色管理</h1>
</div>