feat: add knowledge markdown formatting and detail retrieval
- Implemented KnowledgeWebsiteMarkdownPrompt for formatting webpage content into Markdown. - Added GetItemDetail method in KnowledgeHandler to retrieve knowledge item details. - Introduced KnowledgeDetailDrawer component for displaying knowledge item details in the admin web interface. - Created MarkdownPreview component for rendering Markdown content. - Added knowledge chunking and URL parsing logic to handle webpage content extraction and formatting. - Updated database schema to include markdown_content in knowledge_items table.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, watch } from "vue";
|
||||
|
||||
import MarkdownPreview from "@/components/MarkdownPreview.vue";
|
||||
import { knowledgeApi } from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/display";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
itemId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = computed(() => props.open && Boolean(props.itemId));
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: computed(() => ["knowledge", "detail", props.itemId]),
|
||||
enabled,
|
||||
queryFn: () => knowledgeApi.detail(props.itemId as number),
|
||||
});
|
||||
|
||||
const detail = computed(() => detailQuery.data.value);
|
||||
const hasMarkdown = computed(() => Boolean(detail.value?.markdown_content?.trim()));
|
||||
const isProcessing = computed(() =>
|
||||
detail.value?.status === "pending" || detail.value?.status === "processing",
|
||||
);
|
||||
const isFailed = computed(() => detail.value?.status === "failed");
|
||||
const statusLabel = computed(() => formatKnowledgeStatus(detail.value?.status));
|
||||
const statusColor = computed(() => {
|
||||
if (detail.value?.status === "completed") return "success";
|
||||
if (detail.value?.status === "failed") return "error";
|
||||
if (isProcessing.value) return "processing";
|
||||
return "default";
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.open, () => props.itemId, () => detail.value?.status],
|
||||
([open, itemId, status], _prev, onCleanup) => {
|
||||
if (!open || !itemId || (status !== "pending" && status !== "processing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void Promise.all([
|
||||
detailQuery.refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: ["knowledge", "items"] }),
|
||||
]);
|
||||
}, 2500);
|
||||
|
||||
onCleanup(() => {
|
||||
window.clearInterval(timer);
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function handleClose(): void {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function formatKnowledgeStatus(status?: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "学习完成";
|
||||
case "processing":
|
||||
return "处理中";
|
||||
case "failed":
|
||||
return "解析失败";
|
||||
case "pending":
|
||||
return "待处理";
|
||||
case "deleted":
|
||||
return "已删除";
|
||||
default:
|
||||
return status || "未知状态";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="open"
|
||||
width="760"
|
||||
title="知识详情"
|
||||
class="knowledge-detail-drawer"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="detailQuery.isPending.value" class="knowledge-detail-drawer__loading">
|
||||
<a-skeleton active :paragraph="{ rows: 10 }" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="knowledge-detail-drawer__hero">
|
||||
<div>
|
||||
<h2>{{ detail.name }}</h2>
|
||||
<div class="knowledge-detail-drawer__meta">
|
||||
<span>{{ detail.group_name }}</span>
|
||||
<span>{{ formatDateTime(detail.created_at) }}</span>
|
||||
<span v-if="detail.source_uri">{{ detail.source_uri }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-tag :color="statusColor">{{ statusLabel }}</a-tag>
|
||||
</section>
|
||||
|
||||
<a-alert
|
||||
v-if="isProcessing"
|
||||
type="info"
|
||||
show-icon
|
||||
message="处理中"
|
||||
description="内容正在解析并整理为 Markdown,抽屉会自动刷新。"
|
||||
class="knowledge-detail-drawer__alert"
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-else-if="isFailed"
|
||||
type="error"
|
||||
show-icon
|
||||
message="解析失败"
|
||||
:description="detail.error_message || '当前内容解析失败,请稍后重试。'"
|
||||
class="knowledge-detail-drawer__alert"
|
||||
/>
|
||||
|
||||
<div v-if="hasMarkdown" class="knowledge-detail-drawer__content">
|
||||
<MarkdownPreview :content="detail.markdown_content || ''" />
|
||||
</div>
|
||||
<a-empty v-else description="无可预览文本" />
|
||||
</template>
|
||||
|
||||
<a-result
|
||||
v-else
|
||||
status="404"
|
||||
title="未找到知识内容"
|
||||
sub-title="该内容可能已删除,或当前租户无权访问。"
|
||||
/>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-detail-drawer__loading {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.knowledge-detail-drawer__hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.knowledge-detail-drawer__hero h2 {
|
||||
margin: 0 0 8px;
|
||||
color: #111827;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-detail-drawer__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-detail-drawer__alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.knowledge-detail-drawer__content {
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
}>();
|
||||
|
||||
const renderer = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
const html = computed(() => renderer.render(props.content || ""));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-preview" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-preview {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h1),
|
||||
.markdown-preview :deep(h2),
|
||||
.markdown-preview :deep(h3),
|
||||
.markdown-preview :deep(h4) {
|
||||
margin: 1.2em 0 0.6em;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h1) {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h2) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h3) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p),
|
||||
.markdown-preview :deep(ul),
|
||||
.markdown-preview :deep(ol),
|
||||
.markdown-preview :deep(blockquote) {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(ul),
|
||||
.markdown-preview :deep(ol) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(a) {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(code) {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: #f3f4f6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(pre) {
|
||||
overflow-x: auto;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(table) {
|
||||
width: 100%;
|
||||
margin: 0 0 1em;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(th),
|
||||
.markdown-preview :deep(td) {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(th) {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(hr) {
|
||||
margin: 24px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
KnowledgeGroup,
|
||||
KnowledgeGroupRequest,
|
||||
KnowledgeItem,
|
||||
KnowledgeItemDetail,
|
||||
KnowledgeTextItemRequest,
|
||||
KnowledgeURLItemRequest,
|
||||
Keyword,
|
||||
@@ -248,6 +249,9 @@ export const knowledgeApi = {
|
||||
params: groupId ? { group_id: groupId } : undefined,
|
||||
});
|
||||
},
|
||||
detail(id: number) {
|
||||
return apiClient.get<KnowledgeItemDetail>(`/api/tenant/knowledge/items/${id}`);
|
||||
},
|
||||
createTextItem(payload: KnowledgeTextItemRequest) {
|
||||
return apiClient.post<KnowledgeItem, KnowledgeTextItemRequest>(
|
||||
"/api/tenant/knowledge/items/text",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Radio,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
@@ -92,6 +93,7 @@ app.component("ATextarea", Input.TextArea);
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Radio,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
||||
import { DeleteOutlined, EyeOutlined, 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 KnowledgeDetailDrawer from "@/components/KnowledgeDetailDrawer.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 = "默认目录";
|
||||
const MAX_WEBSITE_URLS = 3;
|
||||
|
||||
interface TreeNode {
|
||||
title: string;
|
||||
@@ -29,6 +30,8 @@ const editingGroup = ref<KnowledgeGroup | null>(null);
|
||||
const itemModalOpen = ref(false);
|
||||
const itemSourceType = ref<KnowledgeSourceType>("document");
|
||||
const uploadFiles = ref<File[]>([]);
|
||||
const previewItemId = ref<number | null>(null);
|
||||
const previewDrawerOpen = ref(false);
|
||||
|
||||
const groupForm = reactive({
|
||||
name: "",
|
||||
@@ -67,6 +70,17 @@ 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 websiteUrls = computed(() =>
|
||||
urlsText.value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const websiteUrlLimitExceeded = computed(() => websiteUrls.value.length > MAX_WEBSITE_URLS);
|
||||
const itemModalOkButtonProps = computed(() => ({
|
||||
style: { width: "80px", borderRadius: "4px" },
|
||||
disabled: itemSourceType.value === "website" && websiteUrlLimitExceeded.value,
|
||||
}));
|
||||
const groupModalTitle = computed(() => {
|
||||
if (editingGroup.value) {
|
||||
return isRootGroup(editingGroup.value) ? "编辑分组" : "编辑子目录";
|
||||
@@ -119,13 +133,11 @@ const groupMutations = {
|
||||
|
||||
const itemMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let targetGroupId = itemForm.group_id;
|
||||
const targetGroupId = itemForm.group_id;
|
||||
if (!targetGroupId) {
|
||||
throw new Error("请选择分组目录");
|
||||
}
|
||||
|
||||
targetGroupId = await resolveItemTargetGroupId(targetGroupId);
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
if (itemSourceType.value === "document") {
|
||||
@@ -142,10 +154,13 @@ const itemMutation = useMutation({
|
||||
);
|
||||
}
|
||||
} else if (itemSourceType.value === "website") {
|
||||
const urls = urlsText.value.split('\n').map(u => u.trim()).filter(Boolean);
|
||||
const urls = websiteUrls.value;
|
||||
if (urls.length === 0) {
|
||||
throw new Error("请输入包含URL的文本");
|
||||
}
|
||||
if (urls.length > MAX_WEBSITE_URLS) {
|
||||
throw new Error(`一次最多支持 ${MAX_WEBSITE_URLS} 个网址`);
|
||||
}
|
||||
for (const url of urls) {
|
||||
let name = url;
|
||||
try { name = new URL(url).hostname + new URL(url).pathname } catch (e) {}
|
||||
@@ -173,12 +188,31 @@ const itemMutation = useMutation({
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successCount = results.filter((result) => result.status === "fulfilled").length;
|
||||
const failedResults = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
|
||||
if (failedResults.length > 0 && successCount === 0) {
|
||||
throw failedResults[0].reason;
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount: promises.length,
|
||||
successCount,
|
||||
failedCount: failedResults.length,
|
||||
firstError: failedResults[0]?.reason,
|
||||
};
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success("内容已提交");
|
||||
itemModalOpen.value = false;
|
||||
resetItemForm();
|
||||
onSuccess: async (result) => {
|
||||
if (result.failedCount === 0) {
|
||||
message.success(result.successCount > 1 ? `已提交 ${result.successCount} 条内容` : "内容已提交");
|
||||
itemModalOpen.value = false;
|
||||
resetItemForm();
|
||||
} else {
|
||||
message.warning(
|
||||
`已成功提交 ${result.successCount} 条,${result.failedCount} 条失败${result.firstError ? `:${formatError(result.firstError)}` : ""}`,
|
||||
);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ["knowledge"] });
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
@@ -310,29 +344,6 @@ function isStorableGroupId(id: number | null | undefined): boolean {
|
||||
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;
|
||||
@@ -481,8 +492,14 @@ function formatSize(value: number): string {
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function editItem(): void {
|
||||
message.info("内容编辑功能开发中");
|
||||
function openItemDetail(itemId: number): void {
|
||||
previewItemId.value = itemId;
|
||||
previewDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function closeItemDetail(): void {
|
||||
previewDrawerOpen.value = false;
|
||||
previewItemId.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -570,11 +587,16 @@ function editItem(): void {
|
||||
{{ 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" />
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip title="查看">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn action-eye" @click="openItemDetail(record.id)">
|
||||
<EyeOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm title="确认删除该内容?" @confirm="deleteItemMutation.mutate(record.id)">
|
||||
<a-button type="link" style="padding: 0 4px">删除</a-button>
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn action-delete">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
@@ -615,7 +637,7 @@ function editItem(): void {
|
||||
"
|
||||
ok-text="确 定"
|
||||
cancel-text="取 消"
|
||||
:ok-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
|
||||
:ok-button-props="itemModalOkButtonProps"
|
||||
:cancel-button-props="{ style: { width: '80px', borderRadius: '4px' } }"
|
||||
>
|
||||
<div class="knowledge-form-add">
|
||||
@@ -644,7 +666,7 @@ function editItem(): void {
|
||||
</div>
|
||||
<p class="dragger-text">点击或拖拽文件至此区域即可上传</p>
|
||||
<p class="dragger-hint">
|
||||
请上传doc、pdf、txt、xls、xlsx格式,不超过30M的文件。严禁上传敏感数据或其他非法文件。
|
||||
请上传 docx、pdf、txt、md、xls、xlsx 格式,不超过30M的文件。严禁上传敏感数据或其他非法文件。
|
||||
</p>
|
||||
<p class="dragger-count">已选择文件:{{ uploadFiles.length }}个</p>
|
||||
</div>
|
||||
@@ -655,9 +677,17 @@ function editItem(): void {
|
||||
<a-textarea
|
||||
v-model:value="urlsText"
|
||||
:rows="11"
|
||||
placeholder="请输入产品相关网页URL,每行一个"
|
||||
:status="websiteUrlLimitExceeded ? 'error' : undefined"
|
||||
placeholder="请输入产品相关网页URL,每行一个,最多支持3个"
|
||||
class="custom-textarea"
|
||||
/>
|
||||
<div
|
||||
class="knowledge-url-hint"
|
||||
:class="{ 'knowledge-url-hint--error': websiteUrlLimitExceeded }"
|
||||
>
|
||||
当前 {{ websiteUrls.length }} / {{ MAX_WEBSITE_URLS }} 个网址
|
||||
<span v-if="websiteUrlLimitExceeded">,请删除多余网址后再提交</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="knowledge-content-wrapper">
|
||||
@@ -690,6 +720,12 @@ function editItem(): void {
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<KnowledgeDetailDrawer
|
||||
:open="previewDrawerOpen"
|
||||
:item-id="previewItemId"
|
||||
@close="closeItemDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -776,7 +812,7 @@ function editItem(): void {
|
||||
}
|
||||
|
||||
.knowledge-sidebar__add {
|
||||
color: #9ca3af;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -823,12 +859,21 @@ function editItem(): void {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.knowledge-actions {
|
||||
/* Actions */
|
||||
.table-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
.action-eye:hover { color: #13c2c2; background: #e6fffb; }
|
||||
.action-delete:hover { color: #ff4d4f; background: #fff2f0; }
|
||||
|
||||
.knowledge-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -871,6 +916,17 @@ function editItem(): void {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.knowledge-url-hint {
|
||||
margin-top: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.knowledge-url-hint--error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
:deep(.custom-dragger.ant-upload-wrapper .ant-upload-drag) {
|
||||
border: 1px dashed #7eb0f7;
|
||||
background-color: #fff;
|
||||
|
||||
Reference in New Issue
Block a user