Files
GovAI/apps/web/src/components/ui/gov-markdown.tsx
T
selfrelease 96b944ac8d chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范
- 更新 ESLint 配置添加 react-hooks 规则
- 运行 Prettier 格式化所有前端文件
- 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏
- 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement)
- 修复 useRef 渲染期间访问问题(改用 useMemo)
- 修复 effect 中 setState 同步调用问题
- 新增 global-not-found.tsx 404 页面
- 修复 knowledge 页面引号转义问题
2026-06-23 14:48:31 +08:00

224 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import type { Chunk } from "@/lib/types";
const mdComponents: Components = {
h1: ({ children }) => (
<h1 className="text-lg font-bold text-primary border-b-2 border-primary/20 pb-2 mb-3 mt-4 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-bold text-primary/90 border-l-3 border-primary pl-3 mb-2 mt-4">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-bold text-foreground mb-1.5 mt-3 flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
{children}
</h3>
),
p: ({ children }) => <p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>,
ul: ({ children }) => <ul className="my-2 ml-1 space-y-1">{children}</ul>,
ol: ({ children }) => (
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
),
li: ({ children }) => (
<li className="text-sm leading-6 text-foreground/90 flex items-start gap-1.5">
<span className="inline-block w-1 h-1 rounded-full bg-primary/60 mt-2.5 shrink-0" />
<span className="flex-1">{children}</span>
</li>
),
blockquote: ({ children }) => (
<blockquote className="my-3 border-l-3 border-amber-400 bg-amber-50/60 dark:bg-amber-950/20 px-4 py-2 rounded-r-lg text-sm">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="my-3 overflow-x-auto rounded-lg border">
<table className="w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-primary/5 border-b">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
{children}
</th>
),
td: ({ children }) => <td className="px-3 py-2 text-sm border-b border-muted">{children}</td>,
hr: () => <hr className="my-4 border-t-2 border-dashed border-primary/10" />,
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
em: ({ children }) => <em className="text-primary/80 not-italic font-medium">{children}</em>,
a: ({ href, children }) => {
if (href === "#cite-kb") {
const label = String(children).replace(/^知识库:/, "");
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-blue-100 text-blue-700 border border-blue-300 align-middle hover:bg-blue-200 transition-colors cursor-pointer">
<BookOpen className="h-3.5 w-3.5 shrink-0" />
<span>{label}</span>
</span>
);
}
if (href === "#cite-ai") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-orange-100 text-orange-700 border border-orange-300 align-middle whitespace-nowrap hover:bg-orange-200 transition-colors cursor-pointer">
<BrainCircuit className="h-3.5 w-3.5 shrink-0" />
<span>AI建议</span>
</span>
);
}
// 推荐应用:渲染为可点击的应用跳转卡片
if (href?.startsWith("#cite-app:")) {
const slug = href.replace("#cite-app:", "");
const appName = String(children);
return <AppLinkBadge slug={slug} name={appName} />;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
{children}
</a>
);
},
code: ({ className, children }) => {
const lang = className?.replace("language-", "") || "";
const isBlock = className?.includes("language-");
if (isBlock && (lang === "markdown" || lang === "md")) {
const text = String(children).replace(/\n$/, "");
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{text}
</ReactMarkdown>
);
}
if (isBlock) {
return (
<div className="my-3 rounded-lg overflow-hidden border bg-slate-50 dark:bg-slate-900">
<div className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 text-xs text-muted-foreground border-b">
{lang || "代码"}
</div>
<pre className="p-3 overflow-x-auto text-xs leading-5">
<code>{children}</code>
</pre>
</div>
);
}
return (
<code className="text-xs font-mono bg-primary/5 text-primary px-1.5 py-0.5 rounded border border-primary/10">
{children}
</code>
);
},
pre: ({ children }) => <>{children}</>,
};
/**
* 推荐应用跳转徽章组件:点击后跳转到同机构内的其他应用
*/
function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
const router = useRouter();
return (
<button
type="button"
onClick={() => router.push(`/chat/${slug}`)}
className="inline-flex items-center gap-1 px-2.5 py-1 mx-0.5 text-xs font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 align-middle cursor-pointer hover:bg-emerald-100 hover:border-emerald-300 transition-colors whitespace-nowrap"
>
<ArrowRight className="h-3 w-3 shrink-0" />
<span>{name}</span>
</button>
);
}
interface GovMarkdownProps {
content: string;
className?: string;
chunks?: Chunk[];
}
function stripOuterCodeFence(text: string): string {
const trimmed = text.trim();
const match = trimmed.match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
if (match) return match[1].trim();
return text;
}
/**
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
*
* 支持的标注格式:
* - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名)
* - [[知识库:文档名]] → 知识库引用徽章
* - [[AI建议]] → AI建议徽章
* - [[推荐应用:名称:slug]] → 可点击跳转链接
*/
function preprocessCitations(content: string, chunks?: Chunk[]): string {
let result = content;
console.log("[preprocessCitations] chunks:", chunks);
// 先处理 chunk 编号引用 [[chunk:N]]
result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => {
const i = parseInt(idx, 10);
if (chunks && chunks[i]) {
// 使用 chunks 映射表,转换为带文档名的知识库引用
const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段";
console.log(`[preprocessCitations] chunk[${i}]:`, docName);
return `[知识库:${docName}](#cite-kb)`;
}
// 无映射时显示后备文本
console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length);
return `[知识库片段](#cite-kb)`;
});
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`);
// AI建议:标准格式 [[AI建议]]
result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)");
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
result = result.replace(/^(\s*\>?\s*)\*\*AI建议[:]\*\*/gm, "$1[AI建议](#cite-ai)");
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
result = result.replace(/^(\s*\>?\s*)AI建议[:]\s*$/gm, "$1[AI建议](#cite-ai)");
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
result = result.replace(
/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g,
(_, name, slug) => `[${name}](#cite-app:${slug})`,
);
return result;
}
const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) {
if (!content) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
<span className="inline-block w-2 h-2 rounded-full bg-primary/60 animate-pulse" />
...
</div>
);
}
const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks);
return (
<div className={`gov-markdown ${className || ""}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{cleaned}
</ReactMarkdown>
</div>
);
});
export default GovMarkdown;