fix(chat): 修复暗黑模式下 Markdown 和滚动条样式 — 响应式 useIsDark hook + 动态主题切换

This commit is contained in:
2026-06-02 22:44:46 +08:00
parent ed97b30d51
commit 1f05f259d0
7 changed files with 73 additions and 12 deletions

View File

@@ -5,19 +5,19 @@
"source": "vercel/ai", "source": "vercel/ai",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/use-ai-sdk/SKILL.md", "skillPath": "skills/use-ai-sdk/SKILL.md",
"computedHash": "c99d2a95b3a5f8fad218f288503f9e724ba0f12bf4e8aaf2c792a9f2bc318ab6" "computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd"
}, },
"ant-design": { "ant-design": {
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/ant-design/SKILL.md", "skillPath": "skills/ant-design/SKILL.md",
"computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466" "computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
}, },
"antd": { "antd": {
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/antd/SKILL.md", "skillPath": "skills/antd/SKILL.md",
"computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2" "computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94"
}, },
"x-components": { "x-components": {
"source": "ant-design/x", "source": "ant-design/x",

View File

@@ -6,6 +6,7 @@ import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useIsDark } from "../../../../hooks/use-is-dark";
import { useChatScroll } from "./use-chat-scroll"; import { useChatScroll } from "./use-chat-scroll";
interface ChatScrollAreaProps { interface ChatScrollAreaProps {
@@ -18,6 +19,7 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null); const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null); const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const isDark = useIsDark();
const handleOsInitialized = useCallback(() => { const handleOsInitialized = useCallback(() => {
const os = osRef.current; const os = osRef.current;
@@ -38,7 +40,10 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
events={{ initialized: handleOsInitialized }} events={{ initialized: handleOsInitialized }}
options={{ options={{
overflow: { x: "hidden", y: "scroll" }, overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" }, scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
},
}} }}
ref={osRef} ref={osRef}
> >

View File

@@ -2,6 +2,27 @@ import { CopyOutlined } from "@ant-design/icons";
import { CodeHighlighter } from "@ant-design/x"; import { CodeHighlighter } from "@ant-design/x";
import { App, Button, Flex, Typography } from "antd"; import { App, Button, Flex, Typography } from "antd";
import React from "react"; import React from "react";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useIsDark } from "../../../../../hooks/use-is-dark";
type SyntaxTheme = Record<string, Record<string, null | number | string>>;
const customOneDark: SyntaxTheme = {
...(oneDark as SyntaxTheme),
'pre[class*="language-"]': {
...(oneDark as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
const customOneLight: SyntaxTheme = {
...(oneLight as SyntaxTheme),
'pre[class*="language-"]': {
...(oneLight as SyntaxTheme)['pre[class*="language-"]'],
margin: 0,
},
};
interface CodeBlockWithCopyProps { interface CodeBlockWithCopyProps {
block?: boolean; block?: boolean;
@@ -13,14 +34,14 @@ interface CodeBlockWithCopyProps {
export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) { export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) {
const { message } = App.useApp(); const { message } = App.useApp();
const isDark = useIsDark();
if (!block) { if (!block) {
return <code className={className}>{children}</code>; return <code className={className}>{children}</code>;
} }
const codeText = extractText(children); const codeText = extractText(children);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const displayLang = lang ?? "plaintext";
const displayLang = lang || "plaintext";
const handleCopy = () => { const handleCopy = () => {
void navigator.clipboard.writeText(codeText).then(() => { void navigator.clipboard.writeText(codeText).then(() => {
@@ -44,7 +65,11 @@ export function CodeBlockWithCopy({ block, children, className, lang }: CodeBloc
); );
return ( return (
<CodeHighlighter header={header} lang={displayLang}> <CodeHighlighter
header={header}
highlightProps={{ style: (isDark ? customOneDark : customOneLight) as React.CSSProperties }}
lang={displayLang}
>
{codeText} {codeText}
</CodeHighlighter> </CodeHighlighter>
); );

View File

@@ -1,9 +1,11 @@
import { XMarkdown } from "@ant-design/x-markdown"; import { XMarkdown } from "@ant-design/x-markdown";
import "@ant-design/x-markdown/themes/dark.css";
import "@ant-design/x-markdown/themes/light.css"; import "@ant-design/x-markdown/themes/light.css";
import { Typography } from "antd"; import { Typography } from "antd";
import type { PartProps } from "./types"; import type { PartProps } from "./types";
import { useIsDark } from "../../../../../hooks/use-is-dark";
import { CodeBlockWithCopy } from "./CodeBlockWithCopy"; import { CodeBlockWithCopy } from "./CodeBlockWithCopy";
interface TextPartProps extends PartProps { interface TextPartProps extends PartProps {
@@ -18,6 +20,7 @@ const xmarkdownComponents = {
export function TextPart({ isStreaming, part, role }: TextPartProps) { export function TextPart({ isStreaming, part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : ""; const text = typeof part["text"] === "string" ? part["text"] : "";
const isDark = useIsDark();
return ( return (
<div className="part-body"> <div className="part-body">
@@ -25,7 +28,7 @@ export function TextPart({ isStreaming, part, role }: TextPartProps) {
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph> <Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : ( ) : (
<XMarkdown <XMarkdown
className="x-markdown-light" className={isDark ? "x-markdown-dark" : "x-markdown-light"}
components={xmarkdownComponents} components={xmarkdownComponents}
content={text} content={text}
streaming={{ hasNextChunk: isStreaming }} streaming={{ hasNextChunk: isStreaming }}

3
src/web/css.d.ts vendored
View File

@@ -1 +1,4 @@
declare module "*.css"; declare module "*.css";
declare module "react-syntax-highlighter/dist/esm/styles/prism" {
export { oneDark, oneLight } from "react-syntax-highlighter";
}

View File

@@ -0,0 +1,6 @@
import { theme } from "antd";
export function useIsDark(): boolean {
const { token } = theme.useToken();
return token.colorBgBase === "#000";
}

View File

@@ -225,23 +225,42 @@ body {
--os-handle-interactive-area-offset: 4px; --os-handle-interactive-area-offset: 4px;
} }
.x-markdown-light table { .os-theme-custom-dark {
--os-size: 8px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-track-border-radius: 10px;
--os-handle-border-radius: 10px;
--os-handle-bg: rgba(255, 255, 255, 0.15);
--os-handle-bg-hover: rgba(255, 255, 255, 0.25);
--os-handle-bg-active: rgba(255, 255, 255, 0.35);
--os-handle-min-size: 33px;
--os-handle-max-size: none;
--os-handle-interactive-area-offset: 4px;
}
.x-markdown-light table,
.x-markdown-dark table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
} }
.x-markdown-light th, .x-markdown-light th,
.x-markdown-light td { .x-markdown-light td,
.x-markdown-dark th,
.x-markdown-dark td {
border: 1px solid var(--ant-color-border); border: 1px solid var(--ant-color-border);
padding: 6px 12px; padding: 6px 12px;
text-align: left; text-align: left;
} }
.x-markdown-light th { .x-markdown-light th,
.x-markdown-dark th {
background: var(--ant-color-fill-quaternary); background: var(--ant-color-fill-quaternary);
font-weight: 600; font-weight: 600;
} }
.x-markdown-light .x-md-table-wrap { .x-markdown-light .x-md-table-wrap,
.x-markdown-dark .x-md-table-wrap {
overflow-x: auto; overflow-x: auto;
} }