diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 29245dda..7c6a1566 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -317,6 +317,12 @@ export const sidebarMenuItems: MenuItem[] = [ icon: BookOpen, path: "/page/workspace/knowledge", }, + { + id: "resource-markdown-docx-editor", + label: "Markdown 转 Word", + icon: FileText, + path: "/page/workspace/knowledge/markdown-editor", + }, { id: "resource-knowledge-base", label: "知识库资源管理", diff --git a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx index 4fb0cf4e..a54b097e 100644 --- a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx +++ b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx @@ -37,7 +37,7 @@ function AdminWeKnoraSettingsButton() { <> diff --git a/frontend-web/src/pages/MarkdownDocxEditorPage.tsx b/frontend-web/src/pages/MarkdownDocxEditorPage.tsx new file mode 100644 index 00000000..6fbd4a6b --- /dev/null +++ b/frontend-web/src/pages/MarkdownDocxEditorPage.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { languages } from "@codemirror/language-data"; +import { basicLightInit } from "@uiw/codemirror-theme-basic"; +import { monokaiInit } from "@uiw/codemirror-theme-monokai"; +import CodeMirror from "@uiw/react-codemirror"; +import { Download, FileUp, Loader2, RefreshCw } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; +import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export"; + +const SAMPLE_MARKDOWN = `# Markdown 文档 + +在左侧编辑 Markdown,右侧会实时预览。 + +## 支持内容 + +- 标题、列表、引用 +- 表格和代码块 +- 下载为 Word 文档 + +| 字段 | 说明 | +| --- | --- | +| 上传 | 支持 .md / .markdown | +| 导出 | 生成 .docx | +`; + +const editorDarkTheme = monokaiInit({ + settings: { + background: "transparent", + gutterBackground: "transparent", + fontSize: "14px", + }, +}); + +const editorLightTheme = basicLightInit({ + settings: { + background: "transparent", + fontSize: "14px", + }, +}); + +function stripMarkdownExtension(name: string): string { + return name.replace(/\.(markdown|md)$/i, ""); +} + +function isMarkdownFilename(name: string): boolean { + return /\.(markdown|md)$/i.test(name); +} + +export default function MarkdownDocxEditorPage() { + const inputRef = useRef(null); + const { resolvedTheme } = useTheme(); + const [title, setTitle] = useState("Markdown 文档"); + const [markdownText, setMarkdownText] = useState(SAMPLE_MARKDOWN); + const [fileName, setFileName] = useState("markdown-document"); + const [exporting, setExporting] = useState(false); + + const extensions = useMemo( + () => [ + markdown({ + base: markdownLanguage, + codeLanguages: languages, + }), + ], + [], + ); + + const handleFile = async (file: File | undefined) => { + if (!file) return; + if (!isMarkdownFilename(file.name)) { + toast.error("请上传 .md 或 .markdown 文件"); + return; + } + try { + const text = await file.text(); + setMarkdownText(text); + const baseName = stripMarkdownExtension(file.name); + setFileName(baseName || "markdown-document"); + setTitle(baseName || "Markdown 文档"); + toast.success("Markdown 文件已载入"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "读取文件失败"); + } finally { + if (inputRef.current) inputRef.current.value = ""; + } + }; + + const handleExport = async () => { + if (!markdownText.trim()) { + toast.error("当前 Markdown 内容为空"); + return; + } + setExporting(true); + try { + await downloadMarkdownDocxFromBackend({ + title: title.trim() || fileName || "Markdown 文档", + markdown: markdownText, + }); + toast.success("Word 文档已生成"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导出 Word 失败"); + } finally { + setExporting(false); + } + }; + + return ( +
+
+
+
+

Markdown 转 Word

+

+ 上传 md 文件后在线编辑,确认内容后下载为 Word。 +

+
+
+ void handleFile(event.target.files?.[0])} + /> + + + +
+
+
+ + setTitle(event.target.value)} + placeholder="请输入导出的 Word 标题" + /> +
+
+ +
+
+
+ Markdown 编辑 +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index fdd5de51..ab49ff1e 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -1,6 +1,11 @@ "use client"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { languages } from "@codemirror/language-data"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { basicLightInit } from "@uiw/codemirror-theme-basic"; +import { monokaiInit } from "@uiw/codemirror-theme-monokai"; +import CodeMirror from "@uiw/react-codemirror"; import { BookOpen, Check, @@ -39,7 +44,6 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, @@ -55,6 +59,7 @@ import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; +import { useTheme } from "next-themes"; type PublicTask = { task_id: string; @@ -903,6 +908,58 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen return
{preview.content || "无正文内容"}
; } +const markdownEditorDarkTheme = monokaiInit({ + settings: { + background: "transparent", + gutterBackground: "transparent", + fontSize: "14px", + }, +}); + +const markdownEditorLightTheme = basicLightInit({ + settings: { + background: "transparent", + fontSize: "14px", + }, +}); + +function PublicMarkdownEditor({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const { resolvedTheme } = useTheme(); + const extensions = useMemo( + () => [ + markdown({ + base: markdownLanguage, + codeLanguages: languages, + }), + ], + [], + ); + + return ( +
+ +
+ ); +} + function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string { const params = new URLSearchParams(); params.set("tab", tab.kind); @@ -1320,12 +1377,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { 正在加载文件内容... ) : isEditing ? ( -