[feat]:[FL-218][操作文档展示页面样式优化]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 17:55:05 +08:00
parent 9580d57b6b
commit 9ad385ec38
4 changed files with 834 additions and 92 deletions
+136
View File
@@ -1181,12 +1181,44 @@
}
}
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
"license": "ISC"
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"license": "MIT"
},
"node_modules/hast-util-heading-rank": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -1214,6 +1246,35 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unist-util-find-after": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -1227,6 +1288,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -1441,6 +1511,21 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -2908,6 +2993,40 @@
"redux": "^5.0.0"
}
},
"node_modules/rehype-highlight": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
"integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-to-text": "^4.0.0",
"lowlight": "^3.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"github-slugger": "^2.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -3136,6 +3255,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
@@ -3309,6 +3442,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"next": "16.2.3",
"react": "19.2.4",
@@ -3316,6 +3450,8 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.3.0",
"recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
"reselect": "^5.2.0",
"tailwind-merge": "^3.5.0"
},
+3
View File
@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
"next": "16.2.3",
"react": "19.2.4",
@@ -25,6 +26,8 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.3.0",
"recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"rehype-slug": "^6.0.0",
"reselect": "^5.2.0",
"tailwind-merge": "^3.5.0"
},
+277 -92
View File
@@ -5,10 +5,13 @@ import {
Card,
Drawer,
Empty,
Input,
Menu,
Spin,
Skeleton,
Typography,
Button,
Image,
Tooltip,
type CardProps,
} from "antd";
import {
@@ -17,10 +20,23 @@ import {
MenuOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SearchOutlined,
CopyOutlined,
LinkOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useState, type ComponentType, type RefAttributes } from "react";
import {
useCallback,
useEffect,
useState,
useRef,
useMemo,
type ComponentType,
type RefAttributes,
} from "react";
import type { MenuProps } from "antd";
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import rehypeSlug from "rehype-slug";
import { useAuth } from "@/components/auth-provider";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
@@ -30,17 +46,35 @@ import type {
DocumentChapterTreeItem,
} from "@/types/document";
const { Title, Paragraph } = Typography;
const { Title, Paragraph, Text } = Typography;
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
type MenuItem = Required<MenuProps>["items"][number];
function flattenDocuments(chapters: DocumentChapterTreeItem[]): { id: number; title: string; chapterPath: string }[] {
const result: { id: number; title: string; chapterPath: string }[] = [];
const walk = (items: DocumentChapterTreeItem[], path: string) => {
for (const chapter of items) {
const currentPath = path ? `${path} / ${chapter.name}` : chapter.name;
for (const doc of chapter.documents?.filter((d) => d.status === "published") ?? []) {
result.push({ id: doc.id, title: doc.title, chapterPath: currentPath });
}
if (chapter.children) walk(chapter.children, currentPath);
}
};
walk(chapters, "");
return result;
}
export default function DocsViewPage() {
const { user, fetchWithAuth, hasPermission } = useAuth();
const isMobile = useMobileDetection();
const [selectedDocumentId, setSelectedDocumentId] = useState<number | null>(null);
const [siderCollapsed, setSiderCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [copiedCodeBlockId, setCopiedCodeBlockId] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const canRead = hasPermission("document.read") || true;
@@ -65,46 +99,89 @@ export default function DocsViewPage() {
enabled: !!selectedDocumentId && !!user && canRead,
});
const convertToMenuItems = useCallback((chapters: DocumentChapterTreeItem[]): MenuItem[] => {
const convert = (chapters: DocumentChapterTreeItem[]): MenuItem[] => {
const documentIndex = useMemo(() => (treeData ? flattenDocuments(treeData) : []), [treeData]);
const filteredMenuItems = useMemo(() => {
if (!treeData) return [];
const filterTree = (chapters: DocumentChapterTreeItem[]): MenuItem[] => {
return chapters
.filter((chapter) => {
const hasPublishedDocs = chapter.documents?.some((doc) => doc.status === "published");
const hasPublishedChildren = chapter.children?.some((child) =>
child.documents?.some((doc) => doc.status === "published")
child.documents?.some((doc) => doc.status === "published"),
);
return hasPublishedDocs || hasPublishedChildren;
})
.map((chapter) => {
const hasChildren = chapter.children && chapter.children.length > 0;
const publishedDocs = chapter.documents?.filter((doc) => doc.status === "published") || [];
const docItems: MenuItem[] = publishedDocs.map((doc) => ({
const matchedDocs = searchQuery
? publishedDocs.filter(
(doc) =>
doc.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
chapter.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: publishedDocs;
const childItems = chapter.children ? filterTree(chapter.children) : [];
if (searchQuery && matchedDocs.length === 0 && childItems.length === 0) {
return null;
}
const docItems: MenuItem[] = matchedDocs.map((doc) => ({
key: `doc-${doc.id}`,
icon: <FileTextOutlined />,
label: doc.title,
label: (
<span className="admin-docs-view-menu-doc-title" data-searching={!!searchQuery}>
{searchQuery ? highlightMatch(doc.title, searchQuery) : doc.title}
</span>
),
}));
const childItems = hasChildren ? convert(chapter.children) : [];
return {
key: `chapter-${chapter.id}`,
icon: <FolderOutlined />,
label: chapter.name,
label: (
<span className="admin-docs-view-menu-chapter-name" data-searching={!!searchQuery}>
{searchQuery ? highlightMatch(chapter.name, searchQuery) : chapter.name}
</span>
),
children: [...docItems, ...childItems],
};
});
})
.filter(Boolean) as MenuItem[];
};
return convert(chapters);
}, []);
const handleMenuClick: MenuProps["onClick"] = useCallback((e) => {
if (e.key.startsWith("doc-")) {
const docId = parseInt(e.key.replace("doc-", ""), 10);
setSelectedDocumentId(docId);
setMobileMenuOpen(false);
}
}, []);
return filterTree(treeData);
}, [treeData, searchQuery]);
function highlightMatch(text: string, query: string): React.ReactNode {
const lower = text.toLowerCase();
const q = query.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="admin-docs-view-search-highlight">{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
);
}
const handleMenuClick: MenuProps["onClick"] = useCallback(
(e) => {
if (e.key.startsWith("doc-")) {
const docId = parseInt(e.key.replace("doc-", ""), 10);
setSelectedDocumentId(docId);
setMobileMenuOpen(false);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}
},
[],
);
const selectFirstDocument = useCallback(() => {
if (!treeData || treeData.length === 0 || selectedDocumentId) return;
@@ -132,6 +209,16 @@ export default function DocsViewPage() {
selectFirstDocument();
}, [selectFirstDocument]);
const handleCopyCode = async (code: string, blockId: string) => {
try {
await navigator.clipboard.writeText(code);
setCopiedCodeBlockId(blockId);
setTimeout(() => setCopiedCodeBlockId(null), 2000);
} catch {
// silent
}
};
if (!user || !canRead) {
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -144,30 +231,64 @@ export default function DocsViewPage() {
);
}
const skeletonMenu = (
<div className="admin-docs-view-skeleton-menu">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="admin-docs-view-skeleton-item"
style={i % 3 === 0 ? {} : { paddingLeft: 20 }}
>
<Skeleton.Input active size="small" block style={{ width: `${50 + (i % 3) * 15}%` }} />
</div>
))}
</div>
);
const menuContent = (
<>
<div className="admin-docs-view-sider-header">
{!siderCollapsed && (
<Title level={4} style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
</Title>
<>
<Title level={4} style={{ margin: 0, fontSize: "16px", fontWeight: 600 }}>
</Title>
{!treeLoading && treeData && treeData.length > 0 && (
<div className="admin-docs-view-search-wrapper">
<Input
className="admin-docs-view-search-input"
placeholder="搜索文档..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
allowClear
size="small"
/>
</div>
)}
</>
)}
</div>
{treeLoading ? (
<div className="admin-docs-view-sider-loading">
<Spin />
</div>
skeletonMenu
) : treeData && treeData.length > 0 ? (
<div className="admin-docs-view-sider-menu">
<Menu
mode="inline"
items={convertToMenuItems(treeData)}
onClick={handleMenuClick}
selectedKeys={selectedDocumentId ? [`doc-${selectedDocumentId}`] : []}
style={{ borderRight: 0, background: "transparent" }}
inlineCollapsed={siderCollapsed && !isMobile}
/>
</div>
<>
{searchQuery && (
<div className="admin-docs-view-search-results-count">
{`找到 ${documentIndex.filter((d) => d.title.toLowerCase().includes(searchQuery.toLowerCase())).length} 个结果`}
</div>
)}
<div className="admin-docs-view-sider-menu">
<Menu
mode="inline"
items={filteredMenuItems}
onClick={handleMenuClick}
selectedKeys={selectedDocumentId ? [`doc-${selectedDocumentId}`] : []}
style={{ borderRight: 0, background: "transparent" }}
inlineCollapsed={siderCollapsed && !isMobile}
/>
</div>
</>
) : (
<div className="admin-docs-view-sider-empty">
<Empty description="暂无文档" image={Empty.PRESENTED_IMAGE_SIMPLE} />
@@ -175,27 +296,41 @@ export default function DocsViewPage() {
)}
{!isMobile && (
<div className="admin-docs-view-sider-footer">
<Button
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
type="text"
onClick={() => setSiderCollapsed((prev) => !prev)}
/>
<Tooltip title={siderCollapsed ? "展开菜单" : "收起菜单"}>
<Button
aria-label={siderCollapsed ? "展开菜单" : "收起菜单"}
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
type="text"
onClick={() => setSiderCollapsed((prev) => !prev)}
/>
</Tooltip>
</div>
)}
</>
);
const skeletonContent = (
<div className="admin-docs-view-skeleton-content">
<Skeleton active paragraph={{ rows: 1 }} title={{ width: "60%" }} />
<div style={{ marginTop: 24 }}>
<Skeleton active paragraph={{ rows: 3 }} />
</div>
<div style={{ marginTop: 24 }}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
<div style={{ marginTop: 24 }}>
<Skeleton active paragraph={{ rows: 4 }} />
</div>
</div>
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<AntCard
className="admin-docs-view-page-card"
extra={
isMobile ? (
<Button
icon={<MenuOutlined />}
onClick={() => setMobileMenuOpen(true)}
>
<Button icon={<MenuOutlined />} onClick={() => setMobileMenuOpen(true)}>
</Button>
) : null
@@ -208,12 +343,9 @@ export default function DocsViewPage() {
</div>
)}
<div className="admin-docs-view-content">
<div className="admin-docs-view-content" ref={contentRef}>
{documentLoading ? (
<div className="admin-docs-view-loading">
<Spin size="large" />
<Typography.Text type="secondary">...</Typography.Text>
</div>
skeletonContent
) : selectedDocument ? (
<AntCard className="admin-docs-view-document-card" bordered={false}>
<div className="admin-docs-view-document-header">
@@ -223,67 +355,106 @@ export default function DocsViewPage() {
</div>
<div className="admin-docs-view-markdown-content">
<ReactMarkdown
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={{
h1: ({ children }) => (
<Title level={2} style={{ marginTop: 32, marginBottom: 16 }}>
h1: ({ children, id }) => (
<Title level={2} className="admin-docs-view-heading-anchor" id={id}>
<a href={`#${id}`} className="admin-docs-view-heading-link" aria-label="Heading anchor">
<LinkOutlined />
</a>
{children}
</Title>
),
h2: ({ children }) => (
<Title level={3} style={{ marginTop: 28, marginBottom: 14 }}>
h2: ({ children, id }) => (
<Title level={3} className="admin-docs-view-heading-anchor" id={id}>
<a href={`#${id}`} className="admin-docs-view-heading-link" aria-label="Heading anchor">
<LinkOutlined />
</a>
{children}
</Title>
),
h3: ({ children }) => (
<Title level={4} style={{ marginTop: 24, marginBottom: 12 }}>
h3: ({ children, id }) => (
<Title level={4} className="admin-docs-view-heading-anchor" id={id}>
<a href={`#${id}`} className="admin-docs-view-heading-link" aria-label="Heading anchor">
<LinkOutlined />
</a>
{children}
</Title>
),
h4: ({ children }) => (
<Title level={5} style={{ marginTop: 20, marginBottom: 10 }}>
h4: ({ children, id }) => (
<Title level={5} className="admin-docs-view-heading-anchor" id={id}>
<a href={`#${id}`} className="admin-docs-view-heading-link" aria-label="Heading anchor">
<LinkOutlined />
</a>
{children}
</Title>
),
p: ({ children }) => (
<Paragraph style={{ marginBottom: 16 }}>
{children}
</Paragraph>
<Paragraph style={{ marginBottom: 16, lineHeight: 1.8 }}>{children}</Paragraph>
),
ul: ({ children }) => (
<ul style={{ marginBottom: 16, paddingLeft: 24 }}>
{children}
</ul>
<ul style={{ marginBottom: 16, paddingLeft: 24, lineHeight: 1.8 }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ marginBottom: 16, paddingLeft: 24 }}>
{children}
</ol>
),
li: ({ children }) => (
<li style={{ marginBottom: 8 }}>
{children}
</li>
<ol style={{ marginBottom: 16, paddingLeft: 24, lineHeight: 1.8 }}>{children}</ol>
),
li: ({ children }) => <li style={{ marginBottom: 8 }}>{children}</li>,
blockquote: ({ children }) => (
<blockquote className="admin-docs-view-blockquote">
{children}
</blockquote>
<blockquote className="admin-docs-view-blockquote">{children}</blockquote>
),
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
const codeText = String(children).replace(/\n$/, "");
const blockId = `code-${Math.random().toString(36).slice(2, 8)}`;
return isBlock ? (
<pre className="admin-docs-view-code-block">
<code>{children}</code>
</pre>
<div className="admin-docs-view-code-block-wrapper">
<div className="admin-docs-view-code-block-header">
<span className="admin-docs-view-code-lang">
{className?.replace("language-", "") || "code"}
</span>
<Button
type="text"
size="small"
className="admin-docs-view-copy-btn"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(codeText, blockId)}
>
{copiedCodeBlockId === blockId ? "已复制" : "复制"}
</Button>
</div>
<pre className="admin-docs-view-code-block">
<code className={className}>{children}</code>
</pre>
</div>
) : (
<code className="admin-docs-view-inline-code">{children}</code>
);
},
table: ({ children }) => (
<div className="admin-docs-view-table-wrapper">
<table className="admin-docs-view-table">
{children}
</table>
<table className="admin-docs-view-table">{children}</table>
</div>
),
img: ({ src, alt }) => (
<div className="admin-docs-view-image-wrapper">
<Image
src={src as string}
alt={alt || ""}
className="admin-docs-view-content-image"
placeholder={
<div className="admin-docs-view-image-placeholder">
<Skeleton.Image active />
</div>
}
preview={{
mask: <div className="admin-docs-view-image-preview-mask"></div>,
}}
/>
{alt && (
<Text type="secondary" className="admin-docs-view-image-caption">
{alt}
</Text>
)}
</div>
),
}}
@@ -308,16 +479,30 @@ export default function DocsViewPage() {
title="文档目录"
placement="left"
open={isMobile && mobileMenuOpen}
width={280}
width={300}
onClose={() => setMobileMenuOpen(false)}
styles={{ body: { padding: 0 } }}
>
<Menu
mode="inline"
items={convertToMenuItems(treeData || [])}
onClick={handleMenuClick}
selectedKeys={selectedDocumentId ? [`doc-${selectedDocumentId}`] : []}
style={{ borderRight: 0 }}
/>
<div className="admin-docs-view-mobile-drawer-content">
{!treeLoading && treeData && treeData.length > 0 && (
<div className="admin-docs-view-search-wrapper" style={{ padding: "12px 16px" }}>
<Input
placeholder="搜索文档..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
allowClear
/>
</div>
)}
<Menu
mode="inline"
items={filteredMenuItems}
onClick={handleMenuClick}
selectedKeys={selectedDocumentId ? [`doc-${selectedDocumentId}`] : []}
style={{ borderRight: 0 }}
/>
</div>
</Drawer>
</div>
);
+418
View File
@@ -1698,6 +1698,424 @@ body {
padding: 8px 12px;
font-size: 14px;
}
/* ======================================
docs-view 新增优化样式
====================================== */
/* 搜索框 */
.admin-docs-view-search-wrapper {
margin-top: 12px;
}
.admin-docs-view-search-input {
border-radius: var(--ant-border-radius);
transition: all 0.3s ease;
}
.admin-docs-view-search-input:focus {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ant-color-primary) 20%, transparent);
}
/* 搜索结果计数 */
.admin-docs-view-search-results-count {
font-size: 12px;
color: var(--ant-color-text-secondary);
padding: 4px 16px 0;
}
/* 搜索高亮 */
.admin-docs-view-search-highlight {
background: color-mix(in srgb, var(--ant-color-primary) 30%, transparent);
color: var(--ant-color-primary);
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
}
/* 骨架屏目录 */
.admin-docs-view-skeleton-menu {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-docs-view-skeleton-item {
height: 22px;
}
/* 骨架屏内容 */
.admin-docs-view-skeleton-content {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
/* 菜单项文档标题(搜索模式) */
.admin-docs-view-menu-doc-title,
.admin-docs-view-menu-chapter-name {
font-size: 14px;
transition: color 0.2s;
}
/* 标题锚点 */
.admin-docs-view-heading-anchor {
position: relative;
}
.admin-docs-view-heading-link {
position: absolute;
left: -24px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
color: var(--ant-color-text-secondary);
font-size: 14px;
transition: opacity 0.2s, color 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
text-decoration: none;
}
.admin-docs-view-heading-anchor:hover .admin-docs-view-heading-link {
opacity: 1;
}
.admin-docs-view-heading-link:hover {
color: var(--ant-color-primary);
}
/* 代码块包装(带标题栏) */
.admin-docs-view-code-block-wrapper {
margin: 20px 0;
border-radius: var(--ant-border-radius);
overflow: hidden;
border: 1px solid var(--ant-color-border-secondary);
}
.admin-docs-view-code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--fquiz-theme-table-header-bg);
border-bottom: 1px solid var(--ant-color-border-secondary);
font-size: 12px;
}
.admin-docs-view-code-lang {
color: var(--ant-color-text-secondary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
text-transform: uppercase;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
}
.admin-docs-view-copy-btn {
font-size: 12px;
color: var(--ant-color-text-secondary);
transition: color 0.2s;
}
.admin-docs-view-copy-btn:hover {
color: var(--ant-color-primary);
}
/* 代码块(在包装器内调整) */
.admin-docs-view-code-block-wrapper .admin-docs-view-code-block {
margin: 0;
border: none;
border-radius: 0;
}
/* 图片包装 */
.admin-docs-view-image-wrapper {
margin: 20px 0;
text-align: center;
}
.admin-docs-view-content-image {
border-radius: var(--ant-border-radius);
max-width: 100%;
cursor: zoom-in;
transition: box-shadow 0.3s ease;
}
.admin-docs-view-content-image:hover {
box-shadow: 0 4px 16px color-mix(in srgb, var(--fquiz-theme-text-primary) 12%, transparent);
}
.admin-docs-view-image-placeholder {
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ant-color-fill-alter);
border-radius: var(--ant-border-radius);
}
.admin-docs-view-image-preview-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, black 30%, transparent);
color: white;
font-size: 14px;
border-radius: var(--ant-border-radius);
opacity: 0;
transition: opacity 0.3s ease;
}
.admin-docs-view-image-wrapper:hover .admin-docs-view-image-preview-mask {
opacity: 1;
}
.admin-docs-view-image-caption {
display: block;
margin-top: 8px;
font-size: 13px;
text-align: center;
}
/* 表格增强:交替行背景 */
.admin-docs-view-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--ant-color-primary) 3%, transparent);
}
.admin-docs-view-table tbody tr:hover {
background: color-mix(in srgb, var(--ant-color-primary) 8%, transparent);
}
/* 内容区滚动条美化 */
.admin-docs-view-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.admin-docs-view-content::-webkit-scrollbar-track {
background: transparent;
}
.admin-docs-view-content::-webkit-scrollbar-thumb {
background: var(--fquiz-scrollbar-thumb);
border-radius: 4px;
transition: background 0.2s;
}
.admin-docs-view-content::-webkit-scrollbar-thumb:hover {
background: var(--fquiz-scrollbar-thumb-hover);
}
/* 侧栏菜单滚动条美化 */
.admin-docs-view-sider-menu::-webkit-scrollbar {
width: 6px;
}
.admin-docs-view-sider-menu::-webkit-scrollbar-track {
background: transparent;
}
.admin-docs-view-sider-menu::-webkit-scrollbar-thumb {
background: var(--fquiz-scrollbar-thumb);
border-radius: 3px;
}
.admin-docs-view-sider-menu::-webkit-scrollbar-thumb:hover {
background: var(--fquiz-scrollbar-thumb-hover);
}
/* 移动端 Drawer 内容 */
.admin-docs-view-mobile-drawer-content {
display: flex;
flex-direction: column;
height: 100%;
}
/* 引用块增强 */
.admin-docs-view-blockquote {
margin: 20px 0;
padding: 16px 20px;
border-left: 4px solid var(--ant-color-primary);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--ant-color-primary) 6%, transparent) 0%,
color-mix(in srgb, var(--ant-color-primary) 2%, transparent) 100%
);
border-radius: 0 var(--ant-border-radius) var(--ant-border-radius) 0;
position: relative;
}
.admin-docs-view-blockquote::before {
content: '"';
position: absolute;
top: -4px;
left: 8px;
font-size: 32px;
color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent);
font-family: Georgia, serif;
line-height: 1;
}
/* 暗色主题适配 */
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block-header {
background: color-mix(in srgb, var(--ant-color-bg-container) 80%, black);
border-bottom-color: color-mix(in srgb, var(--ant-color-border-secondary) 50%, transparent);
}
:root[data-fquiz-theme="dark"] .admin-docs-view-table tbody tr:nth-child(even) {
background: color-mix(in srgb, black 15%, transparent);
}
:root[data-fquiz-theme="dark"] .admin-docs-view-table tbody tr:hover {
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
}
:root[data-fquiz-theme="dark"] .admin-docs-view-image-placeholder {
background: color-mix(in srgb, var(--ant-color-bg-container) 80%, black);
}
:root[data-fquiz-theme="dark"] .admin-docs-view-blockquote {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--ant-color-primary) 10%, transparent) 0%,
color-mix(in srgb, var(--ant-color-primary) 4%, transparent) 100%
);
}
/* 移动端增强 */
@media (max-width: 767px) {
.admin-docs-view-document-header {
padding-bottom: 12px;
margin-bottom: 20px;
}
.admin-docs-view-markdown-content {
font-size: 15px;
line-height: 1.7;
}
.admin-docs-view-image-caption {
font-size: 12px;
}
.admin-docs-view-blockquote {
padding: 12px 16px;
margin: 16px 0;
}
.admin-docs-view-heading-link {
left: -20px;
font-size: 12px;
}
}
/* ======================================
highlight.js 语法高亮主题
====================================== */
.admin-docs-view-code-block code {
background: transparent;
padding: 0;
font-family: 'Consolas', 'Monaco', 'Courier New', 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--ant-color-text);
line-height: 1.6;
}
/* Light theme */
.admin-docs-view-code-block .hljs {
color: #24292e;
}
.admin-docs-view-code-block .hljs-keyword,
.admin-docs-view-code-block .hljs-selector-tag,
.admin-docs-view-code-block .hljs-type { color: #d73a49; }
.admin-docs-view-code-block .hljs-string,
.admin-docs-view-code-block .hljs-addition { color: #032f62; }
.admin-docs-view-code-block .hljs-number,
.admin-docs-view-code-block .hljs-literal { color: #005cc5; }
.admin-docs-view-code-block .hljs-comment,
.admin-docs-view-code-block .hljs-quote { color: #6a737d; font-style: italic; }
.admin-docs-view-code-block .hljs-built_in,
.admin-docs-view-code-block .hljs-title.class_ { color: #e36209; }
.admin-docs-view-code-block .hljs-title.function_,
.admin-docs-view-code-block .hljs-title { color: #6f42c1; }
.admin-docs-view-code-block .hljs-attr,
.admin-docs-view-code-block .hljs-attribute { color: #005cc5; }
.admin-docs-view-code-block .hljs-variable,
.admin-docs-view-code-block .hljs-template-variable { color: #e36209; }
.admin-docs-view-code-block .hljs-regexp { color: #032f62; }
.admin-docs-view-code-block .hljs-tag { color: #22863a; }
.admin-docs-view-code-block .hljs-name { color: #22863a; }
.admin-docs-view-code-block .hljs-selector-class { color: #6f42c1; }
.admin-docs-view-code-block .hljs-meta { color: #005cc5; }
/* Dark theme */
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs {
color: #e6e6e6;
}
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-keyword,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-selector-tag,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-type { color: #ff7b72; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-string,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-addition { color: #a5d6ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-number,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-literal { color: #79c0ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-comment,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-quote { color: #8b949e; font-style: italic; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-built_in,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-title.class_ { color: #ffa657; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-title.function_,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-title { color: #d2a8ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-attr,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-attribute { color: #79c0ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-variable,
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-template-variable { color: #ffa657; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-regexp { color: #a5d6ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-tag { color: #7ee787; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-name { color: #7ee787; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-selector-class { color: #d2a8ff; }
:root[data-fquiz-theme="dark"] .admin-docs-view-code-block .hljs-meta { color: #79c0ff; }
/* 内联引用:侧栏当前文档高亮增强 */
.ant-menu-item-selected {
position: relative;
}
.ant-menu-item-selected::after {
opacity: 1 !important;
}
}
/* 表格边框优化 - 仅外边框,上下圆角对齐 */