feat: 增强 Markdown 代码块高亮和表格样式

This commit is contained in:
2026-06-03 17:23:43 +08:00
parent 714da2d633
commit a896091d27
11 changed files with 499 additions and 6 deletions

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { codeToHtml } from "shiki";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
interface CodeBlockProps {
children: ReactNode;
className?: string;
isStreaming: boolean;
}
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
const isDark = useIsDark();
const [highlighted, setHighlighted] = useState<null | string>(null);
const { codeText, lang } = extractCode(children);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(codeText).then(
() => message.success("已复制"),
() => message.error("复制失败"),
);
}, [codeText]);
useEffect(() => {
if (isStreaming || !codeText) return;
let cancelled = false;
codeToHtml(codeText, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
.then((html) => {
if (!cancelled) setHighlighted(html);
})
.catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [codeText, lang, isDark, isStreaming]);
if (isStreaming) {
return (
<pre className="code-block">
<code>{codeText}</code>
</pre>
);
}
return (
<div className="code-block">
<div className="code-block-header">
<span className="code-block-lang">{lang}</span>
<Button icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
</div>
{highlighted ? (
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<pre className="code-block-body">
<code>{codeText}</code>
</pre>
)}
</div>
);
}
function extractCode(children: ReactNode): { codeText: string; lang: string } {
if (children && typeof children === "object" && "props" in children && children.props) {
const props = children.props as Record<string, unknown>;
const codeText = typeof props["children"] === "string" ? props["children"] : "";
const codeClassName = typeof props["className"] === "string" ? props["className"] : "";
const classes = codeClassName.split(/\s+/);
const langClass = classes.find((c) => c.startsWith("lang-") || c.startsWith("language-")) ?? "";
const lang = langClass.replace(/^(language|lang)-/, "") || "text";
return { codeText, lang };
}
return { codeText: typeof children === "string" ? children : "", lang: "text" };
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
interface MarkdownTableProps {
children: ReactNode;
}
export function MarkdownTable({ children }: MarkdownTableProps) {
return <table className="markdown-table">{children}</table>;
}

View File

@@ -3,6 +3,9 @@ import Markdown from "markdown-to-jsx/react";
import type { PartProps } from "./types";
import { CodeBlock } from "./CodeBlock";
import { MarkdownTable } from "./MarkdownTable";
interface TextPartProps extends PartProps {
isStreaming: boolean;
role: string;
@@ -16,7 +19,17 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) {
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Markdown options={{ optimizeForStreaming: isStreaming }}>{text}</Markdown>
<Markdown
options={{
optimizeForStreaming: isStreaming,
overrides: {
pre: { component: CodeBlock, props: { isStreaming } },
table: MarkdownTable,
},
}}
>
{text}
</Markdown>
)}
</div>
);

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
const PREFERENCE_CHANGE_EVENT = "theme-preference-change";
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
@@ -44,10 +46,19 @@ export function useThemePreference() {
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
const setPreference = (nextPreference: ThemePreference) => {
useEffect(() => {
const handleStorageEvent = (event: CustomEvent) => {
const next = parseThemePreference(event.detail);
setPreferenceState((prev) => (prev !== next ? next : prev));
};
window.addEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
return () => window.removeEventListener(PREFERENCE_CHANGE_EVENT, handleStorageEvent as EventListener);
}, []);
const setPreference = useCallback((nextPreference: ThemePreference) => {
setPreferenceState(nextPreference);
writeThemePreference(nextPreference);
};
}, []);
return { effectiveTheme, preference, setPreference };
}
@@ -58,4 +69,9 @@ export function writeThemePreference(preference: ThemePreference, storage: Stora
} catch {
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
}
try {
window.dispatchEvent(new CustomEvent(PREFERENCE_CHANGE_EVENT, { detail: preference }));
} catch {
// jsdom 等环境可能不支持 CustomEvent
}
}

View File

@@ -291,3 +291,74 @@ body {
.app-inbox-datepicker {
width: 100%;
}
/* Markdown 代码块 */
.code-block {
margin: var(--ant-margin-sm) 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
overflow: hidden;
font-family: var(--ant-font-family-code, monospace);
font-size: var(--ant-font-size-sm);
}
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ant-padding-xxs) var(--ant-padding-sm);
background: var(--ant-color-bg-container);
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.code-block-lang {
color: var(--ant-color-text-quaternary);
font-size: var(--ant-font-size-xs);
text-transform: lowercase;
}
.code-block-body {
margin: 0;
padding: var(--ant-padding-sm);
overflow-x: auto;
background: var(--ant-color-bg-container);
}
.code-block-body code {
font-family: inherit;
font-size: inherit;
}
/* Markdown 表格 */
.markdown-table {
width: 100%;
margin: var(--ant-margin-sm) 0;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
overflow: hidden;
font-size: var(--ant-font-size);
}
.markdown-table th,
.markdown-table td {
padding: var(--ant-padding-xs) var(--ant-padding-sm);
border-bottom: 1px solid var(--ant-color-border-secondary);
text-align: left;
}
.markdown-table thead th {
background: var(--ant-color-bg-container);
color: var(--ant-color-text);
font-weight: 600;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.markdown-table tbody tr:last-child td {
border-bottom: none;
}
.markdown-table tbody tr:hover td {
background: var(--ant-color-fill-quaternary);
}