feat: add knowledge management functionality with CRUD operations and database schema

- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations.
- Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta.
- Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
This commit is contained in:
2026-04-05 17:14:13 +08:00
parent 134dd063c3
commit 446f865cdf
62 changed files with 9503 additions and 2664 deletions
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import type { KnowledgeGroup } from "@geo/shared-types";
import { computed } from "vue";
import { knowledgeApi } from "@/lib/api";
interface TreeNode {
title: string;
value: number;
key: number;
children?: TreeNode[];
}
const props = withDefaults(
defineProps<{
modelValue?: number[];
placeholder?: string;
disabled?: boolean;
}>(),
{
modelValue: () => [],
placeholder: "请选择知识库分组",
disabled: false,
},
);
const emit = defineEmits<{
"update:modelValue": [value: number[]];
}>();
const groupsQuery = useQuery({
queryKey: ["knowledge", "groups", "selector"],
queryFn: () => knowledgeApi.listGroups(),
});
const treeData = computed(() => (groupsQuery.data.value ?? []).map((item) => toTreeNode(item)));
function toTreeNode(group: KnowledgeGroup): TreeNode {
return {
title: `${group.name}${group.item_count > 0 ? ` (${group.item_count})` : ""}`,
value: group.id,
key: group.id,
children: (group.children ?? []).map((item) => toTreeNode(item)),
};
}
function handleChange(values: Array<string | number>): void {
emit(
"update:modelValue",
values
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0),
);
}
</script>
<template>
<a-tree-select
:value="modelValue"
:tree-data="treeData"
tree-checkable
tree-default-expand-all
multiple
show-search
allow-clear
size="large"
style="width: 100%"
:placeholder="placeholder"
:disabled="disabled"
:loading="groupsQuery.isPending.value"
:max-tag-count="3"
@update:value="handleChange"
/>
</template>
@@ -6,6 +6,7 @@ import type { PromptRule, PromptRuleGroup } from "@geo/shared-types";
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import KnowledgeGroupSelect from "@/components/KnowledgeGroupSelect.vue";
import { promptRulesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
@@ -29,6 +30,7 @@ const form = reactive({
default_tone: "",
default_word_count: undefined as number | undefined,
group_id: undefined as number | undefined,
knowledge_group_ids: [] as number[],
});
const groupsQuery = useQuery({
@@ -50,6 +52,7 @@ watch(
form.default_tone = rule?.default_tone ?? "";
form.default_word_count = rule?.default_word_count ?? undefined;
form.group_id = rule?.group_id ?? undefined;
form.knowledge_group_ids = rule?.knowledge_group_ids ?? [];
},
);
@@ -62,6 +65,7 @@ const createMutation = useMutation({
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async (data) => {
message.success(t("custom.messages.ruleCreated"));
@@ -81,6 +85,7 @@ const updateMutation = useMutation({
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
knowledge_group_ids: form.knowledge_group_ids,
}),
onSuccess: async () => {
message.success(t("custom.messages.ruleUpdated"));
@@ -197,6 +202,14 @@ async function handleSubmit(): Promise<void> {
/>
</div>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">知识库引用</label>
<KnowledgeGroupSelect
v-model="form.knowledge_group_ids"
placeholder="可选,用于补充生成内容的背景信息"
/>
</div>
</div>
<template #footer>
@@ -0,0 +1,209 @@
<template>
<div class="characters-container">
<!-- Purple -->
<div ref="purpleRef" class="char purple" :style="purpleStyle">
<div class="eyes" :style="purpleEyesStyle">
<EyeBall v-for="i in 2" :key="'p'+i" :size="18" :pupil-size="7" :max-distance="5"
eye-color="white" pupil-color="#2D2D2D" :is-blinking="isPurpleBlinking"
:force-look-x="purpleLookX" :force-look-y="purpleLookY" />
</div>
</div>
<!-- Black -->
<div ref="blackRef" class="char black" :style="blackStyle">
<div class="eyes" :style="blackEyesStyle">
<EyeBall v-for="i in 2" :key="'b'+i" :size="16" :pupil-size="6" :max-distance="4"
eye-color="white" pupil-color="#2D2D2D" :is-blinking="isBlackBlinking"
:force-look-x="blackLookX" :force-look-y="blackLookY" />
</div>
</div>
<!-- Orange -->
<div ref="orangeRef" class="char orange" :style="orangeStyle">
<div class="eyes" :style="orangeEyesStyle">
<Pupil v-for="i in 2" :key="'o'+i" :size="12" :max-distance="5" pupil-color="#2D2D2D"
:force-look-x="hiding ? -5 : undefined" :force-look-y="hiding ? -4 : undefined" />
</div>
</div>
<!-- Yellow -->
<div ref="yellowRef" class="char yellow" :style="yellowStyle">
<div class="eyes" :style="yellowEyesStyle">
<Pupil v-for="i in 2" :key="'y'+i" :size="12" :max-distance="5" pupil-color="#2D2D2D"
:force-look-x="hiding ? -5 : undefined" :force-look-y="hiding ? -4 : undefined" />
</div>
<div class="mouth" :style="yellowMouthStyle" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import EyeBall from './EyeBall.vue'
import Pupil from './Pupil.vue'
const props = withDefaults(defineProps<{
/** Whether the user is currently typing in an input */
isTyping?: boolean
/** Whether a secret field has content */
hasSecret?: boolean
/** Whether the secret is currently visible (e.g. password shown) */
secretVisible?: boolean
}>(), {
isTyping: false,
hasSecret: false,
secretVisible: false,
})
const mouseX = ref(0)
const mouseY = ref(0)
const isPurpleBlinking = ref(false)
const isBlackBlinking = ref(false)
const isLookingAtEachOther = ref(false)
const isPurplePeeking = ref(false)
const purpleRef = ref<HTMLDivElement | null>(null)
const blackRef = ref<HTMLDivElement | null>(null)
const yellowRef = ref<HTMLDivElement | null>(null)
const orangeRef = ref<HTMLDivElement | null>(null)
const purplePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const blackPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const yellowPos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
const orangePos = reactive({ faceX: 0, faceY: 0, bodySkew: 0 })
// Derived state
const hiding = computed(() => props.hasSecret && props.secretVisible)
const leaning = computed(() => props.isTyping || (props.hasSecret && !props.secretVisible))
function calcPos(el: HTMLDivElement | null, target: { faceX: number; faceY: number; bodySkew: number }) {
if (!el) return
const r = el.getBoundingClientRect()
const dx = mouseX.value - (r.left + r.width / 2)
const dy = mouseY.value - (r.top + r.height / 3)
target.faceX = Math.max(-15, Math.min(15, dx / 20))
target.faceY = Math.max(-10, Math.min(10, dy / 30))
target.bodySkew = Math.max(-6, Math.min(6, -dx / 120))
}
let rafId = 0
function tick() {
calcPos(purpleRef.value, purplePos)
calcPos(blackRef.value, blackPos)
calcPos(yellowRef.value, yellowPos)
calcPos(orangeRef.value, orangePos)
rafId = requestAnimationFrame(tick)
}
function onMouseMove(e: MouseEvent) { mouseX.value = e.clientX; mouseY.value = e.clientY }
function setupBlink(target: { value: boolean }) {
let t: number
const go = () => {
t = window.setTimeout(() => {
target.value = true
window.setTimeout(() => { target.value = false; go() }, 150)
}, Math.random() * 4000 + 3000)
}
go()
return () => clearTimeout(t)
}
let stopP: (() => void) | undefined
let stopB: (() => void) | undefined
onMounted(() => {
window.addEventListener('mousemove', onMouseMove)
stopP = setupBlink(isPurpleBlinking)
stopB = setupBlink(isBlackBlinking)
rafId = requestAnimationFrame(tick)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(rafId)
stopP?.(); stopB?.()
})
// Look at each other when typing starts
watch(() => props.isTyping, (v) => {
if (v) {
isLookingAtEachOther.value = true
setTimeout(() => { isLookingAtEachOther.value = false }, 800)
} else {
isLookingAtEachOther.value = false
}
})
// Purple peeks when secret is visible
let peekT: number | undefined
watch([() => props.hasSecret, () => props.secretVisible, isPurplePeeking], () => {
clearTimeout(peekT)
if (props.hasSecret && props.secretVisible) {
peekT = window.setTimeout(() => {
isPurplePeeking.value = true
setTimeout(() => { isPurplePeeking.value = false }, 800)
}, Math.random() * 3000 + 2000)
} else {
isPurplePeeking.value = false
}
})
// Computed styles
const purpleStyle = computed(() => ({
height: leaning.value ? '440px' : '400px',
transform: hiding.value ? 'skewX(0deg)'
: leaning.value ? `skewX(${purplePos.bodySkew - 12}deg) translateX(40px)`
: `skewX(${purplePos.bodySkew}deg)`,
}))
const purpleEyesStyle = computed(() => ({
left: hiding.value ? '20px' : isLookingAtEachOther.value ? '55px' : `${45 + purplePos.faceX}px`,
top: hiding.value ? '35px' : isLookingAtEachOther.value ? '65px' : `${40 + purplePos.faceY}px`,
gap: '32px',
}))
const purpleLookX = computed(() => hiding.value ? (isPurplePeeking.value ? 4 : -4) : isLookingAtEachOther.value ? 3 : undefined)
const purpleLookY = computed(() => hiding.value ? (isPurplePeeking.value ? 5 : -4) : isLookingAtEachOther.value ? 4 : undefined)
const blackStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)'
: isLookingAtEachOther.value ? `skewX(${blackPos.bodySkew * 1.5 + 10}deg) translateX(20px)`
: leaning.value ? `skewX(${blackPos.bodySkew * 1.5}deg)`
: `skewX(${blackPos.bodySkew}deg)`,
}))
const blackEyesStyle = computed(() => ({
left: hiding.value ? '10px' : isLookingAtEachOther.value ? '32px' : `${26 + blackPos.faceX}px`,
top: hiding.value ? '28px' : isLookingAtEachOther.value ? '12px' : `${32 + blackPos.faceY}px`,
gap: '24px',
}))
const blackLookX = computed(() => hiding.value ? -4 : isLookingAtEachOther.value ? 0 : undefined)
const blackLookY = computed(() => hiding.value ? -4 : isLookingAtEachOther.value ? -4 : undefined)
const orangeStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${orangePos.bodySkew}deg)`,
}))
const orangeEyesStyle = computed(() => ({
left: hiding.value ? '50px' : `${82 + orangePos.faceX}px`,
top: hiding.value ? '85px' : `${90 + orangePos.faceY}px`,
gap: '32px',
}))
const yellowStyle = computed(() => ({
transform: hiding.value ? 'skewX(0deg)' : `skewX(${yellowPos.bodySkew}deg)`,
}))
const yellowEyesStyle = computed(() => ({
left: hiding.value ? '20px' : `${52 + yellowPos.faceX}px`,
top: hiding.value ? '35px' : `${40 + yellowPos.faceY}px`,
gap: '24px',
}))
const yellowMouthStyle = computed(() => ({
left: hiding.value ? '10px' : `${40 + yellowPos.faceX}px`,
top: hiding.value ? '88px' : `${88 + yellowPos.faceY}px`,
}))
</script>
<style scoped>
.characters-container { position: relative; width: 550px; height: 400px; }
.char { position: absolute; bottom: 0; transition: all 0.7s ease-in-out; transform-origin: bottom center; }
.purple { left: 70px; width: 180px; background: #6C3FF5; border-radius: 10px 10px 0 0; z-index: 1; }
.black { left: 240px; width: 120px; height: 310px; background: #2D2D2D; border-radius: 8px 8px 0 0; z-index: 2; }
.orange { left: 0; width: 240px; height: 200px; background: #FF9B6B; border-radius: 120px 120px 0 0; z-index: 3; }
.yellow { left: 310px; width: 140px; height: 230px; background: #E8D754; border-radius: 70px 70px 0 0; z-index: 4; }
.eyes { position: absolute; display: flex; transition: all 0.7s ease-in-out; }
.mouth { position: absolute; width: 80px; height: 4px; background: #2D2D2D; border-radius: 4px; transition: all 0.2s ease-out; }
</style>
@@ -0,0 +1,55 @@
<template>
<div ref="eyeRef" class="eyeball" :style="{
width: size + 'px', height: isBlinking ? '2px' : size + 'px',
backgroundColor: eyeColor,
}">
<div v-if="!isBlinking" class="pupil-inner" :style="{
width: pupilSize + 'px', height: pupilSize + 'px',
backgroundColor: pupilColor,
transform: `translate(${pos.x}px, ${pos.y}px)`,
}" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
size?: number; pupilSize?: number; maxDistance?: number
eyeColor?: string; pupilColor?: string; isBlinking?: boolean
forceLookX?: number; forceLookY?: number
}>(), { size: 48, pupilSize: 16, maxDistance: 10, eyeColor: 'white', pupilColor: 'black', isBlinking: false })
const mx = ref(0), my = ref(0)
const eyeRef = ref<HTMLDivElement>()
const onMove = (e: MouseEvent) => { mx.value = e.clientX; my.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
const pos = computed(() => {
if (!eyeRef.value) return { x: 0, y: 0 }
if (props.forceLookX !== undefined && props.forceLookY !== undefined)
return { x: props.forceLookX, y: props.forceLookY }
const r = eyeRef.value.getBoundingClientRect()
const dx = mx.value - (r.left + r.width / 2)
const dy = my.value - (r.top + r.height / 2)
const d = Math.min(Math.sqrt(dx ** 2 + dy ** 2), props.maxDistance)
const a = Math.atan2(dy, dx)
return { x: Math.cos(a) * d, y: Math.sin(a) * d }
})
</script>
<style scoped>
.eyeball {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
overflow: hidden;
}
.pupil-inner {
border-radius: 50%;
transition: transform 0.1s ease-out;
}
</style>
@@ -0,0 +1,38 @@
<template>
<div ref="pupilRef" class="pupil" :style="{
width: size + 'px', height: size + 'px',
backgroundColor: pupilColor,
transform: `translate(${pos.x}px, ${pos.y}px)`,
}" />
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
size?: number; maxDistance?: number; pupilColor?: string
forceLookX?: number; forceLookY?: number
}>(), { size: 12, maxDistance: 5, pupilColor: 'black' })
const mx = ref(0), my = ref(0)
const pupilRef = ref<HTMLDivElement>()
const onMove = (e: MouseEvent) => { mx.value = e.clientX; my.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
const pos = computed(() => {
if (!pupilRef.value) return { x: 0, y: 0 }
if (props.forceLookX !== undefined && props.forceLookY !== undefined)
return { x: props.forceLookX, y: props.forceLookY }
const r = pupilRef.value.getBoundingClientRect()
const dx = mx.value - (r.left + r.width / 2)
const dy = my.value - (r.top + r.height / 2)
const d = Math.min(Math.sqrt(dx ** 2 + dy ** 2), props.maxDistance)
const a = Math.atan2(dy, dx)
return { x: Math.cos(a) * d, y: Math.sin(a) * d }
})
</script>
<style scoped>
.pupil { border-radius: 50%; transition: transform 0.1s ease-out; }
</style>
+50
View File
@@ -18,6 +18,11 @@ import type {
InstantTaskListParams,
InstantTaskListResponse,
JsonValue,
KnowledgeGroup,
KnowledgeGroupRequest,
KnowledgeItem,
KnowledgeTextItemRequest,
KnowledgeURLItemRequest,
Keyword,
KeywordRequest,
LoginRequest,
@@ -225,6 +230,51 @@ export const articlesApi = {
},
};
export const knowledgeApi = {
listGroups() {
return apiClient.get<KnowledgeGroup[]>("/api/tenant/knowledge/groups");
},
createGroup(payload: KnowledgeGroupRequest) {
return apiClient.post<KnowledgeGroup, KnowledgeGroupRequest>("/api/tenant/knowledge/groups", payload);
},
updateGroup(id: number, payload: KnowledgeGroupRequest) {
return apiClient.put<null, KnowledgeGroupRequest>(`/api/tenant/knowledge/groups/${id}`, payload);
},
removeGroup(id: number) {
return apiClient.remove<null>(`/api/tenant/knowledge/groups/${id}`);
},
listItems(groupId?: number) {
return apiClient.get<KnowledgeItem[]>("/api/tenant/knowledge/items", {
params: groupId ? { group_id: groupId } : undefined,
});
},
createTextItem(payload: KnowledgeTextItemRequest) {
return apiClient.post<KnowledgeItem, KnowledgeTextItemRequest>(
"/api/tenant/knowledge/items/text",
payload,
);
},
createURLItem(payload: KnowledgeURLItemRequest) {
return apiClient.post<KnowledgeItem, KnowledgeURLItemRequest>(
"/api/tenant/knowledge/items/url",
payload,
);
},
async createFileItem(payload: { group_id: number; name?: string; file: File }) {
const form = new FormData();
form.set("group_id", String(payload.group_id));
if (payload.name?.trim()) {
form.set("name", payload.name.trim());
}
form.set("file", payload.file);
const { data } = await apiClient.raw.post("/api/tenant/knowledge/items/file", form);
return data.data as KnowledgeItem;
},
removeItem(id: number) {
return apiClient.remove<null>(`/api/tenant/knowledge/items/${id}`);
},
};
export interface ArticleGenerationStreamEvent {
type: string;
article_id: number;
+6
View File
@@ -37,9 +37,11 @@ import {
Table,
Tabs,
Tag,
Tree,
TimePicker,
Tooltip,
Upload,
TreeSelect,
} from "ant-design-vue";
import App from "./App.vue";
@@ -48,6 +50,8 @@ import { router } from "./router";
import { pinia } from "./stores/pinia";
import "./styles.css";
import "ant-design-vue/dist/reset.css";
import select from "ant-design-vue/es/select";
import tree from "ant-design-vue/es/tree";
const queryClient = new QueryClient({
defaultOptions: {
@@ -101,6 +105,8 @@ app.component("ATextarea", Input.TextArea);
Tag,
TimePicker,
Tooltip,
Tree,
TreeSelect,
Upload,
].forEach((component) => {
app.use(component);
+1 -1
View File
@@ -119,7 +119,7 @@ const router = createRouter({
{
path: "knowledge",
name: "knowledge",
component: () => import("@/views/FeatureStubView.vue"),
component: () => import("@/views/KnowledgeView.vue"),
meta: {
titleKey: "route.knowledge.title",
descriptionKey: "route.knowledge.description",
+970
View File
@@ -0,0 +1,970 @@
<script setup lang="ts">
import { MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, Modal, type TableColumnsType, type UploadProps } from "ant-design-vue";
import type { KnowledgeGroup, KnowledgeItem } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import { knowledgeApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
type KnowledgeSourceType = "document" | "website" | "text";
type KnowledgeGroupLevel = "root" | "child";
const DEFAULT_CHILD_GROUP_NAME = "默认目录";
interface TreeNode {
title: string;
value: number;
key: number;
disabled?: boolean;
children?: TreeNode[];
}
const queryClient = useQueryClient();
const selectedGroupId = ref<number | null>(null);
const groupModalOpen = ref(false);
const editingGroup = ref<KnowledgeGroup | null>(null);
const itemModalOpen = ref(false);
const itemSourceType = ref<KnowledgeSourceType>("document");
const uploadFiles = ref<File[]>([]);
const groupForm = reactive({
name: "",
level: "root" as KnowledgeGroupLevel,
parent_id: undefined as number | undefined,
});
const itemForm = reactive({
group_id: undefined as number | undefined,
});
const urlsText = ref("");
const textItems = ref([{ name: "", content: "" }]);
const groupsQuery = useQuery({
queryKey: ["knowledge", "groups"],
queryFn: () => knowledgeApi.listGroups(),
});
const itemsQuery = useQuery({
queryKey: computed(() => ["knowledge", "items", selectedGroupId.value]),
queryFn: () => knowledgeApi.listItems(selectedGroupId.value ?? undefined),
});
const columns: TableColumnsType<KnowledgeItem> = [
{ title: "名称", dataIndex: "name", key: "name", width: 260 },
{ title: "类型", dataIndex: "source_type", key: "source_type", width: 120 },
{ title: "大小", dataIndex: "size_bytes", key: "size_bytes", width: 120 },
{ title: "AI学习", dataIndex: "status", key: "status", width: 120 },
{ title: "上传时间", dataIndex: "created_at", key: "created_at", width: 180 },
{ title: "操作", key: "actions", width: 120, fixed: "right", align: "right" },
];
const rootGroups = computed(() => groupsQuery.data.value ?? []);
const groupMap = computed(() => buildGroupMap(rootGroups.value));
const treeData = computed(() => rootGroups.value.map((item) => toSidebarTreeNode(item)));
const storableTreeData = computed(() => rootGroups.value.map((item) => toStorageTreeNode(item)));
const storableGroupCount = computed(() => countStorableGroups(rootGroups.value));
const groupModalTitle = computed(() => {
if (editingGroup.value) {
return isRootGroup(editingGroup.value) ? "编辑分组" : "编辑子目录";
}
return groupForm.level === "root" ? "新建分组" : "新建子目录";
});
const groupModalLoading = computed(
() => groupMutations.create.isPending.value || groupMutations.update.isPending.value,
);
const groupMutations = {
create: useMutation({
mutationFn: () =>
knowledgeApi.createGroup({
name: groupForm.name.trim(),
parent_id: groupForm.level === "child" ? (groupForm.parent_id ?? null) : null,
}),
onSuccess: async (group) => {
message.success("分组创建成功");
selectedGroupId.value = group.id;
closeGroupModal();
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
}),
update: useMutation({
mutationFn: () =>
knowledgeApi.updateGroup(editingGroup.value!.id, {
name: groupForm.name.trim(),
}),
onSuccess: async () => {
message.success("分组更新成功");
closeGroupModal();
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
}),
remove: useMutation({
mutationFn: (id: number) => knowledgeApi.removeGroup(id),
onSuccess: async (_data, id) => {
message.success("分组已删除");
if (selectedGroupId.value === id) {
selectedGroupId.value = null;
}
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
}),
};
const itemMutation = useMutation({
mutationFn: async () => {
let targetGroupId = itemForm.group_id;
if (!targetGroupId) {
throw new Error("请选择分组目录");
}
targetGroupId = await resolveItemTargetGroupId(targetGroupId);
const promises: Promise<any>[] = [];
if (itemSourceType.value === "document") {
if (uploadFiles.value.length === 0) {
throw new Error("请先选择文件");
}
for (const file of uploadFiles.value) {
promises.push(
knowledgeApi.createFileItem({
group_id: targetGroupId,
name: file.name,
file: file,
})
);
}
} else if (itemSourceType.value === "website") {
const urls = urlsText.value.split('\n').map(u => u.trim()).filter(Boolean);
if (urls.length === 0) {
throw new Error("请输入包含URL的文本");
}
for (const url of urls) {
let name = url;
try { name = new URL(url).hostname + new URL(url).pathname } catch (e) {}
promises.push(
knowledgeApi.createURLItem({
group_id: targetGroupId,
name: name,
url: url,
})
);
}
} else {
const validTexts = textItems.value.filter(t => t.name.trim() && t.content.trim());
if (validTexts.length === 0) {
throw new Error("请至少输入一项完整的文本内容");
}
for (const txt of validTexts) {
promises.push(
knowledgeApi.createTextItem({
group_id: targetGroupId,
name: txt.name.trim(),
content: txt.content.trim(),
})
);
}
}
await Promise.all(promises);
},
onSuccess: async () => {
message.success("内容已提交");
itemModalOpen.value = false;
resetItemForm();
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
});
const deleteItemMutation = useMutation({
mutationFn: (id: number) => knowledgeApi.removeItem(id),
onSuccess: async () => {
message.success("内容已删除");
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
},
onError: (error) => message.error(formatError(error)),
});
watch(
() => groupsQuery.data.value,
(groups) => {
if (!groups || groups.length === 0) {
selectedGroupId.value = null;
return;
}
if (selectedGroupId.value && hasGroupId(groups, selectedGroupId.value)) {
return;
}
selectedGroupId.value = findFirstGroupId(groups);
},
{ immediate: true },
);
function buildGroupMap(groups: KnowledgeGroup[]): Map<number, KnowledgeGroup> {
const result = new Map<number, KnowledgeGroup>();
const visit = (items: KnowledgeGroup[]) => {
items.forEach((group) => {
result.set(group.id, group);
visit(group.children ?? []);
});
};
visit(groups);
return result;
}
function toSidebarTreeNode(group: KnowledgeGroup): TreeNode {
return {
title: `${group.name} (${group.item_count})`,
key: group.id,
value: group.id,
children: (group.children ?? []).map((item) => toSidebarTreeNode(item)),
};
}
function toStorageTreeNode(group: KnowledgeGroup): TreeNode {
return {
title: group.name,
key: group.id,
value: group.id,
children: (group.children ?? []).map((item) => toStorageTreeNode(item)),
};
}
function hasGroupId(groups: KnowledgeGroup[], targetId: number): boolean {
for (const group of groups) {
if (group.id === targetId) {
return true;
}
if (hasGroupId(group.children ?? [], targetId)) {
return true;
}
}
return false;
}
function findFirstGroupId(groups: KnowledgeGroup[]): number | null {
for (const group of groups) {
if (group.id > 0) {
return group.id;
}
const childID = findFirstGroupId(group.children ?? []);
if (childID) {
return childID;
}
}
return null;
}
function findFirstStorableGroupId(groups: KnowledgeGroup[]): number | undefined {
for (const group of groups) {
if (isStorableGroup(group)) {
return group.id;
}
const childID = findFirstStorableGroupId(group.children ?? []);
if (childID) {
return childID;
}
}
return undefined;
}
function countStorableGroups(groups: KnowledgeGroup[]): number {
return groups.reduce(
(count, group) => count + (isStorableGroup(group) ? 1 : 0) + countStorableGroups(group.children ?? []),
0,
);
}
function getGroupById(id: number): KnowledgeGroup | undefined {
return groupMap.value.get(id);
}
function isRootGroup(group: KnowledgeGroup): boolean {
return group.parent_id == null;
}
function isNodeRootGroup(id: number): boolean {
const group = getGroupById(id);
return group ? isRootGroup(group) : false;
}
function isStorableGroup(group: KnowledgeGroup): boolean {
return group.parent_id != null;
}
function isStorableGroupId(id: number | null | undefined): boolean {
if (!id) {
return false;
}
const group = getGroupById(id);
return Boolean(group && isStorableGroup(group));
}
function findChildGroupByName(parentId: number, name: string): KnowledgeGroup | undefined {
const parentGroup = getGroupById(parentId);
return parentGroup?.children.find((child) => child.name === name);
}
async function resolveItemTargetGroupId(groupId: number): Promise<number> {
if (!isNodeRootGroup(groupId)) {
return groupId;
}
const defaultChild = findChildGroupByName(groupId, DEFAULT_CHILD_GROUP_NAME);
if (defaultChild) {
return defaultChild.id;
}
const newGroup = await knowledgeApi.createGroup({
name: DEFAULT_CHILD_GROUP_NAME,
parent_id: groupId,
});
return newGroup.id;
}
function resolveDefaultItemGroupId(): number | undefined {
if (selectedGroupId.value) {
return selectedGroupId.value;
}
return rootGroups.value[0]?.id;
}
function openCreateGroupModal(): void {
editingGroup.value = null;
groupForm.name = "";
groupForm.parent_id = undefined;
groupForm.level = "root";
groupModalOpen.value = true;
}
function openCreateSubdirectoryModal(parentId: number): void {
editingGroup.value = null;
groupForm.name = "";
groupForm.parent_id = parentId;
groupForm.level = "child";
groupModalOpen.value = true;
}
function openEditGroupModal(groupId: number): void {
const group = getGroupById(groupId);
if (!group) {
return;
}
editingGroup.value = group;
groupForm.name = group.name;
groupForm.level = isRootGroup(group) ? "root" : "child";
groupForm.parent_id = group.parent_id ?? undefined;
groupModalOpen.value = true;
}
function closeGroupModal(): void {
groupModalOpen.value = false;
editingGroup.value = null;
groupForm.name = "";
groupForm.level = "root";
groupForm.parent_id = undefined;
}
async function submitGroup(): Promise<void> {
if (!groupForm.name.trim()) {
message.warning("请输入分组名称");
return;
}
if (!editingGroup.value && groupForm.level === "child" && !groupForm.parent_id) {
message.warning("请选择所属一级目录");
return;
}
if (editingGroup.value) {
await groupMutations.update.mutateAsync();
return;
}
await groupMutations.create.mutateAsync();
}
function confirmDeleteGroup(groupId: number): void {
const group = getGroupById(groupId);
if (!group) {
return;
}
const content = isRootGroup(group)
? "删除一级目录后,该目录下的二级目录和知识内容会一并删除。确定继续吗?"
: "删除二级目录后,该目录下的知识内容会一并删除。确定继续吗?";
Modal.confirm({
title: `确认删除“${group.name}”?`,
content,
okText: "删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
await groupMutations.remove.mutateAsync(groupId);
},
});
}
function openItemModal(type: KnowledgeSourceType): void {
if (rootGroups.value.length === 0) {
message.warning("请先创建目录后再新增内容");
return;
}
itemSourceType.value = type;
itemForm.group_id = resolveDefaultItemGroupId();
itemModalOpen.value = true;
}
function resetItemForm(): void {
itemForm.group_id = resolveDefaultItemGroupId();
urlsText.value = "";
textItems.value = [{ name: "", content: "" }];
uploadFiles.value = [];
itemSourceType.value = "document";
}
function handleTreeSelect(keys: (string | number)[]): void {
if (!keys.length) {
return;
}
selectedGroupId.value = Number(keys[0]);
}
const beforeUpload: UploadProps["beforeUpload"] = (file) => {
uploadFiles.value.push(file as File);
return false;
};
function handleRemoveFile(file: any): void {
uploadFiles.value = uploadFiles.value.filter((f: any) => f.uid !== file.uid);
}
function handleAddTextItem(): void {
textItems.value.push({ name: "", content: "" });
}
function formatSourceType(value: KnowledgeItem["source_type"]): string {
if (value === "document") return "文档";
if (value === "website") return "网站";
return "文本";
}
function formatStatus(value: string): string {
switch (value) {
case "completed":
return "学习完成";
case "processing":
return "处理中";
case "failed":
return "失败";
case "pending":
return "待处理";
case "deleted":
return "已删除";
default:
return value;
}
}
function formatSize(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function editItem(): void {
message.info("内容编辑功能开发中");
}
</script>
<template>
<div class="knowledge-page">
<div class="knowledge-page__hero">
<h2>知识库</h2>
<p>
通过上传文档网站认证及任何与您的产品相关的包装及品牌故事内容对各种格式的数据和资料信息来进行归纳并结合生成文章
</p>
</div>
<div class="knowledge-main-card">
<div class="knowledge-toolbar">
<a-button type="primary" class="knowledge-add-btn" @click="openItemModal('document')">
<template #icon><PlusOutlined /></template>
添加知识
</a-button>
</div>
<div class="knowledge-layout-inner">
<aside class="knowledge-sidebar">
<div class="knowledge-sidebar__header">
<div class="knowledge-sidebar__title">分组</div>
<div class="knowledge-sidebar__add" @click="openCreateGroupModal">
<PlusOutlined /> 新建分组
</div>
</div>
<a-tree
v-if="treeData.length > 0"
block-node
default-expand-all
:tree-data="treeData"
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
@select="handleTreeSelect"
>
<template #title="{ title, key }">
<div class="tree-node-wrapper">
<span class="tree-node-title">{{ title }}</span>
<a-dropdown :trigger="['click', 'hover']" placement="bottomRight">
<span class="tree-node-more" @click.stop>
<MoreOutlined />
</span>
<template #overlay>
<a-menu>
<a-menu-item v-if="isNodeRootGroup(Number(key))" key="add-sub" @click.stop="openCreateSubdirectoryModal(Number(key))">
新建子目录
</a-menu-item>
<a-menu-item key="edit" @click.stop="openEditGroupModal(Number(key))">编辑</a-menu-item>
<a-menu-item key="delete" danger @click.stop="confirmDeleteGroup(Number(key))">
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</a-tree>
<a-empty v-else description="暂无分组" />
</aside>
<section class="knowledge-table">
<a-table
:columns="columns"
:data-source="itemsQuery.data.value ?? []"
:loading="itemsQuery.isPending.value"
:pagination="false"
row-key="id"
:scroll="{ x: 1080 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'source_type'">
{{ formatSourceType(record.source_type) }}
</template>
<template v-else-if="column.key === 'size_bytes'">
{{ formatSize(record.size_bytes) }}
</template>
<template v-else-if="column.key === 'status'">
<span class="knowledge-status-text">
{{ formatStatus(record.status) }}
</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="knowledge-actions">
<a-button type="link" style="padding: 0 4px" @click="editItem()">编辑</a-button>
<a-divider type="vertical" />
<a-popconfirm title="确认删除该内容?" @confirm="deleteItemMutation.mutate(record.id)">
<a-button type="link" style="padding: 0 4px">删除</a-button>
</a-popconfirm>
</div>
</template>
</template>
</a-table>
</section>
</div>
</div>
<a-modal
:open="groupModalOpen"
:title="groupModalTitle"
:confirm-loading="groupModalLoading"
@ok="submitGroup"
@cancel="closeGroupModal"
>
<div class="knowledge-form">
<div class="knowledge-form__field">
<label>{{ groupForm.level === 'root' ? '分组名称' : '目录名称' }}</label>
<a-input
v-model:value="groupForm.name"
:placeholder="groupForm.level === 'root' ? '请输入分组名称' : '请输入子目录名称'"
:maxlength="100"
/>
</div>
</div>
</a-modal>
<a-modal
:open="itemModalOpen"
title="添加知识库"
:confirm-loading="itemMutation.isPending.value"
width="640px"
@ok="itemMutation.mutate()"
@cancel="
itemModalOpen = false;
resetItemForm();
"
ok-text=" "
cancel-text=" "
:ok-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
:cancel-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
>
<div class="knowledge-form-add">
<div class="knowledge-type-row">
<span class="form-section-title">内容类型</span>
<a-radio-group v-model:value="itemSourceType">
<a-radio value="document">文档</a-radio>
<a-radio value="website">网页</a-radio>
<a-radio value="text">文本</a-radio>
</a-radio-group>
</div>
<div v-if="itemSourceType === 'document'" class="knowledge-content-wrapper">
<a-upload-dragger
:before-upload="beforeUpload"
:file-list="uploadFiles"
@remove="handleRemoveFile"
:multiple="true"
class="custom-dragger"
>
<div class="dragger-content">
<div class="dragger-icon-wrapper">
<div class="dragger-icon-bg">
<UploadOutlined class="dragger-icon" />
</div>
</div>
<p class="dragger-text">点击或拖拽文件至此区域即可上传</p>
<p class="dragger-hint">
请上传docpdftxtxlsxlsx格式不超过30M的文件严禁上传敏感数据或其他非法文件
</p>
<p class="dragger-count">已选择文件{{ uploadFiles.length }}</p>
</div>
</a-upload-dragger>
</div>
<div v-else-if="itemSourceType === 'website'" class="knowledge-content-wrapper">
<a-textarea
v-model:value="urlsText"
:rows="11"
placeholder="请输入产品相关网页URL,每行一个"
class="custom-textarea"
/>
</div>
<div v-else class="knowledge-content-wrapper">
<div class="text-items-list">
<div v-for="(txt, index) in textItems" :key="index" class="text-item-box">
<div class="text-item-row">
<span class="text-item-label">文本名称</span>
<a-input v-model:value="txt.name" placeholder="请输入文本名称" style="flex: 1;" />
</div>
<div class="text-item-row">
<span class="text-item-label">文本内容</span>
<a-textarea v-model:value="txt.content" placeholder="请输入文本内容" :rows="5" style="flex: 1;" />
</div>
</div>
</div>
<a-button type="dashed" class="add-text-btn" @click="handleAddTextItem">
<PlusOutlined /> 添加文本
</a-button>
</div>
<div class="knowledge-form-add__field">
<div class="form-section-title" style="margin-bottom: 8px;">知识库分组</div>
<a-tree-select
v-model:value="itemForm.group_id"
:tree-data="storableTreeData"
tree-default-expand-all
style="width: 240px"
placeholder="请选择分组目录"
/>
</div>
</div>
</a-modal>
</div>
</template>
<style scoped>
.knowledge-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.knowledge-page__hero {
display: flex;
flex-direction: column;
gap: 8px;
}
.knowledge-page__hero h2 {
margin: 0;
color: #111827;
font-size: 18px;
font-weight: 700;
}
.knowledge-page__hero p {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.knowledge-main-card {
background: #fff;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
}
.knowledge-toolbar {
display: flex;
}
.knowledge-add-btn {
width: 120px;
border-radius: 6px;
}
.knowledge-layout-inner {
display: flex;
gap: 20px;
align-items: stretch;
min-height: 500px;
}
.knowledge-sidebar {
width: 240px;
flex-shrink: 0;
padding: 16px;
border-radius: 8px;
border: 1px solid #f0f0f0;
background: #fff;
}
.knowledge-table {
flex: 1;
min-width: 0;
padding: 16px;
border-radius: 8px;
border: 1px solid #f0f0f0;
background: #fff;
}
.knowledge-sidebar__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.knowledge-sidebar__title {
color: #111827;
font-size: 14px;
}
.knowledge-sidebar__add {
color: #9ca3af;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.knowledge-sidebar__add:hover {
color: #1677ff;
}
.tree-node-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.tree-node-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node-more {
visibility: hidden;
color: #9ca3af;
padding: 0 4px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.ant-tree-node-content-wrapper:hover .tree-node-more {
visibility: visible;
}
.tree-node-more:hover {
color: #1677ff;
}
.knowledge-status-text {
color: #6b7280;
}
.knowledge-actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.knowledge-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.knowledge-form__field label {
display: block;
margin-bottom: 8px;
color: #111827;
font-weight: 600;
}
.knowledge-form__hint {
margin-top: 8px;
color: #6b7280;
font-size: 12px;
}
.knowledge-form-add {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.knowledge-type-row {
display: flex;
align-items: center;
gap: 16px;
}
.form-section-title {
color: #111827;
font-weight: 600;
font-size: 14px;
}
.knowledge-content-wrapper {
margin-top: 4px;
}
:deep(.custom-dragger.ant-upload-wrapper .ant-upload-drag) {
border: 1px dashed #7eb0f7;
background-color: #fff;
border-radius: 6px;
}
.dragger-content {
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
}
.dragger-icon {
font-size: 32px;
color: #9ca3af;
}
.dragger-text {
color: #111827;
font-size: 14px;
font-weight: 500;
margin: 0;
}
.dragger-hint {
color: #9ca3af;
font-size: 12px;
margin: 0;
max-width: 380px;
}
.dragger-count {
color: #9ca3af;
font-size: 13px;
margin: 0;
margin-top: 4px;
}
:deep(.custom-textarea) {
border: 1px solid #7eb0f7;
border-radius: 6px;
resize: none;
}
.text-items-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.text-item-box {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 16px;
background: #fafaf9;
display: flex;
flex-direction: column;
gap: 16px;
}
.text-item-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.text-item-label {
color: #4b5563;
font-size: 14px;
padding-top: 6px;
width: 72px;
}
.add-text-btn {
width: 140px;
color: #111827;
border-radius: 6px;
}
@media (max-width: 960px) {
.knowledge-layout-inner {
flex-direction: column;
}
.knowledge-sidebar {
width: 100%;
}
.knowledge-main-card {
padding: 16px;
}
}
</style>
@@ -0,0 +1,10 @@
--- /Users/liangxu/Documents/test/geo-rankly/apps/admin-web/src/views/KnowledgeView.vue
+++ /Users/liangxu/Documents/test/geo-rankly/apps/admin-web/src/views/KnowledgeView.vue
@@ -37,6 +37,5 @@
const itemForm = reactive({
- group_id: undefined as number | undefined,
- name: "",
+ group_ids: [] as number[],
url: "",
content: "",
});
+154 -146
View File
@@ -1,18 +1,101 @@
<template>
<div class="page">
<div class="left" :style="{ background: `linear-gradient(135deg, ${primaryColor}e6, ${primaryColor}, ${primaryColor}cc)` }">
<div class="brand">
<div class="brand-icon"><StarOutlined :style="{ fontSize: '16px' }" /></div>
<span>{{ t('app.name') }}</span>
</div>
<div class="characters-area">
<AnimatedCharacters
:is-typing="isTyping"
:has-secret="!!formState.password"
:secret-visible="showPassword"
/>
</div>
<div class="footer-links">
<a href="#">Privacy Policy</a>
<a href="#">Terms of Service</a>
<a href="#">Contact</a>
</div>
<div class="deco-grid" />
<div class="deco-circle deco-circle-1" />
<div class="deco-circle deco-circle-2" />
</div>
<div class="right">
<div class="form-wrapper">
<div class="mobile-brand">
<div class="brand-icon"><StarOutlined :style="{ fontSize: '16px' }" /></div>
<span>{{ t('app.name') }}</span>
</div>
<div class="header">
<h1>{{ t('auth.welcomeBack') }}</h1>
<p>{{ t('auth.tenantAdminLogin') }}</p>
</div>
<form @submit.prevent="handleSubmit" class="form">
<div class="field">
<label for="login-email">{{ t('auth.email') }}</label>
<input id="login-email" type="email" placeholder="admin@geo.local" v-model="formState.email"
autocomplete="off" @focus="isTyping = true" @blur="isTyping = false" required />
</div>
<div class="field">
<label for="login-password">{{ t('auth.password') }}</label>
<div class="password-wrap">
<input id="login-password" :type="showPassword ? 'text' : 'password'"
placeholder="Admin@123" v-model="formState.password" required />
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
<EyeInvisibleOutlined v-if="showPassword" />
<EyeOutlined v-else />
</button>
</div>
</div>
<div class="options">
<label class="remember"><input type="checkbox" v-model="remember" /> Remember for 30 days</label>
<a href="#" class="forgot">Forgot password?</a>
</div>
<button type="submit" class="btn-primary" :disabled="submitting"
:style="{ background: primaryColor }">
{{ submitting ? t('auth.loginAndEnter') + '...' : t('auth.loginAndEnter') }}
</button>
</form>
<div class="login-credentials">
<div class="login-credentials__row">
<span>{{ t("auth.defaultTestAccount") }}</span>
<strong>admin@geo.local</strong>
</div>
<div class="login-credentials__row">
<span>{{ t("auth.defaultTestPassword") }}</span>
<strong>Admin@123</strong>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { LockOutlined, MailOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { message } from "ant-design-vue";
import { StarOutlined, EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons-vue";
import { formatError } from "@/lib/errors";
import { useAuthStore } from "@/stores/auth";
import AnimatedCharacters from "@/components/login-animation/AnimatedCharacters.vue";
const authStore = useAuthStore();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const primaryColor = ref('#1f5cff');
const submitting = ref(false);
const showPassword = ref(false);
const isTyping = ref(false);
const remember = ref(false);
const formState = reactive({
email: "admin@geo.local",
@@ -38,174 +121,99 @@ async function handleSubmit(): Promise<void> {
}
</script>
<template>
<div class="login-screen">
<div class="login-screen__cover">
<div class="login-screen__cover-copy">
<p class="eyebrow">Tenant Admin</p>
<h1>{{ t("app.name") }}</h1>
<p>{{ t("app.loginIntro") }}</p>
</div>
</div>
<div class="login-screen__form-container">
<div class="login-header">
<h2>{{ t("auth.welcomeBack") }}</h2>
<p>{{ t("auth.tenantAdminLogin") }}</p>
</div>
<a-form
layout="vertical"
:model="formState"
class="login-form"
@finish="handleSubmit"
@submit.prevent="handleSubmit"
>
<a-form-item :label="t('auth.email')" name="email">
<a-input v-model:value="formState.email" size="large" placeholder="admin@geo.local">
<template #prefix><MailOutlined class="login-form__icon" /></template>
</a-input>
</a-form-item>
<a-form-item :label="t('auth.password')" name="password">
<a-input-password v-model:value="formState.password" size="large" placeholder="Admin@123">
<template #prefix><LockOutlined class="login-form__icon" /></template>
</a-input-password>
</a-form-item>
<a-button
type="primary"
size="large"
block
html-type="submit"
:loading="submitting"
@click="handleSubmit"
>
{{ t("auth.loginAndEnter") }}
</a-button>
</a-form>
<div class="login-credentials">
<div class="login-credentials__row">
<span>{{ t("auth.defaultTestAccount") }}</span>
<strong>admin@geo.local</strong>
</div>
<div class="login-credentials__row">
<span>{{ t("auth.defaultTestPassword") }}</span>
<strong>Admin@123</strong>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.login-screen {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(420px, 500px);
min-height: 100vh;
background: #eef3f9;
.page {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
width: 100%;
flex: 1;
}
.login-screen__cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background:
radial-gradient(circle at top left, rgba(112, 172, 255, 0.35), transparent 34%),
linear-gradient(135deg, #0f172a 0%, #1f5cff 48%, #57b5ff 100%);
color: #fff;
.left {
position: relative; display: flex; flex-direction: column; justify-content: space-between;
padding: 48px; color: white; overflow: hidden;
}
.brand { position: relative; z-index: 20; display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 600; }
.brand-icon {
width: 32px; height: 32px; border-radius: 8px; background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center;
}
.characters-area { position: relative; z-index: 20; display: flex; align-items: flex-end; justify-content: center; height: 500px; transform: scale(0.95); }
.footer-links { position: relative; z-index: 20; display: flex; gap: 32px; font-size: 14px; }
.footer-links a { color: rgba(255,255,255,0.6); transition: color 0.2s; }
.footer-links a:hover { color: white; }
.deco-grid {
position: absolute; inset: 0;
background-image: linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.deco-circle { position: absolute; border-radius: 50%; filter: blur(48px); }
.deco-circle-1 { top: 25%; right: 25%; width: 256px; height: 256px; background: rgba(255,255,255,0.1); }
.deco-circle-2 { bottom: 25%; left: 25%; width: 384px; height: 384px; background: rgba(255,255,255,0.05); }
.right { display: flex; align-items: center; justify-content: center; padding: 32px; background: #fff; }
.form-wrapper { width: 100%; max-width: 420px; }
.mobile-brand { display: none; }
.header { text-align: center; margin-bottom: 40px; }
.header h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 8px; color: #18181b; }
.header p { color: #71717a; font-size: 14px; margin: 0; }
.form { display: flex; flex-direction: column; gap: 20px; margin-bottom: 32px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field label { font-size: 14px; font-weight: 500; color: #18181b; }
.field input {
height: 48px; padding: 0 12px; border-radius: 6px; border: 1px solid #d4d4d8;
font-size: 14px; outline: none; background: #fff; transition: border-color 0.2s; color: #18181b;
}
.field input:focus { border-color: #1f5cff; box-shadow: 0 0 0 2px rgba(31,92,255,0.2); }
.password-wrap { position: relative; }
.password-wrap input { width: 100%; padding-right: 40px; box-sizing: border-box; }
.eye-btn {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: #a1a1aa; transition: color 0.2s;
display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
}
.eye-btn:hover { color: #3f3f46; }
.options { display: flex; align-items: center; justify-content: space-between; font-size: 14px; }
.remember { display: flex; align-items: center; gap: 8px; cursor: pointer; color: #18181b; }
.remember input { width: 16px; height: 16px; accent-color: #1f5cff; cursor: pointer; }
.forgot { color: #1f5cff; font-weight: 500; }
.forgot:hover { text-decoration: underline; }
.login-screen__cover-copy {
max-width: 520px;
}
.login-screen__cover h1 {
margin: 0;
font-size: 56px;
line-height: 0.96;
}
.login-screen__cover p:last-child {
max-width: 420px;
margin: 18px 0 0;
color: rgba(255, 255, 255, 0.82);
font-size: 18px;
line-height: 1.6;
}
.login-screen__form-container {
display: flex;
flex-direction: column;
justify-content: center;
padding: 64px 48px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(14px);
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.08);
}
.login-header {
margin-bottom: 36px;
}
.login-header h2 {
margin: 0 0 8px;
font-size: 32px;
}
.login-header p {
margin: 0;
color: var(--muted);
}
.login-form__icon {
color: rgba(0, 0, 0, 0.25);
.btn-primary {
width: 100%; height: 48px; font-size: 16px; font-weight: 500; border: none; border-radius: 6px;
color: white; transition: opacity 0.2s; cursor: pointer;
}
.btn-primary:hover { opacity: 0.9; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.login-credentials {
margin-top: 24px;
padding: 16px;
background: #f8fafc;
border: 1px solid #e6edf5;
border-radius: 16px;
border-radius: 12px;
}
.login-credentials__row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.login-credentials__row + .login-credentials__row {
margin-top: 8px;
}
.login-credentials__row span {
color: var(--muted);
color: var(--muted, #71717a);
font-size: 13px;
}
.login-credentials__row strong {
color: #18181b;
font-size: 13px;
}
@media (max-width: 980px) {
.login-screen {
grid-template-columns: 1fr;
}
.login-screen__cover {
min-height: 280px;
}
}
@media (max-width: 640px) {
.login-screen__form-container,
.login-screen__cover {
padding: 32px 20px;
}
.login-screen__cover h1 {
font-size: 40px;
}
@media (max-width: 1023px) {
.page { grid-template-columns: 1fr; }
.left { display: none; }
.mobile-brand { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 18px; font-weight: 600; margin-bottom: 48px; color: #18181b; }
}
</style>
@@ -19,6 +19,7 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import KnowledgeGroupSelect from "@/components/KnowledgeGroupSelect.vue";
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import { getTemplateMeta } from "@/lib/display";
@@ -170,6 +171,7 @@ const selectedTitle = ref("");
const customTitle = ref("");
const reviewIntroHook = ref("");
const keyPoints = ref("");
const selectedKnowledgeGroupIds = ref<number[]>([]);
const outlineSections = ref<string[]>([]);
const customOutlineSections = ref<ResolvedOutlineOption[]>([]);
const outlineOptionOrder = ref<string[]>([]);
@@ -424,6 +426,7 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
customTitle.value = "";
reviewIntroHook.value = "";
keyPoints.value = "";
selectedKnowledgeGroupIds.value = [];
customOutlineSections.value = [];
customOutlineInput.value = "";
generatedOutline.value = [];
@@ -481,6 +484,7 @@ function applyDraftArticleState(detail: ArticleDetail): void {
customTitle.value = stringFromUnknown(draftState.custom_title) || "";
reviewIntroHook.value = stringFromUnknown(draftState.review_intro_hook) || "";
keyPoints.value = stringFromUnknown(draftState.key_points) || "";
selectedKnowledgeGroupIds.value = numberListFromUnknown(draftState.knowledge_group_ids) ?? [];
customOutlineSections.value = parseCustomOutlineSections(draftState.custom_outline_sections);
outlineOptionOrder.value = stringListFromUnknown(draftState.outline_option_order) ?? outlineOptionOrder.value;
outlineSections.value = stringListFromUnknown(draftState.outline_sections) ?? outlineSections.value;
@@ -989,6 +993,8 @@ function buildPayload(): Record<string, JsonValue> {
primary_keyword: primaryKeyword.value || undefined,
keywords: keywordDrafts.value.length > 0 ? dedupeStrings(keywordDrafts.value) : undefined,
[reviewIntroHookFieldName.value]: reviewIntroHookPromptValue.value || undefined,
knowledge_group_ids:
selectedKnowledgeGroupIds.value.length > 0 ? selectedKnowledgeGroupIds.value : undefined,
competitors: competitorInputValue,
};
@@ -1031,6 +1037,7 @@ function buildDraftState(): Record<string, JsonValue> {
custom_title: customTitle.value,
title: finalTitle.value || selectedTitle.value || "",
review_intro_hook: reviewIntroHook.value,
knowledge_group_ids: selectedKnowledgeGroupIds.value,
outline_sections: outlineSections.value,
outline_option_order: outlineOptionOrder.value,
custom_outline_sections: customOutlineSections.value.map((item) => ({
@@ -1524,6 +1531,16 @@ function numberFromUnknown(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function numberListFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const items = value
.map((item) => (typeof item === "number" && Number.isFinite(item) ? item : NaN))
.filter((item) => Number.isFinite(item) && item > 0);
return items.length > 0 ? items : undefined;
}
function clampStep(value: number | undefined): number {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0;
@@ -2415,6 +2432,14 @@ function onStructureDragEnd(): void {
/>
</div>
<div class="field-item field-item--compact">
<label>引用知识库</label>
<KnowledgeGroupSelect
v-model="selectedKnowledgeGroupIds"
placeholder="可选,用于补充文章内容和事实背景"
/>
</div>
<a-alert
type="info"
show-icon