diff --git a/src/app/article/components/MD/index.scss b/src/app/article/components/MD/index.scss
index e7b7c77..3cbda50 100755
--- a/src/app/article/components/MD/index.scss
+++ b/src/app/article/components/MD/index.scss
@@ -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)];
}
diff --git a/src/app/article/components/MD/index.tsx b/src/app/article/components/MD/index.tsx
index 05f45eb..3691c22 100755
--- a/src/app/article/components/MD/index.tsx
+++ b/src/app/article/components/MD/index.tsx
@@ -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({
{
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) => ,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
- if (children === 'douyin-video' && href) {
- const videoId = href.split('/').pop();
- return (
-
-
-
- );
+ 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 ;
+ }
}
return {children};
},
+ // 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 ;
+ }
+ return (
+
+ 小组件语法解析失败,请检查 JSON / 配置格式
+
+ );
+ }
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 ;
},
}),
- [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([]);
const photos = useMemo(
() => 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) => {
-
+
-
+
);
@@ -313,11 +344,16 @@ const ContentMD = ({ data, headings = [] }: Props) => {
return (
<>
-
+
diff --git a/src/app/article/components/MD/widgets/components.tsx b/src/app/article/components/MD/widgets/components.tsx
new file mode 100644
index 0000000..8b0f6e8
--- /dev/null
+++ b/src/app/article/components/MD/widgets/components.tsx
@@ -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(value: unknown): T[] {
+ return Array.isArray(value) ? (value as T[]) : [];
+}
+
+function cx(...parts: Array) {
+ return parts.filter(Boolean).join(' ');
+}
+
+function WidgetShell({
+ children,
+ className,
+ label,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ label?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+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 (
+
+
+
+
+
+ );
+}
+
+export function YoutubeWidget({ data }: { data: WidgetPayload }) {
+ const id = asString(data.id || data.videoId);
+ if (!id) return null;
+ return (
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+export function DouyinWidget({ data }: { data: WidgetPayload }) {
+ const id = asString(data.id || data.vid);
+ if (!id) return null;
+ return (
+
+
+
+
+
+ );
+}
+
+export function AudioWidget({ data }: { data: WidgetPayload }) {
+ const src = asString(data.src || data.url);
+ const title = asString(data.title, '音频');
+ if (!src) return null;
+ return (
+
+
+
+ {title}
+ {asString(data.artist) && {asString(data.artist)}}
+
+
+
+
+ );
+}
+
+export function TabsWidget({ data }: { data: WidgetPayload }) {
+ const items = asArray(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 (
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ {current?.content || ''}
+
+
+ );
+}
+
+export function TimelineWidget({ data }: { data: WidgetPayload }) {
+ const items = asArray(data.items);
+ if (!items.length) return null;
+ return (
+
+
+ {items.map((item, index) => (
+
+
+
+ {item.time &&
}
+
{item.title}
+ {item.content &&
{item.content}
}
+
+
+ ))}
+
+
+ );
+}
+
+export function StepsWidget({ data }: { data: WidgetPayload }) {
+ const items = asArray(data.items);
+ if (!items.length) return null;
+ return (
+
+
+ {items.map((item, index) => (
+
+
{index + 1}
+
+
{item.title}
+ {item.content &&
{item.content}
}
+
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
{title}
+ {description &&
{description}
}
+
+
+
+ );
+}
+
+export function GalleryWidget({
+ data,
+ onPreview,
+}: {
+ data: WidgetPayload;
+ onPreview?: (src: string, urls: string[]) => void;
+}) {
+ const items = asArray(data.items).filter((item) => item?.src);
+ const urls = items.map((item) => item.src);
+ if (!items.length) return null;
+
+ return (
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/article/components/MD/widgets/index.scss b/src/app/article/components/MD/widgets/index.scss
new file mode 100644
index 0000000..1d1a371
--- /dev/null
+++ b/src/app/article/components/MD/widgets/index.scss
@@ -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;
+ }
+}
diff --git a/src/app/article/components/MD/widgets/index.tsx b/src/app/article/components/MD/widgets/index.tsx
new file mode 100644
index 0000000..c5d6d77
--- /dev/null
+++ b/src/app/article/components/MD/widgets/index.tsx
@@ -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 ;
+ case 'youtube':
+ return ;
+ case 'netease':
+ return ;
+ case 'douyin':
+ return ;
+ case 'audio':
+ return ;
+ case 'tabs':
+ return ;
+ case 'timeline':
+ return ;
+ case 'steps':
+ return ;
+ case 'cta':
+ return ;
+ case 'gallery':
+ return ;
+ default:
+ return (
+
+ 未知小组件类型:{String((data as WidgetPayload).type)}
+
+ );
+ }
+}
diff --git a/src/app/article/components/MD/widgets/parse.ts b/src/app/article/components/MD/widgets/parse.ts
new file mode 100644
index 0000000..427d00a
--- /dev/null
+++ b/src/app/article/components/MD/widgets/parse.ts
@@ -0,0 +1,113 @@
+import type { WidgetPayload, WidgetType } from './types';
+
+const LINK_ALIAS: Record = {
+ 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 | null {
+ const lines = raw
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ if (!lines.length) return null;
+
+ const result: Record = {};
+ 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 || '');
+}
diff --git a/src/app/article/components/MD/widgets/types.ts b/src/app/article/components/MD/widgets/types.ts
new file mode 100644
index 0000000..48614bb
--- /dev/null
+++ b/src/app/article/components/MD/widgets/types.ts
@@ -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 };
diff --git a/src/app/demo/widgets/content.md b/src/app/demo/widgets/content.md
new file mode 100644
index 0000000..64df4b1
--- /dev/null
+++ b/src/app/demo/widgets/content.md
@@ -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"
+}
+```
\ No newline at end of file
diff --git a/src/app/demo/widgets/page.tsx b/src/app/demo/widgets/page.tsx
new file mode 100644
index 0000000..653259c
--- /dev/null
+++ b/src/app/demo/widgets/page.tsx
@@ -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 (
+
+
+
+ );
+}