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:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
请上传doc、pdf、txt、xls、xlsx格式,不超过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: "",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user