Unify Wiki reading, preserve metadata and graph links, add asynchronous file-backed transfers, and reuse compatible vectors. Includes regression coverage and live acceptance notes.
624 lines
24 KiB
TypeScript
624 lines
24 KiB
TypeScript
import { AlertCircle, Download, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor";
|
|
import { WikiPackageTransfers } from "./WikiPackageTransfers";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
|
|
import {
|
|
createLlmWikiWeKnoraEmbedSession,
|
|
getLlmWikiSourceContext,
|
|
useLlmWikiActions,
|
|
useLlmWikiKnowledgeBase,
|
|
useLlmWikiWikiPage,
|
|
useLlmWikiRuntime,
|
|
} from "@/strategy-components/api/llmwiki";
|
|
import {
|
|
createLlmWikiExportPackage,
|
|
listLlmWikiExportPackages,
|
|
type LlmWikiExportPackageJob,
|
|
} from "@/strategy-components/api/assistant-knowledge";
|
|
|
|
const LIST_PATH = "/page/workspace/knowledge";
|
|
const RUNNING_EXPORT_STATUSES = new Set(["queued", "running"]);
|
|
const EXPORT_STATUS_LABELS: Record<string, string> = {
|
|
queued: "排队中",
|
|
running: "整理中",
|
|
completed: "已完成",
|
|
failed: "失败",
|
|
};
|
|
|
|
type DeerFlowThemePayload = {
|
|
type: "deerflow-weknora-theme";
|
|
theme: "dark" | "light";
|
|
vars: Record<string, string>;
|
|
};
|
|
|
|
function cssVar(name: string, fallback = ""): string {
|
|
if (typeof window === "undefined") return fallback;
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
|
}
|
|
|
|
function collectThemePayload(): DeerFlowThemePayload {
|
|
const isDark =
|
|
document.documentElement.classList.contains("dark") ||
|
|
document.documentElement.getAttribute("theme-mode") === "dark";
|
|
return {
|
|
type: "deerflow-weknora-theme",
|
|
theme: isDark ? "dark" : "light",
|
|
vars: {
|
|
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
|
|
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
|
|
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
|
|
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
|
|
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
|
|
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
|
|
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
|
|
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
|
|
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
|
|
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
|
|
destructive: cssVar("--destructive", "#d54941"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function postThemeToFrame(frame: HTMLIFrameElement | null) {
|
|
if (!frame?.contentWindow) return;
|
|
const src = frame.getAttribute("src") || "";
|
|
if (!src) return;
|
|
const targetOrigin = new URL(src, window.location.href).origin;
|
|
try {
|
|
const currentUrl = frame.contentWindow.location.href;
|
|
if (!currentUrl || currentUrl === "about:blank") return;
|
|
if (new URL(currentUrl, window.location.href).origin !== targetOrigin) return;
|
|
} catch {
|
|
// Reading location throws once the iframe has reached the cross-origin backend.
|
|
}
|
|
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
|
|
}
|
|
|
|
const WEKNORA_BACKEND_PREFIX = "/deerflow";
|
|
|
|
function buildFrameUrl(framePath: string) {
|
|
const backendBase = getBackendBaseURL().replace(/\/+$/, "");
|
|
const backendOrigin = backendBase
|
|
? new URL(backendBase, window.location.origin).origin
|
|
: window.location.origin;
|
|
if (/^https?:\/\//i.test(framePath)) return new URL(framePath);
|
|
if (framePath.startsWith("/")) {
|
|
const path = !framePath.startsWith(`${WEKNORA_BACKEND_PREFIX}/`)
|
|
? `${WEKNORA_BACKEND_PREFIX}${framePath}`
|
|
: framePath;
|
|
return new URL(path, backendOrigin);
|
|
}
|
|
return new URL(`${backendBase || backendOrigin}/${framePath.replace(/^\/+/, "")}`);
|
|
}
|
|
|
|
function formatBytes(value: number | undefined | null) {
|
|
const bytes = Number(value || 0);
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "—";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let current = bytes;
|
|
let unitIndex = 0;
|
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
|
current /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatTime(value: string | undefined | null) {
|
|
if (!value) return "—";
|
|
const time = new Date(value);
|
|
return Number.isNaN(time.getTime()) ? "—" : time.toLocaleString();
|
|
}
|
|
|
|
export function WeKnoraKnowledgeDetailPage() {
|
|
const {
|
|
knowledge_base_id: strategyKnowledgeBaseId,
|
|
noteId: workspaceKnowledgeBaseId,
|
|
} = useParams();
|
|
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
|
const [frameSrc, setFrameSrc] = useState("");
|
|
const [frameVersion, setFrameVersion] = useState(0);
|
|
const [frameError, setFrameError] = useState<string | null>(null);
|
|
const [exportPackageLoading, setExportPackageLoading] = useState(false);
|
|
const [exportJobsLoading, setExportJobsLoading] = useState(false);
|
|
const [exportJobs, setExportJobs] = useState<LlmWikiExportPackageJob[]>([]);
|
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
|
const runtime = useLlmWikiRuntime();
|
|
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
|
|
|
const refreshExportJobs = useCallback(
|
|
async (silent = false) => {
|
|
if (!knowledgeBaseId || !isAdmin) return;
|
|
if (!silent) setExportJobsLoading(true);
|
|
try {
|
|
setExportJobs(await listLlmWikiExportPackages(knowledgeBaseId));
|
|
} catch (error) {
|
|
if (!silent) toast.error(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
if (!silent) setExportJobsLoading(false);
|
|
}
|
|
},
|
|
[isAdmin, knowledgeBaseId],
|
|
);
|
|
|
|
const detailsReady = !!details.data;
|
|
const requestedTab = searchParams.get("tab");
|
|
const detailsWikiEnabled =
|
|
details.data?.type === "wiki" ||
|
|
details.data?.wiki_index_enabled === true ||
|
|
details.data?.indexing_strategy?.wiki_enabled === true ||
|
|
details.data?.capabilities?.wiki_enabled === true ||
|
|
(details.data?.wiki_config && Object.keys(details.data.wiki_config).length > 0);
|
|
const preferredTab =
|
|
requestedTab === "documents" || requestedTab === "wiki" || requestedTab === "graph"
|
|
? requestedTab
|
|
: detailsWikiEnabled
|
|
? "wiki"
|
|
: "documents";
|
|
const sourceChunkId = searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
|
const documentId = searchParams.get("documentId") || "";
|
|
const wikiSlug = searchParams.get("wikiSlug") || "";
|
|
const nativeWikiPageUrl = knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
|
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
|
: "";
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setFrameSrc("");
|
|
setFrameError(null);
|
|
if (nativeWikiPageUrl || !knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}
|
|
|
|
void (async () => {
|
|
const resolvedWikiSlug =
|
|
wikiSlug ||
|
|
(sourceChunkId
|
|
? (await getLlmWikiSourceContext(knowledgeBaseId, sourceChunkId).catch(
|
|
() => null,
|
|
))?.wiki_page?.slug
|
|
: "");
|
|
const session = await createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab);
|
|
if (cancelled) return;
|
|
const framePath = session.frame_path || "";
|
|
const nextUrl = buildFrameUrl(framePath);
|
|
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
|
if (sourceChunkId) nextUrl.searchParams.set("sourceChunkId", sourceChunkId);
|
|
if (documentId) nextUrl.searchParams.set("documentId", documentId);
|
|
if (resolvedWikiSlug) nextUrl.searchParams.set("wikiSlug", resolvedWikiSlug);
|
|
setFrameSrc(nextUrl.toString());
|
|
})()
|
|
.catch((error: unknown) => {
|
|
if (cancelled) return;
|
|
const message = error instanceof Error ? error.message : "创建知识库详情会话失败";
|
|
setFrameError(message);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [detailsReady, documentId, knowledgeBaseId, nativeWikiPageUrl, preferredTab, runtime.data?.provider, sourceChunkId, wikiSlug]);
|
|
|
|
useEffect(() => {
|
|
if (!frameSrc) return;
|
|
const frame = frameRef.current;
|
|
const send = () => postThemeToFrame(frame);
|
|
const timer = window.setTimeout(send, 300);
|
|
const observer = new MutationObserver(send);
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["class", "style", "data-theme", "theme-mode"],
|
|
});
|
|
window.addEventListener("storage", send);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
observer.disconnect();
|
|
window.removeEventListener("storage", send);
|
|
};
|
|
}, [frameSrc]);
|
|
|
|
useEffect(() => {
|
|
if (!isAdmin || !knowledgeBaseId || runtime.data?.provider !== "weknora") {
|
|
setExportJobs([]);
|
|
return;
|
|
}
|
|
void refreshExportJobs();
|
|
}, [isAdmin, knowledgeBaseId, refreshExportJobs, runtime.data?.provider]);
|
|
|
|
useEffect(() => {
|
|
if (!isAdmin || !knowledgeBaseId || !exportJobs.some((job) => RUNNING_EXPORT_STATUSES.has(job.status))) {
|
|
return;
|
|
}
|
|
const timer = window.setInterval(() => void refreshExportJobs(true), 5000);
|
|
return () => window.clearInterval(timer);
|
|
}, [exportJobs, isAdmin, knowledgeBaseId, refreshExportJobs]);
|
|
|
|
if (nativeWikiPageUrl) {
|
|
return <Navigate to={nativeWikiPageUrl} replace />;
|
|
}
|
|
|
|
if (runtime.isLoading || details.isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
|
加载中
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
|
|
|
|
if (frameError) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
|
<AlertCircle className="size-6" />
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
|
|
</div>
|
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
|
返回知识库
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (details.error || !details.data) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
|
<AlertCircle className="size-6" />
|
|
</div>
|
|
<div>
|
|
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
当前账号没有访问权限,或知识服务资源已不存在。
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
|
返回知识库
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const canWrite = details.data.can_write && !details.data.is_conversation_deposit;
|
|
const canEditCurrentWikiArticle =
|
|
canWrite && !!detailsWikiEnabled && !!wikiSlug;
|
|
const canManageWiki = canWrite && !!detailsWikiEnabled;
|
|
|
|
const createVectorizedPackage = () => {
|
|
if (exportPackageLoading) return;
|
|
setExportPackageLoading(true);
|
|
void (async () => {
|
|
try {
|
|
const job = await createLlmWikiExportPackage(knowledgeBaseId);
|
|
setExportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
|
|
toast.success("已创建向量化数据包整理记录,完成后可在下方列表下载");
|
|
void refreshExportJobs(true);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
setExportPackageLoading(false);
|
|
}
|
|
})();
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
|
{canManageWiki && isAdmin && <details className="shrink-0 border-b px-4 py-2"><summary className="cursor-pointer text-sm">从助手知识库导入数据</summary><div className="py-3"><WikiPackageTransfers id={knowledgeBaseId} /></div></details>}
|
|
{canManageWiki && (
|
|
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
|
<div className="min-w-0 flex-1">
|
|
{canEditCurrentWikiArticle && (
|
|
<CurrentWikiArticleActions
|
|
knowledgeBaseId={knowledgeBaseId}
|
|
slug={wikiSlug}
|
|
onChanged={() => setFrameVersion((value) => value + 1)}
|
|
onDeleted={() => {
|
|
navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}?tab=wiki`, {
|
|
replace: true,
|
|
});
|
|
setFrameVersion((value) => value + 1);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{isAdmin && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={exportPackageLoading}
|
|
onClick={createVectorizedPackage}
|
|
>
|
|
{exportPackageLoading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
|
|
生成向量化数据包
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
|
>
|
|
<FileText className="mr-2 size-4" />
|
|
Wiki管理
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{canManageWiki && isAdmin && (
|
|
<ExportPackageJobsPanel
|
|
jobs={exportJobs}
|
|
loading={exportJobsLoading}
|
|
onRefresh={() => void refreshExportJobs()}
|
|
/>
|
|
)}
|
|
{frameSrc && (
|
|
<iframe
|
|
key={`${frameSrc}:${frameVersion}`}
|
|
ref={frameRef}
|
|
title={`${details.data.name || "知识库"} 详情`}
|
|
src={frameSrc}
|
|
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
|
referrerPolicy="no-referrer"
|
|
onLoad={() => postThemeToFrame(frameRef.current)}
|
|
/>
|
|
)}
|
|
{!frameSrc && (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center bg-background text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
|
加载中
|
|
</div>
|
|
)}
|
|
{details.isFetching && (
|
|
<div className="pointer-events-none fixed bottom-4 right-4 rounded-full border bg-card px-3 py-2 text-xs text-muted-foreground shadow-sm">
|
|
<RefreshCw className="mr-2 inline size-3 animate-spin" />
|
|
同步知识库状态
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExportPackageJobsPanel({
|
|
jobs,
|
|
loading,
|
|
onRefresh,
|
|
}: {
|
|
jobs: LlmWikiExportPackageJob[];
|
|
loading: boolean;
|
|
onRefresh: () => void;
|
|
}) {
|
|
return (
|
|
<div className="shrink-0 border-b bg-muted/20 px-4 py-3">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">向量化数据包记录</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
每次点击都会生成一条后台整理记录;文件较大时,请等待状态变为“已完成”后再下载。
|
|
</p>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={loading}>
|
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <RefreshCw className="mr-2 size-4" />}
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
|
|
{jobs.map((job) => {
|
|
const running = RUNNING_EXPORT_STATUSES.has(job.status);
|
|
const completed = job.status === "completed";
|
|
const canDownload = completed && !!job.package_download_url && job.file_exists !== false;
|
|
const counts = job.counts ?? {};
|
|
return (
|
|
<div key={job.id} className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 text-xs">
|
|
<Badge
|
|
variant={job.status === "failed" ? "destructive" : completed ? "default" : "secondary"}
|
|
className="shrink-0"
|
|
>
|
|
{running && <Loader2 className="mr-1 inline size-3 animate-spin" />}
|
|
{EXPORT_STATUS_LABELS[job.status] || job.status}
|
|
</Badge>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span className="font-medium">任务 {job.id.slice(0, 8)}</span>
|
|
<span className="text-muted-foreground">创建:{formatTime(job.created_at)}</span>
|
|
<span className="text-muted-foreground">大小:{formatBytes(job.file_size_bytes)}</span>
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
|
|
<span>Wiki {counts.wiki_page ?? counts.wiki_pages ?? 0}</span>
|
|
<span>文档 {counts.document ?? counts.documents ?? 0}</span>
|
|
<span>片段 {counts.chunk ?? counts.chunks ?? 0}</span>
|
|
<span>向量 {counts.vector ?? counts.vectors ?? 0}</span>
|
|
<span>阶段:{job.phase}</span>
|
|
{job.error && <span className="text-destructive">{job.error}</span>}
|
|
</div>
|
|
</div>
|
|
{canDownload ? (
|
|
<Button asChild size="sm" variant="outline" className="shrink-0">
|
|
<a href={job.package_download_url} target="_blank" rel="noreferrer">
|
|
<Download className="mr-2 size-4" />
|
|
下载
|
|
</a>
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" variant="outline" className="shrink-0" disabled>
|
|
{completed && job.file_exists === false ? "文件不存在" : "等待完成"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{!jobs.length && (
|
|
<div className="rounded-md border border-dashed bg-card px-3 py-6 text-center text-xs text-muted-foreground">
|
|
暂无导出记录。点击上方“生成向量化数据包”后,任务会显示在这里。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CurrentWikiArticleActions({
|
|
knowledgeBaseId,
|
|
slug,
|
|
onChanged,
|
|
onDeleted,
|
|
}: {
|
|
knowledgeBaseId: string;
|
|
slug: string;
|
|
onChanged: () => void;
|
|
onDeleted: () => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const detail = useLlmWikiWikiPage(knowledgeBaseId, slug || null, !!slug);
|
|
const actions = useLlmWikiActions();
|
|
const [draft, setDraft] = useState({ title: "", summary: "", content: "" });
|
|
|
|
useEffect(() => {
|
|
const page = detail.data;
|
|
if (!page) return;
|
|
setDraft({
|
|
title: page.title || "",
|
|
summary: page.summary || "",
|
|
content: page.content || "",
|
|
});
|
|
}, [detail.data]);
|
|
|
|
const save = async () => {
|
|
if (!slug) return;
|
|
try {
|
|
await actions.updateWikiPage.mutateAsync({
|
|
id: knowledgeBaseId,
|
|
slug,
|
|
input: {
|
|
title: draft.title.trim(),
|
|
summary: draft.summary,
|
|
content: draft.content,
|
|
},
|
|
});
|
|
toast.success("文章已保存");
|
|
await detail.refetch();
|
|
setOpen(false);
|
|
onChanged();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "保存文章失败");
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!slug) return;
|
|
if (!window.confirm(`确定删除文章「${detail.data?.title || slug}」吗?删除后不可恢复。`)) return;
|
|
try {
|
|
await actions.removeWikiPage.mutateAsync({ id: knowledgeBaseId, slug });
|
|
toast.success("文章已删除");
|
|
onDeleted();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "删除文章失败");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-foreground">
|
|
{detail.data?.title || slug}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">{slug}</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="shrink-0"
|
|
onClick={() => setOpen(true)}
|
|
disabled={detail.isLoading || !!detail.error}
|
|
>
|
|
<Edit3 className="mr-2 size-4" />
|
|
编辑此文章
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="shrink-0"
|
|
onClick={remove}
|
|
disabled={actions.removeWikiPage.isPending}
|
|
>
|
|
{actions.removeWikiPage.isPending ? (
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-2 size-4" />
|
|
)}
|
|
删除
|
|
</Button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="flex max-h-[94vh] w-[calc(100vw-2rem)] max-w-7xl flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>编辑此文章</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 space-y-3 overflow-auto">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">标题</label>
|
|
<Input
|
|
value={draft.title}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">摘要</label>
|
|
<Textarea
|
|
value={draft.summary}
|
|
onChange={(event) => setDraft((prev) => ({ ...prev, summary: event.target.value }))}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">正文</label>
|
|
<MarkdownCodeEditor
|
|
value={draft.content}
|
|
onChange={(content) => setDraft((prev) => ({ ...prev, content }))}
|
|
className="[&_.cm-editor]:min-h-[48vh]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex shrink-0 justify-end gap-2 border-t pt-3">
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={actions.updateWikiPage.isPending}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
onClick={save}
|
|
disabled={actions.updateWikiPage.isPending || !draft.title.trim() || !draft.content.trim()}
|
|
>
|
|
{actions.updateWikiPage.isPending ? (
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="mr-2 size-4" />
|
|
)}
|
|
保存
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default WeKnoraKnowledgeDetailPage;
|