feat(web): 文章编辑器重设计 — 公众号风格三栏布局 + styled-block 自定义模块

- 左栏样式组件库(标题/内容/区块 14 种模板,5 种配色主题)
- 中间 Notion 风格编辑区(标题置顶 + wangEditor + 自定义 styled-block)
- 右栏 iPhone 仿真预览(匹配小程序暖奶油配色)
- 设置面板移至 Drawer 抽屉按需打开
- 注册 wangEditor 自定义模块保留模板内联样式
- 使用 snabbdom VNode + insertNode API 解决样式被剥离问题
This commit is contained in:
iven
2026-05-11 02:18:24 +08:00
parent 4788e19a1d
commit f4b09858c4
11 changed files with 1362 additions and 558 deletions

View File

@@ -27,6 +27,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"snabbdom": "^3.6.3",
"zustand": "^5.0.12"
},
"devDependencies": {

View File

@@ -47,6 +47,9 @@ importers:
react-router-dom:
specifier: ^7.14.0
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
snabbdom:
specifier: ^3.6.3
version: 3.6.3
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(immer@9.0.21)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))

View File

@@ -68,9 +68,11 @@ const ConsentList = lazy(() => import('./pages/health/ConsentList'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
const ArticleEditor = lazy(() => import('./pages/health/ArticleEditor'));
const ArticleEditor = lazy(() => import('./pages/health/articleEditor/ArticleEditor'));
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
const BannerManage = lazy(() => import('./pages/health/BannerManage'));
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
@@ -326,6 +328,8 @@ export default function App() {
<Route path="/health/articles/:id/edit" element={<ArticleEditor />} />
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
<Route path="/health/article-tags" element={<ArticleTagManage />} />
<Route path="/health/banners" element={<BannerManage />} />
<Route path="/health/media-library" element={<MediaLibrary />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -1,557 +0,0 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined, PictureOutlined } from '@ant-design/icons';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import {
articleApi,
articleCategoryApi,
articleTagApi,
type Article,
type ArticleTagItem,
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import MediaPicker from '../../components/MediaPicker';
import { handleApiError } from '../../api/client';
import { uploadFile } from '../../api/upload';
import '@wangeditor/editor/dist/css/style.css';
export default function ArticleEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
const isDark = useThemeMode();
// 表单状态
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [content, setContent] = useState('');
const [coverImage, setCoverImage] = useState('');
const [slug, setSlug] = useState('');
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState(0);
const [version, setVersion] = useState(0);
// 选项数据
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<ArticleTagItem[]>([]);
// UI 状态
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editor, setEditor] = useState<IDomEditor | null>(null);
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
// 加载分类和标签
useEffect(() => {
const fetchOptions = async () => {
try {
const [cats, tagList] = await Promise.all([
articleCategoryApi.list(),
articleTagApi.list(),
]);
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
setTags(tagList);
} catch {
// 选项加载失败不阻塞
}
};
fetchOptions();
}, []);
// 编辑模式:加载现有文章
useEffect(() => {
if (!id) return;
const fetchArticle = async () => {
setLoading(true);
try {
const article: Article = await articleApi.get(id);
setTitle(article.title);
setSummary(article.summary || '');
setContent(article.content || '');
setCoverImage(article.cover_image || '');
setSlug(article.slug || '');
setCategoryId(article.category_id);
setSelectedTagIds(article.tags?.map((t) => t.id) || []);
setSortOrder(article.sort_order);
setVersion(article.version);
} catch {
message.error('加载文章失败');
navigate('/health/articles');
} finally {
setLoading(false);
}
};
fetchArticle();
}, [id, navigate]);
// 编辑器配置
const toolbarConfig = useMemo<Partial<IToolbarConfig>>(
() => ({
excludeKeys: [
'group-video',
'insertLink',
'editLink',
'unLink',
'viewLink',
'codeView',
],
}),
[],
);
const editorConfig = useMemo<Partial<IEditorConfig>>(
() => ({
placeholder: '请输入文章内容...',
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
try {
const formData = new FormData();
formData.append('file', file);
const result = await uploadFile(file);
const url: string = result.url;
const token = localStorage.getItem('access_token');
const urlWithToken = token ? `${url}?token=${token}` : url;
insertFn(urlWithToken, file.name, urlWithToken);
} catch {
message.error('图片上传失败');
}
},
},
},
}),
[],
);
// 及时销毁编辑器
useEffect(() => {
return () => {
if (editor) {
editor.destroy();
setEditor(null);
}
};
}, [editor]);
const handleSave = useCallback(async () => {
if (!title.trim()) {
message.warning('请输入文章标题');
return;
}
setSaving(true);
try {
if (isEdit && id) {
await articleApi.update(id, {
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
version,
});
message.success('文章已保存');
// 重新加载以获取新 version
const updated = await articleApi.get(id);
setVersion(updated.version);
} else {
await articleApi.create({
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
});
message.success('文章已创建');
// 返回列表页,避免 WangEditor 全局 toolbar 注册导致同组件内重复初始化
navigate('/health/articles');
}
} catch (err: unknown) {
handleApiError(err, '保存失败');
} finally {
setSaving(false);
}
}, [
id, isEdit, title, summary, content, coverImage, slug, categoryId,
selectedTagIds, sortOrder, version, navigate,
]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
message.warning('请输入文章标题');
return;
}
setSaving(true);
try {
// 先保存
let currentVersion = version;
if (isEdit && id) {
await articleApi.update(id, {
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
version,
});
const updated = await articleApi.get(id);
currentVersion = updated.version;
setVersion(updated.version);
} else {
const _created = await articleApi.create({
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
});
currentVersion = _created.version;
setVersion(_created.version);
await articleApi.submit(_created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
}
// 编辑模式提交审核
if (id) {
await articleApi.submit(id, currentVersion);
}
message.success('已提交审核');
navigate('/health/articles');
} catch (err: unknown) {
handleApiError(err, '提交审核失败');
} finally {
setSaving(false);
}
}, [
id, isEdit, title, summary, content, coverImage, slug, categoryId,
selectedTagIds, sortOrder, version, navigate,
]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
return (
<div>
{/* 页面标题栏 */}
<div className="erp-page-header">
<Space size={12}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/health/articles')}
/>
<div>
<h4 style={{ margin: 0 }}>{isEdit ? '编辑文章' : '新建文章'}</h4>
</div>
</Space>
<Space size={8}>
<AuthButton code="health.articles.manage">
<Button
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
稿
</Button>
</AuthButton>
<AuthButton code="health.articles.manage">
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={saving}
>
</Button>
</AuthButton>
</Space>
</div>
{/* 主体布局: 左侧编辑区 + 右侧设置面板 */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
{/* 左侧: 富文本编辑器 — key 变化时强制重新挂载,防止 WangEditor toolbar 重复创建 */}
<div
key={id ?? 'new'}
style={{
flex: 1,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<div
style={{
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
zIndex: 100,
}}
>
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode="default"
style={{
background: isDark ? '#0f172a' : '#f8fafc',
borderBottom: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
}}
/>
</div>
<div style={{ height: 600, overflowY: 'auto' }}>
<Editor
defaultConfig={editorConfig}
value={content}
onCreated={setEditor}
onChange={(editorInstance) => setContent(editorInstance.getHtml())}
mode="default"
style={{
minHeight: 500,
background: isDark ? '#111827' : '#FFFFFF',
color: isDark ? '#e2e8f0' : '#1e293b',
}}
/>
</div>
</div>
{/* 右侧: 设置面板 */}
<div
style={{
width: 320,
flexShrink: 0,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* 标题 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
<span style={{ color: '#dc2626' }}>*</span>
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="请输入文章标题"
maxLength={200}
showCount
/>
</div>
{/* 分类 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
</label>
<Select
value={categoryId}
onChange={setCategoryId}
placeholder="选择分类"
allowClear
style={{ width: '100%' }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</div>
{/* 标签 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
</label>
<Select
mode="multiple"
value={selectedTagIds}
onChange={setSelectedTagIds}
placeholder="选择标签"
allowClear
style={{ width: '100%' }}
options={tags.map((t) => ({ label: t.name, value: t.id }))}
maxTagCount={5}
/>
</div>
{/* 摘要 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
</label>
<Input.TextArea
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="请输入文章摘要"
rows={3}
maxLength={500}
showCount
/>
</div>
{/* 封面图 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
</label>
<Space.Compact style={{ width: '100%' }}>
<Input
value={coverImage}
onChange={(e) => setCoverImage(e.target.value)}
placeholder="请输入封面图片 URL 或上传文件"
style={{ flex: 1 }}
/>
<Button icon={<PictureOutlined />} onClick={() => setMediaPickerOpen(true)}>
</Button>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={async (file) => {
try {
const result = await uploadFile(file);
setCoverImage(result.url);
message.success('封面图上传成功');
} catch {
message.error('封面图上传失败');
}
return false;
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Space.Compact>
{coverImage && (
<div
style={{
marginTop: 8,
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
}}
>
<img
src={coverImage}
alt="封面预览"
style={{ width: '100%', height: 120, objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
{/* Slug */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
URL (Slug)
</label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="例如: health-tips-for-elderly"
/>
</div>
{/* 排序 */}
<div>
<label
style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
}}
>
</label>
<Input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</div>
<MediaPicker
open={mediaPickerOpen}
onClose={() => setMediaPickerOpen(false)}
onSelect={(url) => {
setCoverImage(url);
message.success('已选择封面图');
}}
/>
</div>
);
}

View File

@@ -0,0 +1,470 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Input, Space, message, Spin } from 'antd';
import {
ArrowLeftOutlined,
SaveOutlined,
SendOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { Editor } from '@wangeditor/editor-for-react';
import { createToolbar } from '@wangeditor/editor';
import type { IDomEditor, IEditorConfig, IToolbarConfig, Toolbar as ToolbarType } from '@wangeditor/editor';
import {
articleApi,
articleCategoryApi,
articleTagApi,
type Article,
type ArticleTagItem,
} from '../../../api/health/articles';
import { useThemeMode } from '../../../hooks/useThemeMode';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
import { uploadFile } from '../../../api/upload';
import ArticleStyleLibrary from './ArticleStyleLibrary';
import ArticlePhonePreview from './ArticlePhonePreview';
import ArticleSettingsDrawer from './ArticleSettingsDrawer';
import { registerStyledBlockPlugin } from './styledBlockPlugin';
import '@wangeditor/editor/dist/css/style.css';
// 在任何编辑器实例创建之前注册自定义模块
registerStyledBlockPlugin();
export default function ArticleEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
const isDark = useThemeMode();
// 表单状态
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [content, setContent] = useState('');
const [coverImage, setCoverImage] = useState('');
const [slug, setSlug] = useState('');
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState(0);
const [version, setVersion] = useState(0);
// 选项数据
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<ArticleTagItem[]>([]);
// UI 状态
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editor, setEditor] = useState<IDomEditor | null>(null);
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
const [selectedThemeId, setSelectedThemeId] = useState('red');
const [previewContent, setPreviewContent] = useState('');
// 命令式 toolbar
const toolbarContainerRef = useRef<HTMLDivElement>(null);
const toolbarInstanceRef = useRef<ToolbarType | null>(null);
// 防抖预览内容
useEffect(() => {
const timer = setTimeout(() => setPreviewContent(content), 300);
return () => clearTimeout(timer);
}, [content]);
// 加载分类和标签
useEffect(() => {
const fetchOptions = async () => {
try {
const [cats, tagList] = await Promise.all([
articleCategoryApi.list(),
articleTagApi.list(),
]);
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
setTags(tagList);
} catch {
// 选项加载失败不阻塞
}
};
fetchOptions();
}, []);
// 编辑模式:加载现有文章
useEffect(() => {
if (!id) return;
const fetchArticle = async () => {
setLoading(true);
try {
const article: Article = await articleApi.get(id);
setTitle(article.title);
setSummary(article.summary || '');
setContent(article.content || '');
setCoverImage(article.cover_image || '');
setSlug(article.slug || '');
setCategoryId(article.category_id);
setSelectedTagIds(article.tags?.map((t) => t.id) || []);
setSortOrder(article.sort_order);
setVersion(article.version);
} catch {
message.error('加载文章失败');
navigate('/health/articles');
} finally {
setLoading(false);
}
};
fetchArticle();
}, [id, navigate]);
// 编辑器配置
const toolbarConfig = useMemo<Partial<IToolbarConfig>>(
() => ({
excludeKeys: [
'group-video',
'insertLink',
'editLink',
'unLink',
'viewLink',
'codeView',
],
}),
[],
);
const editorConfig = useMemo<Partial<IEditorConfig>>(
() => ({
placeholder: '请输入文章内容...',
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
try {
const result = await uploadFile(file);
const url: string = result.url;
const token = localStorage.getItem('access_token');
const urlWithToken = token ? `${url}?token=${token}` : url;
insertFn(urlWithToken, file.name, urlWithToken);
} catch {
message.error('图片上传失败');
}
},
},
},
}),
[],
);
// toolbar 生命周期
useEffect(() => {
if (!editor || !toolbarContainerRef.current) return;
if (toolbarInstanceRef.current) {
toolbarInstanceRef.current.destroy();
toolbarInstanceRef.current = null;
}
toolbarInstanceRef.current = createToolbar({
editor,
selector: toolbarContainerRef.current,
config: toolbarConfig,
mode: 'default',
});
return () => {
if (toolbarInstanceRef.current) {
toolbarInstanceRef.current.destroy();
toolbarInstanceRef.current = null;
}
};
}, [editor, toolbarConfig]);
useEffect(() => {
return () => {
if (toolbarInstanceRef.current) {
toolbarInstanceRef.current.destroy();
toolbarInstanceRef.current = null;
}
};
}, []);
// 插入模板
const handleInsertTemplate = useCallback(
(html: string) => {
if (!editor) {
message.warning('请先点击编辑器获取焦点');
return;
}
editor.restoreSelection();
// 解析模板 HTML 提取 style 和 innerHtml
const temp = document.createElement('div');
temp.innerHTML = html;
const el = temp.firstElementChild as HTMLElement | null;
try {
if (el && el.getAttribute('data-w-e-type') === 'styled-block') {
editor.insertNode({
type: 'styled-block',
style: el.getAttribute('style') || '',
innerHtml: el.innerHTML,
children: [{ text: '' }],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} else {
editor.dangerouslyInsertHtml(html);
}
} catch (err) {
console.error('[styled-block insert error]', err);
editor.dangerouslyInsertHtml(html);
}
},
[editor],
);
const handleSave = useCallback(async () => {
if (!title.trim()) {
message.warning('请输入文章标题');
return;
}
setSaving(true);
try {
if (isEdit && id) {
await articleApi.update(id, {
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
version,
});
message.success('文章已保存');
const updated = await articleApi.get(id);
setVersion(updated.version);
} else {
await articleApi.create({
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
});
message.success('文章已创建');
navigate('/health/articles');
}
} catch (err: unknown) {
handleApiError(err, '保存失败');
} finally {
setSaving(false);
}
}, [
id, isEdit, title, summary, content, coverImage, slug, categoryId,
selectedTagIds, sortOrder, version, navigate,
]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
message.warning('请输入文章标题');
return;
}
setSaving(true);
try {
let currentVersion = version;
if (isEdit && id) {
await articleApi.update(id, {
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
version,
});
const updated = await articleApi.get(id);
currentVersion = updated.version;
setVersion(updated.version);
} else {
const created = await articleApi.create({
title,
summary: summary || undefined,
content,
cover_image: coverImage || undefined,
slug: slug || undefined,
category_id: categoryId,
tag_ids: selectedTagIds,
sort_order: sortOrder,
});
currentVersion = created.version;
setVersion(created.version);
await articleApi.submit(created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
}
if (id) {
await articleApi.submit(id, currentVersion);
}
message.success('已提交审核');
navigate('/health/articles');
} catch (err: unknown) {
handleApiError(err, '提交审核失败');
} finally {
setSaving(false);
}
}, [
id, isEdit, title, summary, content, coverImage, slug, categoryId,
selectedTagIds, sortOrder, version, navigate,
]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
const borderColor = isDark ? '#1e293b' : '#f1f5f9';
const bgPrimary = isDark ? '#111827' : '#FFFFFF';
const bgSecondary = isDark ? '#0f172a' : '#f8fafc';
return (
<div>
{/* 页面标题栏 */}
<div className="erp-page-header">
<Space size={12}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/health/articles')}
/>
<h4 style={{ margin: 0 }}>{isEdit ? '编辑文章' : '新建文章'}</h4>
</Space>
<Space size={8}>
<Button
icon={<SettingOutlined />}
onClick={() => setSettingsDrawerOpen(true)}
>
</Button>
<AuthButton code="health.articles.manage">
<Button icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
稿
</Button>
</AuthButton>
<AuthButton code="health.articles.manage">
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={saving}
>
</Button>
</AuthButton>
</Space>
</div>
{/* 三栏布局 */}
<div style={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 0 }}>
{/* 左栏:样式组件库 */}
<ArticleStyleLibrary
isDark={isDark}
onInsertTemplate={handleInsertTemplate}
selectedTheme={selectedThemeId}
onThemeChange={setSelectedThemeId}
/>
{/* 中间:标题 + 工具栏 + 编辑器 */}
<div
key={id ?? 'new'}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: bgPrimary,
overflow: 'hidden',
}}
>
{/* 标题输入 — Notion 风格大字 */}
<div
style={{
padding: '16px 24px 8px',
borderBottom: `1px solid ${borderColor}`,
}}
>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="请输入文章标题..."
variant="borderless"
maxLength={200}
style={{
fontSize: 28,
fontWeight: 700,
color: isDark ? '#e2e8f0' : '#1e293b',
padding: 0,
lineHeight: 1.4,
}}
/>
</div>
{/* 工具栏 */}
<div
ref={toolbarContainerRef}
style={{
borderBottom: `1px solid ${borderColor}`,
background: bgSecondary,
zIndex: 100,
}}
/>
{/* 编辑器 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<Editor
defaultConfig={editorConfig}
value={content}
onCreated={setEditor}
onChange={(editorInstance) => setContent(editorInstance.getHtml())}
mode="default"
style={{
minHeight: 400,
background: bgPrimary,
color: isDark ? '#e2e8f0' : '#1e293b',
}}
/>
</div>
</div>
{/* 右栏:手机预览 */}
<ArticlePhonePreview
title={title}
content={previewContent}
category={categories.find((c) => c.id === categoryId)?.name}
summary={summary}
coverImage={coverImage}
isDark={isDark}
/>
</div>
{/* 设置抽屉 */}
<ArticleSettingsDrawer
open={settingsDrawerOpen}
onClose={() => setSettingsDrawerOpen(false)}
isDark={isDark}
summary={summary}
onSummaryChange={setSummary}
coverImage={coverImage}
onCoverImageChange={setCoverImage}
categoryId={categoryId}
onCategoryChange={setCategoryId}
selectedTagIds={selectedTagIds}
onTagIdsChange={setSelectedTagIds}
slug={slug}
onSlugChange={setSlug}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
categories={categories}
tags={tags}
/>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import { useMemo } from 'react';
interface ArticlePhonePreviewProps {
title: string;
content: string;
category?: string;
summary?: string;
coverImage?: string;
isDark: boolean;
}
/**
* 手机端实时预览组件
* 模拟微信小程序文章阅读效果,匹配小程序 article/detail 页面样式。
* 预览内容始终亮色(不跟暗黑模式),外框背景跟随主题。
*/
export default function ArticlePhonePreview({
title,
content,
category,
summary,
coverImage,
isDark,
}: ArticlePhonePreviewProps) {
// 小程序 design tokens
const mpColors = useMemo(
() => ({
bg: '#F5F0EB',
primary: '#C4623A',
primaryLight: '#F0DDD4',
text: '#2D2A26',
textSecondary: '#5A554F',
card: '#FFFFFF',
}),
[],
);
const today = useMemo(() => new Date().toLocaleDateString('zh-CN'), []);
return (
<div
style={{
width: 400,
flexShrink: 0,
background: isDark ? '#0f172a' : '#f0f2f5',
borderLeft: `1px solid ${isDark ? '#1e293b' : '#e5e5ea'}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '16px 0',
overflow: 'auto',
}}
>
<div
style={{
fontSize: 12,
color: isDark ? '#64748b' : '#86868b',
marginBottom: 12,
fontWeight: 500,
}}
>
📱 ·
</div>
{/* iPhone 外壳 */}
<div
style={{
width: 375,
height: 680,
borderRadius: 40,
border: '4px solid #1a1a1a',
background: '#000',
position: 'relative',
overflow: 'hidden',
boxShadow: '0 8px 40px rgba(0,0,0,0.15)',
}}
>
{/* 灵动岛 */}
<div
style={{
position: 'absolute',
top: 8,
left: '50%',
transform: 'translateX(-50%)',
width: 120,
height: 28,
background: '#000',
borderRadius: 14,
zIndex: 20,
}}
/>
{/* 状态栏 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 28px',
fontSize: 13,
fontWeight: 600,
color: '#fff',
zIndex: 10,
}}
>
<span>9:41</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<svg width="14" height="10" viewBox="0 0 16 12" fill="none">
<path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#fff" />
<path
d="M3 7.5a7 7 0 0110 0"
stroke="#fff"
strokeWidth="1.3"
fill="none"
strokeLinecap="round"
/>
</svg>
<div
style={{
width: 20,
height: 9,
border: '1px solid #fff',
borderRadius: 2,
padding: 1,
}}
>
<div
style={{
width: '80%',
height: '100%',
background: '#fff',
borderRadius: 1,
}}
/>
</div>
</div>
</div>
{/* 内容区 */}
<div
style={{
position: 'absolute',
top: 48,
left: 0,
right: 0,
bottom: 24,
overflowY: 'auto',
background: mpColors.bg,
borderRadius: '0 0 36px 36px',
}}
>
{/* 作用域样式 — 匹配小程序 article/detail/index.scss */}
<style>{`
.mp-preview { background: ${mpColors.bg}; padding-bottom: 32px; }
.mp-preview .mp-header { background: ${mpColors.card}; padding: 24px 20px; margin-bottom: 2px; }
.mp-preview .mp-title { font-size: 22px; font-weight: 700; color: ${mpColors.text}; line-height: 1.4; margin-bottom: 12px; display: block; }
.mp-preview .mp-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.mp-preview .mp-category { font-size: 13px; color: ${mpColors.primary}; background: ${mpColors.primaryLight}; padding: 3px 10px; border-radius: 12px; }
.mp-preview .mp-author { font-size: 13px; color: ${mpColors.textSecondary}; }
.mp-preview .mp-date { font-size: 12px; color: #9ca3af; }
.mp-preview .mp-cover { width: 100%; border-radius: 8px; margin: 0 0 12px; max-height: 160px; object-fit: cover; }
.mp-preview .mp-summary { background: ${mpColors.card}; padding: 18px 20px; margin-bottom: 2px; }
.mp-preview .mp-summary-text { font-size: 14px; color: ${mpColors.textSecondary}; line-height: 1.7; border-left: 3px solid ${mpColors.primary}; padding-left: 12px; }
.mp-preview .mp-content { background: ${mpColors.card}; padding: 20px; }
.mp-preview .mp-content p { font-size: 16px; color: ${mpColors.text}; line-height: 1.8; margin-bottom: 14px; }
.mp-preview .mp-content h1, .mp-preview .mp-content h2, .mp-preview .mp-content h3 { font-weight: 700; color: ${mpColors.text}; margin: 20px 0 10px; }
.mp-preview .mp-content h1 { font-size: 20px; }
.mp-preview .mp-content h2 { font-size: 18px; }
.mp-preview .mp-content h3 { font-size: 16px; }
.mp-preview .mp-content img { max-width: 100%; border-radius: 8px; margin: 10px 0; }
.mp-preview .mp-content blockquote { border-left: 3px solid ${mpColors.primary}; padding: 8px 12px; color: ${mpColors.textSecondary}; margin: 12px 0; }
.mp-preview .mp-content ul, .mp-preview .mp-content ol { padding-left: 20px; margin: 12px 0; font-size: 15px; line-height: 2; color: ${mpColors.text}; }
.mp-preview .mp-content table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 14px; }
.mp-preview .mp-content td, .mp-preview .mp-content th { border: 1px solid #e5e7eb; padding: 8px 10px; }
.mp-preview .mp-content hr { border: none; border-top: 1px dashed #d1d5db; margin: 16px 0; }
.mp-preview .mp-empty { padding: 60px 20px; text-align: center; color: #9ca3af; font-size: 14px; }
`}</style>
<div className="mp-preview">
{/* 头部:标题+元信息 */}
<div className="mp-header">
{coverImage && (
<img
className="mp-cover"
src={coverImage}
alt="封面"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="mp-title">{title || '文章标题'}</span>
<div className="mp-meta">
{category && <span className="mp-category">{category}</span>}
<span className="mp-author"></span>
<span className="mp-date">{today}</span>
</div>
</div>
{/* 摘要 */}
{summary && (
<div className="mp-summary">
<div className="mp-summary-text">{summary}</div>
</div>
)}
{/* 正文内容 */}
<div className="mp-content">
{content && content !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: content }} />
) : (
<div className="mp-empty">...</div>
)}
</div>
</div>
</div>
{/* Home Indicator */}
<div
style={{
position: 'absolute',
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
width: 120,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 999,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useState } from 'react';
import { Drawer, Input, Select, Space, Upload, Button, message } from 'antd';
import { UploadOutlined, PictureOutlined } from '@ant-design/icons';
import type { ArticleTagItem } from '../../../api/health/articles';
import { uploadFile } from '../../../api/upload';
import MediaPicker from '../../../components/MediaPicker';
interface ArticleSettingsDrawerProps {
open: boolean;
onClose: () => void;
isDark: boolean;
summary: string;
onSummaryChange: (v: string) => void;
coverImage: string;
onCoverImageChange: (v: string) => void;
categoryId: string | undefined;
onCategoryChange: (v: string | undefined) => void;
selectedTagIds: string[];
onTagIdsChange: (v: string[]) => void;
slug: string;
onSlugChange: (v: string) => void;
sortOrder: number;
onSortOrderChange: (v: number) => void;
categories: { id: string; name: string }[];
tags: ArticleTagItem[];
}
const labelStyle = (isDark: boolean) => ({
display: 'block',
fontSize: 13,
fontWeight: 500 as const,
marginBottom: 6,
color: isDark ? '#94a3b8' : '#475569',
});
/**
* 文章设置抽屉
* 从原右侧面板迁移包含分类、标签、摘要、封面图、Slug、排序等设置。
*/
export default function ArticleSettingsDrawer({
open,
onClose,
isDark,
summary,
onSummaryChange,
coverImage,
onCoverImageChange,
categoryId,
onCategoryChange,
selectedTagIds,
onTagIdsChange,
slug,
onSlugChange,
sortOrder,
onSortOrderChange,
categories,
tags,
}: ArticleSettingsDrawerProps) {
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
return (
<>
<Drawer
title="文章设置"
placement="right"
width={360}
open={open}
onClose={onClose}
styles={{
body: { padding: '16px 20px' },
header: {
background: isDark ? '#0f172a' : undefined,
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f0f0f0'}`,
},
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 分类 */}
<div>
<label style={labelStyle(isDark)}></label>
<Select
value={categoryId}
onChange={onCategoryChange}
placeholder="选择分类"
allowClear
style={{ width: '100%' }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</div>
{/* 标签 */}
<div>
<label style={labelStyle(isDark)}></label>
<Select
mode="multiple"
value={selectedTagIds}
onChange={onTagIdsChange}
placeholder="选择标签"
allowClear
style={{ width: '100%' }}
options={tags.map((t) => ({ label: t.name, value: t.id }))}
maxTagCount={5}
/>
</div>
{/* 摘要 */}
<div>
<label style={labelStyle(isDark)}></label>
<Input.TextArea
value={summary}
onChange={(e) => onSummaryChange(e.target.value)}
placeholder="请输入文章摘要"
rows={3}
maxLength={500}
showCount
/>
</div>
{/* 封面图 */}
<div>
<label style={labelStyle(isDark)}></label>
<Space.Compact style={{ width: '100%' }}>
<Input
value={coverImage}
onChange={(e) => onCoverImageChange(e.target.value)}
placeholder="输入 URL 或上传文件"
style={{ flex: 1 }}
/>
<Button
icon={<PictureOutlined />}
onClick={() => setMediaPickerOpen(true)}
>
</Button>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={async (file) => {
try {
const result = await uploadFile(file);
onCoverImageChange(result.url);
message.success('封面图上传成功');
} catch {
message.error('封面图上传失败');
}
return false;
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Space.Compact>
{coverImage && (
<div
style={{
marginTop: 8,
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
}}
>
<img
src={coverImage}
alt="封面预览"
style={{ width: '100%', height: 120, objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
{/* Slug */}
<div>
<label style={labelStyle(isDark)}>URL (Slug)</label>
<Input
value={slug}
onChange={(e) => onSlugChange(e.target.value)}
placeholder="例如: health-tips-for-elderly"
/>
</div>
{/* 排序 */}
<div>
<label style={labelStyle(isDark)}></label>
<Input
type="number"
value={sortOrder}
onChange={(e) => onSortOrderChange(Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</Drawer>
<MediaPicker
open={mediaPickerOpen}
onClose={() => setMediaPickerOpen(false)}
onSelect={(url) => {
onCoverImageChange(url);
message.success('已选择封面图');
}}
/>
</>
);
}

View File

@@ -0,0 +1,203 @@
import { useState, useMemo } from 'react';
import { Input } from 'antd';
import {
ALL_TEMPLATES,
COLOR_THEMES,
applyTheme,
getColorTheme,
} from './articleTemplates';
interface ArticleStyleLibraryProps {
isDark: boolean;
onInsertTemplate: (html: string) => void;
selectedTheme: string;
onThemeChange: (themeId: string) => void;
}
const sectionLabel = (text: string, isDark: boolean) => ({
fontSize: 12,
fontWeight: 600,
color: isDark ? '#64748b' : '#86868b',
padding: '12px 14px 6px',
textTransform: 'uppercase' as const,
letterSpacing: 0.5,
});
/**
* 左栏样式组件库
* 提供标题样式、内容模板、区块组件和配色方案。
*/
export default function ArticleStyleLibrary({
isDark,
onInsertTemplate,
selectedTheme,
onThemeChange,
}: ArticleStyleLibraryProps) {
const [searchKeyword, setSearchKeyword] = useState('');
const theme = useMemo(() => getColorTheme(selectedTheme), [selectedTheme]);
const filteredTemplates = useMemo(() => {
if (!searchKeyword.trim()) return ALL_TEMPLATES;
const kw = searchKeyword.toLowerCase();
return ALL_TEMPLATES.filter(
(t) =>
t.name.toLowerCase().includes(kw) ||
t.category.toLowerCase().includes(kw),
);
}, [searchKeyword]);
const handleInsert = (template: typeof ALL_TEMPLATES[number]) => {
const html = applyTheme(template.html, theme);
onInsertTemplate(html);
};
const headingFiltered = filteredTemplates.filter(
(t) => t.category === 'heading',
);
const contentFiltered = filteredTemplates.filter(
(t) => t.category === 'content',
);
const blockFiltered = filteredTemplates.filter(
(t) => t.category === 'block',
);
const cardBg = isDark ? '#1e293b' : '#fff';
const cardBorder = isDark ? '#334155' : '#e5e5ea';
const panelBg = isDark ? '#0f172a' : '#fafafa';
const renderSection = (
label: string,
templates: typeof ALL_TEMPLATES,
) => {
if (templates.length === 0) return null;
return (
<>
<div style={sectionLabel(label, isDark)}>{label}</div>
{templates.map((t) => (
<div
key={t.id}
onClick={() => handleInsert(t)}
style={{
margin: '0 12px 8px',
padding: 12,
background: cardBg,
borderRadius: 8,
border: `1px solid ${cardBorder}`,
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = theme.primary;
e.currentTarget.style.boxShadow = `0 0 0 1px ${theme.primary}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = cardBorder;
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: isDark ? '#e2e8f0' : '#1d1d1f',
marginBottom: 2,
}}
>
{t.name}
</div>
<div
style={{
fontSize: 12,
color: isDark ? '#94a3b8' : '#6e6e73',
background: isDark ? '#1e293b' : '#f5f5f7',
padding: '5px 8px',
borderRadius: 4,
lineHeight: 1.4,
marginTop: 4,
}}
>
{t.preview}
</div>
</div>
))}
</>
);
};
return (
<div
style={{
width: 240,
flexShrink: 0,
background: panelBg,
borderRight: `1px solid ${isDark ? '#1e293b' : '#e5e5ea'}`,
overflow: 'auto',
height: '100%',
}}
>
{/* 搜索框 */}
<div style={{ padding: '10px 10px 6px' }}>
<Input
size="small"
placeholder="搜索样式模板..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
allowClear
style={{ borderRadius: 8 }}
/>
</div>
{/* 配色方案 */}
<div style={sectionLabel('配色方案', isDark)}></div>
<div
style={{
display: 'flex',
gap: 8,
padding: '0 14px',
marginBottom: 4,
}}
>
{COLOR_THEMES.map((c) => (
<div
key={c.id}
title={c.name}
onClick={() => onThemeChange(c.id)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c.primary,
cursor: 'pointer',
border:
selectedTheme === c.id
? `2px solid ${isDark ? '#fff' : '#1d1d1f'}`
: '2px solid transparent',
transition: 'border-color 0.15s, transform 0.1s',
transform: selectedTheme === c.id ? 'scale(1.15)' : 'scale(1)',
}}
/>
))}
</div>
{/* 模板列表 */}
{renderSection('标题样式', headingFiltered)}
{renderSection('内容模板', contentFiltered)}
{renderSection('区块组件', blockFiltered)}
{/* 无结果 */}
{filteredTemplates.length === 0 && (
<div
style={{
padding: '24px 14px',
textAlign: 'center',
color: isDark ? '#64748b' : '#86868b',
fontSize: 13,
}}
>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
/**
* 文章编辑器样式模板数据
* 所有 HTML 片段使用内联样式,通过 wangEditor 自定义 styled-block 模块保留样式。
* 模板中使用 {{primary}} / {{primaryLight}} 占位符,由 applyTheme() 替换为实际颜色值。
*/
export interface StyleTemplate {
id: string;
name: string;
category: 'heading' | 'content' | 'block';
preview: string;
html: string;
}
export interface ColorTheme {
id: string;
name: string;
primary: string;
primaryLight: string;
}
export const COLOR_THEMES: ColorTheme[] = [
{ id: 'green', name: '清新绿', primary: '#16a34a', primaryLight: '#dcfce7' },
{ id: 'blue', name: '专业蓝', primary: '#2563eb', primaryLight: '#dbeafe' },
{ id: 'red', name: '暖橘红', primary: '#C4623A', primaryLight: '#F0DDD4' },
{ id: 'purple', name: '雅致紫', primary: '#7c3aed', primaryLight: '#ede9fe' },
{ id: 'amber', name: '琥珀金', primary: '#d97706', primaryLight: '#fef3c7' },
];
const W = 'data-w-e-type="styled-block"';
export const HEADING_TEMPLATES: StyleTemplate[] = [
{
id: 'heading-classic',
name: '经典',
category: 'heading',
preview: '▎左边框标题',
html: `<div ${W} style="border-left: 4px solid {{primary}}; padding-left: 12px; font-size: 20px; font-weight: 700; color: #1a1a1a; margin: 24px 0 12px;">标题文本</div>`,
},
{
id: 'heading-simple',
name: '简约',
category: 'heading',
preview: '下划线标题 ──',
html: `<div ${W} style="border-bottom: 2px solid {{primary}}; padding-bottom: 8px; font-size: 20px; font-weight: 700; color: #1a1a1a; margin: 24px 0 12px; display: inline-block;">标题文本</div>`,
},
{
id: 'heading-rounded',
name: '圆标',
category: 'heading',
preview: '■ 标签式标题',
html: `<div ${W} style="display: inline-block; background: {{primaryLight}}; color: {{primary}}; padding: 4px 14px; border-radius: 4px; font-size: 18px; font-weight: 700; margin: 24px 0 12px;">标题文本</div>`,
},
{
id: 'heading-centered',
name: '居中',
category: 'heading',
preview: '居中标题',
html: `<div ${W} style="text-align: center; font-size: 20px; font-weight: 700; color: #1a1a1a; margin: 28px 0 12px;">标题文本</div>`,
},
];
export const CONTENT_TEMPLATES: StyleTemplate[] = [
{
id: 'blockquote',
name: '引用框',
category: 'content',
preview: '▎引用文字...',
html: `<div ${W} style="border-left: 3px solid {{primary}}; padding: 12px 16px; margin: 16px 0; background: #f9fafb; border-radius: 0 8px 8px 0; font-size: 15px; line-height: 1.8; color: #5a554f; font-style: italic;">引用内容请在此处编辑</div>`,
},
{
id: 'tip-warning',
name: '提示框 · 警告',
category: 'content',
preview: '⚠ 温馨提示',
html: `<div ${W} style="background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #92400e;"><strong>⚠ 温馨提示:</strong>请在此处编辑警告内容。</div>`,
},
{
id: 'tip-info',
name: '提示框 · 信息',
category: 'content',
preview: ' 补充说明',
html: `<div ${W} style="background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 14px 16px; margin: 16px 0; font-size: 15px; line-height: 1.8; color: #1e40af;"><strong> 补充说明:</strong>请在此处编辑信息内容。</div>`,
},
{
id: 'list-ordered',
name: '有序列表',
category: 'content',
preview: '① ② ③',
html: `<div ${W} style="padding-left: 20px; margin: 16px 0; font-size: 16px; line-height: 2; color: #3a3a3c;"><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -20px; color: {{primary}}; font-weight: 600;">1.</span>第一项内容</div><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -20px; color: {{primary}}; font-weight: 600;">2.</span>第二项内容</div><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -20px; color: {{primary}}; font-weight: 600;">3.</span>第三项内容</div></div>`,
},
{
id: 'list-unordered',
name: '无序列表',
category: 'content',
preview: '• • •',
html: `<div ${W} style="padding-left: 20px; margin: 16px 0; font-size: 16px; line-height: 2; color: #3a3a3c;"><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -14px; color: {{primary}};">●</span>第一项内容</div><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -14px; color: {{primary}};">●</span>第二项内容</div><div style="position: relative; padding-left: 8px; margin-bottom: 4px;"><span style="position: absolute; left: -14px; color: {{primary}};">●</span>第三项内容</div></div>`,
},
{
id: 'card-image-text',
name: '图文卡片',
category: 'content',
preview: '[图] 文字说明',
html: `<div ${W} style="display: flex; gap: 12px; margin: 16px 0; padding: 12px; background: #f9fafb; border-radius: 8px; align-items: center;"><div style="width: 100px; height: 80px; background: #e5e7eb; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #9ca3af; font-size: 12px; flex-shrink: 0;">图片</div><div style="flex: 1; font-size: 14px; line-height: 1.8; color: #3a3a3c;">图文卡片的文字描述内容请在此处编辑。</div></div>`,
},
{
id: 'card-data',
name: '数据卡片',
category: 'content',
preview: '血压 120/80',
html: `<div ${W} style="display: flex; gap: 12px; margin: 16px 0; flex-wrap: wrap;"><div style="flex: 1; min-width: 120px; background: {{primaryLight}}; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: {{primary}};">120/80</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">血压 (mmHg)</div></div><div style="flex: 1; min-width: 120px; background: #f3f4f6; border-radius: 8px; padding: 14px; text-align: center;"><div style="font-size: 24px; font-weight: 700; color: #1a1a1a;">72</div><div style="font-size: 13px; color: #5a554f; margin-top: 4px;">心率 (bpm)</div></div></div>`,
},
];
export const BLOCK_TEMPLATES: StyleTemplate[] = [
{
id: 'divider',
name: '分割线',
category: 'block',
preview: '─ ─ ─ ─',
html: `<div ${W} style="border: none; border-top: 1px dashed #d1d5db; margin: 24px 0; height: 0;"></div>`,
},
{
id: 'section-header',
name: '章节标题',
category: 'block',
preview: '§ 带编号章节',
html: `<div ${W} style="margin: 24px 0 12px; display: flex; align-items: center; gap: 10px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: {{primary}}; color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; flex-shrink: 0;">1</span><span style="font-size: 18px; font-weight: 700; color: #1a1a1a;">章节标题</span></div>`,
},
{
id: 'table',
name: '数据表格',
category: 'block',
preview: '⊞ 3×2 表格',
html: `<div ${W} style="display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0; font-size: 14px;"><div style="background: {{primaryLight}}; padding: 10px 12px; font-weight: 600; color: {{primary}}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">项目</div><div style="background: {{primaryLight}}; padding: 10px 12px; font-weight: 600; color: {{primary}}; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">数值</div><div style="background: {{primaryLight}}; padding: 10px 12px; font-weight: 600; color: {{primary}}; border-bottom: 1px solid #e5e7eb;">备注</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">收缩压</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">120 mmHg</div><div style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">正常</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">舒张压</div><div style="background: #f9fafb; padding: 10px 12px; border-right: 1px solid #e5e7eb;">80 mmHg</div><div style="background: #f9fafb; padding: 10px 12px;">正常</div></div>`,
},
];
/** 所有模板合并列表 */
export const ALL_TEMPLATES: StyleTemplate[] = [
...HEADING_TEMPLATES,
...CONTENT_TEMPLATES,
...BLOCK_TEMPLATES,
];
/** 将模板 HTML 中的颜色占位符替换为主题实际颜色值 */
export function applyTheme(html: string, theme: ColorTheme): string {
return html
.replaceAll('{{primary}}', theme.primary)
.replaceAll('{{primaryLight}}', theme.primaryLight);
}
/** 根据 ID 查找颜色主题 */
export function getColorTheme(themeId: string): ColorTheme {
return COLOR_THEMES.find((t) => t.id === themeId) ?? COLOR_THEMES[0];
}

View File

@@ -0,0 +1 @@
export { default } from './ArticleEditor';

View File

@@ -0,0 +1,78 @@
import { Boot } from '@wangeditor/editor';
import type { SlateElement } from '@wangeditor/editor';
import { h, type VNode } from 'snabbdom';
const TYPE = 'styled-block';
function parseStyleStr(str: string): Record<string, string> {
const result: Record<string, string> = {};
str.split(';').forEach((rule) => {
const idx = rule.indexOf(':');
if (idx === -1) return;
const key = rule.substring(0, idx).trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const value = rule.substring(idx + 1).trim();
if (key && value) result[key] = value;
});
return result;
}
const renderElemConf = {
type: TYPE,
renderElem(elemNode: SlateElement): VNode {
const node = elemNode as Record<string, unknown>;
const style = (node.style as string) || '';
const innerHtml = (node.innerHtml as string) || '';
return h(
'div',
{
attrs: { 'data-w-e-type': TYPE, contenteditable: 'false' },
style: parseStyleStr(style),
hook: {
insert(vnode: VNode) {
if (vnode.elm) (vnode.elm as HTMLElement).innerHTML = innerHtml;
},
postpatch(_: VNode, vnode: VNode) {
if (vnode.elm) (vnode.elm as HTMLElement).innerHTML = innerHtml;
},
},
},
[],
);
},
};
const elemToHtmlConf = {
type: TYPE,
elemToHtml(elemNode: SlateElement): string {
const node = elemNode as Record<string, unknown>;
const style = (node.style as string) || '';
const innerHtml = (node.innerHtml as string) || '';
return `<div data-w-e-type="${TYPE}" style="${style}">${innerHtml}</div>`;
},
};
const parseElemHtmlConf = {
selector: `div[data-w-e-type="${TYPE}"]`,
parseElemHtml($elem: HTMLElement): SlateElement {
return {
type: TYPE,
style: $elem.getAttribute('style') || '',
innerHtml: $elem.innerHTML,
children: [{ text: '' }],
} as SlateElement;
},
};
let registered = false;
export function registerStyledBlockPlugin() {
if (registered) return;
Boot.registerModule({
renderElems: [renderElemConf],
elemsToHtml: [elemToHtmlConf],
parseElemsHtml: [parseElemHtmlConf],
});
registered = true;
}
export { TYPE as STYLE_BLOCK_TYPE };