feat: 增强Markdown组件以支持小组件渲染和图片预览功能

1. 新增WidgetRenderer以解析和渲染小组件,支持从Markdown中提取小组件语法。
2. 更新MarkdownBody组件,添加onOpenGalleryPreview回调以处理图片画廊预览。
3. 修改样式以确保小组件和列表项的显示效果一致。
4. 优化Skeleton组件的高度设置,提升加载体验。
This commit is contained in:
刘宇阳
2026-07-24 17:02:16 +08:00
parent 17c8a9f2ea
commit 45723ee787
9 changed files with 1595 additions and 17 deletions

View File

@@ -178,6 +178,10 @@
@apply my-2.5 ml-6;
}
.tx-widget li {
margin: 0 !important;
}
ul:not(.contains-task-list) {
@apply list-disc;
}
@@ -199,6 +203,11 @@
}
}
.tx-widget ol > li::before {
content: none !important;
display: none !important;
}
blockquote {
@apply my-5 pl-4 bg-[rgba(246,248,250)] border-l-4 border-[#11181C] dark:border-gray-500 dark:bg-[rgba(246,248,250,0.1)];
}

View File

@@ -23,6 +23,9 @@ import './index.scss';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
import WidgetRenderer from './widgets';
import { isWidgetCodeLanguage, parseWidgetPayload, payloadFromLinkAlias } from './widgets/parse';
import './widgets/index.scss';
interface Props {
data: string;
@@ -149,7 +152,7 @@ function MarkdownImage({
<img
alt={alt}
src={imageSrc}
className={`${className ?? 'max-h-[500px]'} ${loaded ? '' : 'markdown-img--loading'}`}
className={`${className ?? 'max-h-125'} ${loaded ? '' : 'markdown-img--loading'}`}
onLoad={markLoaded}
ref={(node) => {
if (node?.complete) markLoaded();
@@ -164,9 +167,15 @@ type MarkdownBodyProps = {
data: string;
headings: TocHeading[];
onOpenPreview: (src: string) => void;
onOpenGalleryPreview: (src: string, urls: string[]) => void;
};
const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview }: MarkdownBodyProps) {
const MarkdownBody = memo(function MarkdownBody({
data,
headings,
onOpenPreview,
onOpenGalleryPreview,
}: MarkdownBodyProps) {
const headingIndexRef = useRef(0);
const headingComponents = useMemo(
@@ -178,18 +187,32 @@ const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview
() => ({
img: (props) => <MarkdownImage {...props} onPreview={onOpenPreview} />,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
if (children === 'douyin-video' && href) {
const videoId = href.split('/').pop();
return (
<div className="flex justify-center">
<iframe src={`https://open.douyin.com/player/video?vid=${videoId}&autoplay=0`} referrerPolicy="unsafe-url" allowFullScreen className="douyin" />
</div>
);
const label = typeof children === 'string' ? children : Array.isArray(children) && children.length === 1 && typeof children[0] === 'string' ? children[0] : null;
if (label && href) {
const payload = payloadFromLinkAlias(label, href);
if (payload) {
return <WidgetRenderer data={payload} onPreview={onOpenGalleryPreview} />;
}
}
return <a href={href}>{children}</a>;
},
// CodeBlock / Widget 自身已输出完整块级结构,避免再包一层 pre
pre: ({ children }) => <>{children}</>,
code: ({ node, inline, className = '', children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const match = /language-([\w-]+)/.exec(className || '');
const codeString = node?.value ?? String(children).replace(/\n$/, '');
if (!inline && isWidgetCodeLanguage(className)) {
const payload = parseWidgetPayload(codeString);
if (payload) {
return <WidgetRenderer data={payload} onPreview={onOpenGalleryPreview} />;
}
return (
<div className="tx-widget tx-widget--unknown">
JSON /
</div>
);
}
if (inline || !match) {
return (
@@ -200,12 +223,10 @@ const MarkdownBody = memo(function MarkdownBody({ data, headings, onOpenPreview
}
const language = match[1].toLowerCase();
const codeString = node?.value ?? String(children);
return <CodeBlock language={language} value={codeString} />;
},
}),
[onOpenPreview],
[onOpenPreview, onOpenGalleryPreview],
);
useEffect(() => {
@@ -244,6 +265,7 @@ const ContentMD = ({ data, headings = [] }: Props) => {
const [isClient, setIsClient] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewStartIndex, setPreviewStartIndex] = useState(0);
const [previewPhotos, setPreviewPhotos] = useState<PhotoItem[]>([]);
const photos = useMemo<PhotoItem[]>(
() => extractMarkdownImages(data).map((url, i) => ({ id: `${i}`, url, alt: `图片-${i + 1}` })),
@@ -251,11 +273,20 @@ const ContentMD = ({ data, headings = [] }: Props) => {
);
const openPreview = useCallback((src: string) => {
setPreviewPhotos(photos);
const index = photos.findIndex((photo) => photo.url === src);
setPreviewStartIndex(index >= 0 ? index : 0);
setPreviewOpen(true);
}, [photos]);
const openGalleryPreview = useCallback((src: string, urls: string[]) => {
const list = urls.map((url, i) => ({ id: `g-${i}`, url, alt: `图片-${i + 1}` }));
setPreviewPhotos(list);
const index = list.findIndex((photo) => photo.url === src);
setPreviewStartIndex(index >= 0 ? index : 0);
setPreviewOpen(true);
}, []);
const closePreview = useCallback(() => {
setPreviewOpen(false);
}, []);
@@ -299,13 +330,13 @@ const ContentMD = ({ data, headings = [] }: Props) => {
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-[200px] w-3/6 my-4" />
<Skeleton className="h-50 w-3/6 my-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-9/12" />
</div>
<Skeleton className="h-[120px] w-full" />
<Skeleton className="h-30 w-full" />
</div>
</div>
);
@@ -313,11 +344,16 @@ const ContentMD = ({ data, headings = [] }: Props) => {
return (
<>
<MarkdownBody data={data} headings={headings} onOpenPreview={openPreview} />
<MarkdownBody
data={data}
headings={headings}
onOpenPreview={openPreview}
onOpenGalleryPreview={openGalleryPreview}
/>
<PhotoPreview
open={previewOpen}
photos={photos}
photos={previewPhotos.length ? previewPhotos : photos}
index={previewStartIndex}
onClose={closePreview}
/>

View File

@@ -0,0 +1,257 @@
'use client';
import { useState } from 'react';
import type {
GalleryItem,
StepItem,
TabItem,
TimelineItem,
WidgetPayload,
} from './types';
function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
function WidgetShell({
children,
className,
label,
}: {
children: React.ReactNode;
className?: string;
label?: string;
}) {
return (
<div className={cx('tx-widget', className)} data-widget={label}>
{children}
</div>
);
}
export function BilibiliWidget({ data }: { data: WidgetPayload }) {
const bvid = asString(data.bvid || data.id);
if (!bvid) return null;
const page = Number(data.page || 1) || 1;
const src = `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${page}&high_quality=1&danmaku=0`;
return (
<WidgetShell label="bilibili" className="tx-widget--media">
<div className="tx-widget__ratio">
<iframe
src={src}
title={`Bilibili ${bvid}`}
allowFullScreen
scrolling="no"
frameBorder={0}
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
/>
</div>
</WidgetShell>
);
}
export function YoutubeWidget({ data }: { data: WidgetPayload }) {
const id = asString(data.id || data.videoId);
if (!id) return null;
return (
<WidgetShell label="youtube" className="tx-widget--media">
<div className="tx-widget__ratio">
<iframe
src={`https://www.youtube.com/embed/${encodeURIComponent(id)}`}
title={`YouTube ${id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</WidgetShell>
);
}
export function NeteaseWidget({ data }: { data: WidgetPayload }) {
const id = asString(data.id);
const auto = data.auto === true || data.autoplay === true ? 1 : 0;
if (!id) return null;
return (
<WidgetShell label="netease" className="tx-widget--embed">
<iframe
className="tx-widget__netease"
src={`https://music.163.com/outchain/player?type=2&id=${encodeURIComponent(id)}&auto=${auto}&height=66`}
title={`Netease ${id}`}
/>
</WidgetShell>
);
}
export function DouyinWidget({ data }: { data: WidgetPayload }) {
const id = asString(data.id || data.vid);
if (!id) return null;
return (
<WidgetShell label="douyin" className="tx-widget--media">
<div className="tx-widget__ratio tx-widget__ratio--portrait">
<iframe
src={`https://open.douyin.com/player/video?vid=${encodeURIComponent(id)}&autoplay=0`}
title={`Douyin ${id}`}
referrerPolicy="unsafe-url"
allowFullScreen
className="douyin"
/>
</div>
</WidgetShell>
);
}
export function AudioWidget({ data }: { data: WidgetPayload }) {
const src = asString(data.src || data.url);
const title = asString(data.title, '音频');
if (!src) return null;
return (
<WidgetShell label="audio" className="tx-widget--card">
<div className="tx-widget__audio">
<div className="tx-widget__audio-meta">
<strong>{title}</strong>
{asString(data.artist) && <span>{asString(data.artist)}</span>}
</div>
<audio controls preload="none" src={src}>
</audio>
</div>
</WidgetShell>
);
}
export function TabsWidget({ data }: { data: WidgetPayload }) {
const items = asArray<TabItem>(data.items).filter((item) => item?.title);
const [active, setActive] = useState(0);
if (!items.length) return null;
const current = items[Math.min(active, items.length - 1)];
return (
<WidgetShell label="tabs" className="tx-widget--card">
<div className="tx-widget__tabs" role="tablist">
{items.map((item, index) => (
<button
key={`${item.title}-${index}`}
type="button"
role="tab"
aria-selected={index === active}
className={cx(index === active && 'is-active')}
onClick={() => setActive(index)}
>
{item.title}
</button>
))}
</div>
<div className="tx-widget__tab-panel whitespace-pre-wrap" role="tabpanel">
{current?.content || ''}
</div>
</WidgetShell>
);
}
export function TimelineWidget({ data }: { data: WidgetPayload }) {
const items = asArray<TimelineItem>(data.items);
if (!items.length) return null;
return (
<WidgetShell label="timeline" className="tx-widget--card">
<div className="tx-widget__timeline">
{items.map((item, index) => (
<div className="tx-widget__timeline-item" key={`${item.title}-${index}`}>
<span className="tx-widget__dot" aria-hidden />
<div className="tx-widget__timeline-body">
{item.time && <time>{item.time}</time>}
<strong>{item.title}</strong>
{item.content && <p className="whitespace-pre-wrap">{item.content}</p>}
</div>
</div>
))}
</div>
</WidgetShell>
);
}
export function StepsWidget({ data }: { data: WidgetPayload }) {
const items = asArray<StepItem>(data.items);
if (!items.length) return null;
return (
<WidgetShell label="steps" className="tx-widget--card">
<div className="tx-widget__steps">
{items.map((item, index) => (
<div className="tx-widget__step-item" key={`${item.title}-${index}`}>
<span className="tx-widget__step-index">{index + 1}</span>
<div className="tx-widget__step-body">
<strong>{item.title}</strong>
{item.content && <p className="whitespace-pre-wrap">{item.content}</p>}
</div>
</div>
))}
</div>
</WidgetShell>
);
}
export function CtaWidget({ data }: { data: WidgetPayload }) {
const title = asString(data.title, '立刻行动');
const description = asString(data.description);
const primaryText = asString(data.primaryText || data.buttonText, '了解更多');
const primaryUrl = asString(data.primaryUrl || data.url || data.href, '#');
const secondaryText = asString(data.secondaryText);
const secondaryUrl = asString(data.secondaryUrl);
return (
<WidgetShell label="cta" className="tx-widget--cta">
<div>
<strong>{title}</strong>
{description && <p>{description}</p>}
</div>
<div className="tx-widget__cta-actions">
<a href={primaryUrl} target="_blank" rel="noopener noreferrer" className="is-primary">
{primaryText}
</a>
{secondaryText && secondaryUrl && (
<a href={secondaryUrl} target="_blank" rel="noopener noreferrer">
{secondaryText}
</a>
)}
</div>
</WidgetShell>
);
}
export function GalleryWidget({
data,
onPreview,
}: {
data: WidgetPayload;
onPreview?: (src: string, urls: string[]) => void;
}) {
const items = asArray<GalleryItem>(data.items).filter((item) => item?.src);
const urls = items.map((item) => item.src);
if (!items.length) return null;
return (
<WidgetShell label="gallery" className="tx-widget--card">
<div className={cx('tx-widget__gallery', items.length === 1 && 'is-single')}>
{items.map((item, index) => (
<button
key={`${item.src}-${index}`}
type="button"
className="tx-widget__gallery-item"
onClick={() => onPreview?.(item.src, urls)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.src} alt={item.alt || `gallery-${index + 1}`} />
</button>
))}
</div>
</WidgetShell>
);
}

View File

@@ -0,0 +1,926 @@
@reference '../../../../../styles/tailwind.css';
.tx-widget {
margin: 1.25rem 0;
color: #334155;
// 隔离文章 Markdown 的 ol/li 编号与缩进样式
list-style: none;
ol,
ul,
li {
list-style: none !important;
margin-left: 0 !important;
padding-left: 0;
}
ol > li::before,
ul > li::before {
content: none !important;
display: none !important;
border: 0 !important;
width: 0 !important;
height: 0 !important;
}
.dark & {
color: #cbd5e1;
}
&--card,
&--cta,
&--terminal,
&--embed {
// border: 1px solid #e8eef6;
// border-radius: 14px;
// background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
overflow: hidden;
.dark & {
border-color: #3d4654;
background: linear-gradient(180deg, #1b2230 0%, #161b26 100%);
}
}
&--media {
border-radius: 14px;
overflow: hidden;
}
&--unknown {
padding: 0.75rem 1rem;
border-radius: 10px;
background: #fff4f4;
color: #b42318;
font-size: 0.875rem;
.dark & {
background: #3a2323;
color: #f5a8a0;
}
}
&__ratio {
position: relative;
width: 100%;
padding-top: 56.25%;
background: #0f172a;
iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
&--portrait {
padding-top: 70%;
max-width: 420px;
margin: 0 auto;
}
}
&__netease {
width: 100%;
height: 86px;
border: 0;
}
&__eyebrow {
display: inline-block;
margin-bottom: 0.35rem;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #539dfd;
}
&__audio {
display: grid;
gap: 0.75rem;
// padding: 1rem 1.1rem;
audio {
width: 100%;
}
strong {
display: block;
font-size: 1rem;
}
span {
color: #64748b;
font-size: 0.875rem;
}
}
&__collapse-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1.1rem;
text-align: left;
font-weight: 600;
cursor: pointer;
svg {
transition: transform 0.2s ease;
flex-shrink: 0;
}
&.is-open svg {
transform: rotate(180deg);
}
}
&__collapse-body {
padding: 0 1.1rem 1.1rem;
color: #475569;
line-height: 1.7;
border-top: 1px dashed #e2e8f0;
.dark & {
color: #94a3b8;
border-top-color: #3d4654;
}
}
&__tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.75rem 0.75rem 0;
button {
border-radius: 999px;
padding: 0.4rem 0.85rem;
font-size: 0.875rem;
color: #64748b;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&.is-active {
background: #539dfd;
color: #fff;
}
&:hover:not(.is-active) {
background: rgba(83, 157, 253, 0.12);
color: #539dfd;
}
}
}
&__tab-panel {
padding: 1rem 1.1rem 1.2rem;
line-height: 1.7;
}
&__timeline {
margin: 0;
padding: 1.15rem 1.2rem 0.4rem;
display: grid;
gap: 0;
}
&__timeline-item {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 0.9rem;
padding-bottom: 1.15rem;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 7px;
top: 18px;
bottom: 0;
width: 2px;
background: #dbe7f8;
.dark & {
background: #3d4654;
}
}
}
&__timeline-body {
min-width: 0;
time {
display: block;
font-size: 0.75rem;
color: #539dfd;
margin-bottom: 0.2rem;
font-weight: 600;
}
strong {
display: block;
margin-bottom: 0.25rem;
color: #1f2937;
font-size: 0.95rem;
.dark & {
color: #e2e8f0;
}
}
p {
margin: 0;
color: #64748b;
line-height: 1.65;
font-size: 0.875rem;
}
}
&__dot {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid #539dfd;
margin-top: 2px;
box-sizing: border-box;
.dark & {
background: #161b26;
}
}
&__steps {
margin: 0;
// padding: 1.15rem 1.2rem;
display: grid;
gap: 0.95rem;
}
&__step-item {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 0.9rem;
align-items: start;
}
&__step-body {
min-width: 0;
padding-top: 0.15rem;
strong {
display: block;
margin-bottom: 0.2rem;
color: #1f2937;
font-size: 0.95rem;
.dark & {
color: #e2e8f0;
}
}
p {
margin: 0;
color: #64748b;
line-height: 1.65;
font-size: 0.875rem;
}
}
&__step-index {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(83, 157, 253, 0.12);
color: #539dfd;
font-weight: 700;
font-size: 0.875rem;
flex-shrink: 0;
}
&__info {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
padding: 1rem;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
&__info-cover {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background: #e2e8f0;
}
&__info-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
strong {
font-size: 1.05rem;
}
p {
margin: 0;
color: #64748b;
line-height: 1.6;
}
}
&__link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
color: #539dfd;
font-size: 0.875rem;
}
&__quote {
margin: 0;
padding: 1.15rem 1.25rem;
border-left: 3px solid #539dfd;
background: transparent;
p {
margin: 0;
font-size: 1.05rem;
line-height: 1.75;
}
footer {
margin-top: 0.85rem;
display: flex;
gap: 0.75rem;
align-items: center;
font-size: 0.875rem;
color: #64748b;
}
a {
color: #539dfd;
}
}
&__download-list {
list-style: none;
margin: 0;
padding: 0.5rem;
li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem;
border-radius: 12px;
&:hover {
background: rgba(83, 157, 253, 0.06);
}
strong {
display: block;
}
p {
margin: 0.25rem 0;
color: #64748b;
font-size: 0.875rem;
}
a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: #539dfd;
color: #fff;
font-size: 0.875rem;
}
}
}
&__api {
padding: 1rem 1.1rem 1.15rem;
p {
margin: 0.75rem 0;
color: #64748b;
}
pre {
margin: 0.65rem 0 0;
padding: 0.75rem;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
overflow: auto;
font-size: 0.8125rem;
}
}
&__api-head {
display: flex;
align-items: center;
gap: 0.65rem;
code {
flex: 1;
font-size: 0.9rem;
word-break: break-all;
}
button {
color: #64748b;
cursor: pointer;
&:hover {
color: #539dfd;
}
}
}
&__method {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3.25rem;
padding: 0.2rem 0.45rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
background: #64748b;
&.is-get {
background: #16a34a;
}
&.is-post {
background: #2563eb;
}
&.is-put,
&.is-patch {
background: #d97706;
}
&.is-delete {
background: #dc2626;
}
}
&--cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.15rem 1.25rem;
background:
radial-gradient(circle at top right, rgba(83, 157, 253, 0.18), transparent 45%),
linear-gradient(180deg, #f8fbff 0%, #fff 100%);
.dark & {
background:
radial-gradient(circle at top right, rgba(83, 157, 253, 0.2), transparent 45%),
linear-gradient(180deg, #1b2230 0%, #161b26 100%);
}
strong {
display: block;
font-size: 1.05rem;
margin-bottom: 0.25rem;
}
p {
margin: 0;
color: #64748b;
font-size: 0.9rem;
}
@media (max-width: 640px) {
flex-direction: column;
align-items: stretch;
}
}
&__cta-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.5rem;
padding: 0 1rem;
border-radius: 999px;
border: 1px solid #dbe7f8;
color: #334155;
font-size: 0.875rem;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
.dark & {
border-color: #3d4654;
color: #e2e8f0;
}
&.is-primary {
background: #539dfd;
border-color: #539dfd;
color: #fff;
&:hover {
background: #3f8ef0;
}
}
&:hover:not(.is-primary) {
border-color: #539dfd;
color: #539dfd;
}
}
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
&__badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(83, 157, 253, 0.12);
color: #2563eb;
font-size: 0.8125rem;
font-weight: 600;
}
&__kbd-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
&__kbd-group {
display: inline-flex;
align-items: center;
gap: 0.35rem;
i {
font-style: normal;
color: #94a3b8;
margin: 0 0.15rem;
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.2rem 0.45rem;
border-radius: 6px;
border: 1px solid #cbd5e1;
background: linear-gradient(180deg, #fff, #f1f5f9);
box-shadow: 0 1px 0 #cbd5e1;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
.dark & {
border-color: #475569;
background: linear-gradient(180deg, #1e293b, #0f172a);
box-shadow: 0 1px 0 #334155;
}
}
}
&--terminal {
background: #0b1220 !important;
border-color: #1e293b !important;
color: #e2e8f0;
}
&__terminal-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid #1e293b;
color: #94a3b8;
font-size: 0.75rem;
button {
color: #94a3b8;
cursor: pointer;
&:hover {
color: #fff;
}
}
}
&__prompt {
color: #4ade80;
}
&--terminal pre {
margin: 0;
padding: 0.9rem 1rem 1.1rem;
overflow: auto;
font-size: 0.875rem;
line-height: 1.7;
background: transparent;
}
&__diff-head {
padding: 0.7rem 1rem;
border-bottom: 1px solid #e8eef6;
font-size: 0.8125rem;
color: #64748b;
.dark & {
border-bottom-color: #3d4654;
}
}
&__diff pre {
margin: 0;
padding: 0.5rem 0;
overflow: auto;
font-size: 0.8125rem;
line-height: 1.6;
background: transparent;
}
&__diff-line {
display: grid;
grid-template-columns: 1.5rem 1fr;
padding: 0 1rem;
gap: 0.35rem;
&.is-add {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
}
&.is-del {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
code {
white-space: pre-wrap;
word-break: break-word;
}
}
&__gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
// padding: 0.75rem;
&.is-single {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
&__gallery-item {
position: relative;
overflow: hidden;
border-radius: 12px;
aspect-ratio: 4 / 3;
cursor: zoom-in;
background: #e2e8f0;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.04);
}
}
&__compare {
position: relative;
overflow: hidden;
aspect-ratio: 16 / 10;
cursor: ew-resize;
user-select: none;
touch-action: none;
background: #0f172a;
.is-after,
.is-before {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.is-before {
z-index: 1;
}
}
&__compare-handle {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: #fff;
z-index: 2;
span {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #fff;
background: #539dfd;
transform: translate(-50%, -50%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
}
&__compare-labels {
position: absolute;
left: 0.75rem;
right: 0.75rem;
bottom: 0.75rem;
display: flex;
justify-content: space-between;
pointer-events: none;
z-index: 2;
em {
font-style: normal;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.65);
color: #fff;
font-size: 0.75rem;
}
}
&__section-title {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.9rem 1.1rem 0;
font-weight: 600;
color: #334155;
.dark & {
color: #e2e8f0;
}
}
&__related {
list-style: none;
margin: 0;
padding: 0.65rem;
li a {
display: block;
padding: 0.85rem;
border-radius: 12px;
transition: background 0.2s ease;
&:hover {
background: rgba(83, 157, 253, 0.08);
}
strong {
display: block;
margin-bottom: 0.25rem;
}
p {
margin: 0;
color: #64748b;
font-size: 0.875rem;
line-height: 1.5;
}
}
}
&__related-empty {
padding: 1rem 1.1rem;
color: #64748b;
font-size: 0.875rem;
}
&__vote {
padding: 1.1rem;
strong {
display: block;
margin-bottom: 0.85rem;
font-size: 1.05rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
button {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 2.75rem;
padding: 0.65rem 0.85rem;
border-radius: 10px;
border: 1px solid #e2e8f0;
overflow: hidden;
cursor: pointer;
text-align: left;
.dark & {
border-color: #3d4654;
}
&:disabled {
cursor: default;
}
&:not(:disabled):hover {
border-color: #539dfd;
}
}
}
&__vote-fill {
position: absolute;
inset: 0 auto 0 0;
background: rgba(83, 157, 253, 0.16);
transition: width 0.35s ease;
}
&__vote-label,
&__vote-percent {
position: relative;
z-index: 1;
}
&__vote-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
&__vote-percent {
color: #539dfd;
font-size: 0.875rem;
font-weight: 600;
}
&__hint {
margin: 0.75rem 0 0;
font-size: 0.75rem;
color: #94a3b8;
}
}

View File

@@ -0,0 +1,51 @@
'use client';
import type { WidgetPayload } from './types';
import {
AudioWidget,
BilibiliWidget,
CtaWidget,
DouyinWidget,
GalleryWidget,
NeteaseWidget,
StepsWidget,
TabsWidget,
TimelineWidget,
YoutubeWidget,
} from './components';
type Props = {
data: WidgetPayload;
onPreview?: (src: string, urls: string[]) => void;
};
export default function WidgetRenderer({ data, onPreview }: Props) {
switch (data.type) {
case 'bilibili':
return <BilibiliWidget data={data} />;
case 'youtube':
return <YoutubeWidget data={data} />;
case 'netease':
return <NeteaseWidget data={data} />;
case 'douyin':
return <DouyinWidget data={data} />;
case 'audio':
return <AudioWidget data={data} />;
case 'tabs':
return <TabsWidget data={data} />;
case 'timeline':
return <TimelineWidget data={data} />;
case 'steps':
return <StepsWidget data={data} />;
case 'cta':
return <CtaWidget data={data} />;
case 'gallery':
return <GalleryWidget data={data} onPreview={onPreview} />;
default:
return (
<div className="tx-widget tx-widget--unknown">
{String((data as WidgetPayload).type)}
</div>
);
}
}

View File

@@ -0,0 +1,113 @@
import type { WidgetPayload, WidgetType } from './types';
const LINK_ALIAS: Record<string, WidgetType> = {
bilibili: 'bilibili',
youtube: 'youtube',
netease: 'netease',
'netease-music': 'netease',
douyin: 'douyin',
'douyin-video': 'douyin',
audio: 'audio',
};
function stripFenceWrappers(raw: string): string {
return raw
.replace(/^```(?:tx-widget|widget)?\s*/i, '')
.replace(/```$/i, '')
.trim();
}
function parseLineConfig(raw: string): Record<string, unknown> | null {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) return null;
const result: Record<string, unknown> = {};
for (const line of lines) {
const match = line.match(/^([A-Za-z_][\w-]*)\s*[:=]\s*(.+)$/);
if (!match) return null;
const key = match[1];
let value: unknown = match[2].trim();
if ((value as string).startsWith('[') || (value as string).startsWith('{')) {
try {
value = JSON.parse(value as string);
} catch {
// keep string
}
} else if (value === 'true' || value === 'false') {
value = value === 'true';
} else if (/^-?\d+(\.\d+)?$/.test(value as string)) {
value = Number(value);
} else if (
((value as string).startsWith('"') && (value as string).endsWith('"')) ||
((value as string).startsWith("'") && (value as string).endsWith("'"))
) {
value = (value as string).slice(1, -1);
}
result[key] = value;
}
return result.type ? result : null;
}
export function parseWidgetPayload(raw: string): WidgetPayload | null {
const text = stripFenceWrappers(raw);
if (!text) return null;
try {
const json = JSON.parse(text) as WidgetPayload;
if (json && typeof json === 'object' && typeof json.type === 'string') {
return json;
}
} catch {
// fallback to line config
}
const lineConfig = parseLineConfig(text);
if (lineConfig && typeof lineConfig.type === 'string') {
return lineConfig as WidgetPayload;
}
return null;
}
export function payloadFromLinkAlias(label: string, href?: string): WidgetPayload | null {
const type = LINK_ALIAS[label.trim().toLowerCase()];
if (!type || !href) return null;
if (type === 'bilibili') {
const bvid = href.match(/BV[\w]+/i)?.[0] || href;
return { type, bvid };
}
if (type === 'youtube') {
const id =
href.match(/(?:v=|youtu\.be\/|embed\/)([\w-]{6,})/)?.[1] ||
href.replace(/^https?:\/\//, '').split('/').pop() ||
href;
return { type, id };
}
if (type === 'netease') {
const id = href.match(/[?&]id=(\d+)/)?.[1] || href.match(/(\d{5,})/)?.[1] || href;
return { type, id };
}
if (type === 'douyin') {
const id = href.match(/(\d{8,})/)?.[1] || href.split('/').pop() || href;
return { type, id };
}
if (type === 'audio') {
return { type, src: href };
}
return { type, id: href };
}
export function isWidgetCodeLanguage(className?: string): boolean {
return /language-(?:tx-widget|widget)\b/i.test(className || '');
}

View File

@@ -0,0 +1,21 @@
export type WidgetType =
| 'bilibili'
| 'youtube'
| 'netease'
| 'douyin'
| 'audio'
| 'tabs'
| 'timeline'
| 'steps'
| 'cta'
| 'gallery';
export type WidgetPayload = {
type: WidgetType;
[key: string]: unknown;
};
export type TabItem = { title: string; content: string };
export type TimelineItem = { time?: string; title: string; content?: string };
export type StepItem = { title: string; content?: string };
export type GalleryItem = { src: string; alt?: string };

View File

@@ -0,0 +1,115 @@
# 文章小组件演示
这是一篇用于预览自定义小组件的模拟文章。管理端通过 `tx-widget` 代码块(或链接别名)插入,博客端会渲染成对应组件。
## 媒体嵌入
### Bilibili
```tx-widget
{
"type": "bilibili",
"bvid": "BV1GJ411x7h7"
}
```
### YouTube
```tx-widget
{
"type": "youtube",
"id": "dQw4w9WgXcQ"
}
```
### 网易云音乐
```tx-widget
{
"type": "netease",
"id": "1824045033"
}
```
### 抖音(链接别名写法)
[douyin-video](7234567890123456789)
### 音频播放器
```tx-widget
{
"type": "audio",
"title": "演示音频",
"artist": "ThriveX Demo",
"src": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
}
```
## 内容结构
### Tabs 切换
```tx-widget
{
"type": "tabs",
"items": [
{ "title": "React", "content": "适合组件化与生态丰富的 Web 应用。" },
{ "title": "Vue", "content": "上手快,模板语法友好,适合中后台。" },
{ "title": "Svelte", "content": "编译期优化,运行时更轻。" }
]
}
```
### 时间线
```tx-widget
{
"type": "timeline",
"items": [
{ "time": "2024", "title": "起步", "content": "搭建博客基础能力。" },
{ "time": "2025", "title": "增强", "content": "补齐主题、评论与缓存。" },
{ "time": "2026", "title": "小组件", "content": "文章内可嵌入可交互模块。" }
]
}
```
### 步骤条
```tx-widget
{
"type": "steps",
"items": [
{ "title": "写 Markdown", "content": "在正文中插入 tx-widget 语法。" },
{ "title": "管理端预览", "content": "发布前确认组件参数。" },
{ "title": "博客渲染", "content": "读者看到的是真实小组件。" }
]
}
```
## 画廊
```tx-widget
{
"type": "gallery",
"items": [
{ "src": "https://picsum.photos/seed/a1/800/600", "alt": "风景 1" },
{ "src": "https://picsum.photos/seed/a2/800/600", "alt": "风景 2" },
{ "src": "https://picsum.photos/seed/a3/800/600", "alt": "风景 3" }
]
}
```
## CTA
```tx-widget
{
"type": "cta",
"title": "想把 ThriveX 用到自己的博客?",
"description": "开箱即用的管理端 + 博客端,支持主题与内容扩展。",
"primaryText": "查看项目",
"primaryUrl": "https://github.com",
"secondaryText": "阅读文档",
"secondaryUrl": "https://liuyuyang.net"
}
```

View File

@@ -0,0 +1,50 @@
import type { Metadata } from 'next';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import MD from '@/app/article/components/MD';
import Summary from '@/app/article/components/Summary';
import ArticleTOC from '@/app/article/components/ArticleTOC';
import { extractArticleHeadings } from '@/utils/article';
export const metadata: Metadata = {
title: '文章小组件演示',
description: '预览 ThriveX 文章自定义小组件效果,便于决定保留哪些组件',
robots: {
index: false,
follow: false,
},
};
function loadDemoMarkdown() {
const filePath = path.join(process.cwd(), 'src/app/demo/widgets/content.md');
return readFileSync(filePath, 'utf-8');
}
export default function WidgetsDemoPage() {
const content = loadDemoMarkdown();
const headings = extractArticleHeadings(content);
return (
<main className="min-h-screen bg-[#f7f8fa] pb-20 pt-24 dark:bg-black-a">
<div className="mx-auto w-[92%] max-w-4xl">
<header className="mb-8 rounded-2xl border border-[#e8eef6] bg-white px-6 py-7 shadow-[0_10px_30px_rgba(83,157,253,0.08)] dark:border-[#3d4654] dark:bg-[#1b2230]">
<p className="mb-2 text-sm tracking-wide text-primary">DEMO</p>
<h1 className="text-2xl font-semibold text-[#1f2937] dark:text-slate-100 sm:text-3xl">
</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[#667085] dark:text-slate-400 sm:text-base">
Markdown
</p>
</header>
<div className="rounded-2xl border border-[#e8eef6] bg-white px-4 py-6 dark:border-[#3d4654] dark:bg-[#161b26] sm:px-8">
<ArticleTOC headings={headings}>
<Summary content="当前保留媒体嵌入、Tabs / 时间线 / 步骤、画廊、CTA。统一用 tx-widget 语法插入。" />
<MD data={content} headings={headings} />
</ArticleTOC>
</div>
</div>
</main>
);
}