feat(web): 文章编辑器重设计 — 公众号风格三栏布局 + styled-block 自定义模块
- 左栏样式组件库(标题/内容/区块 14 种模板,5 种配色主题) - 中间 Notion 风格编辑区(标题置顶 + wangEditor + 自定义 styled-block) - 右栏 iPhone 仿真预览(匹配小程序暖奶油配色) - 设置面板移至 Drawer 抽屉按需打开 - 注册 wangEditor 自定义模块保留模板内联样式 - 使用 snabbdom VNode + insertNode API 解决样式被剥离问题
This commit is contained in:
@@ -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": {
|
||||
|
||||
3
apps/web/pnpm-lock.yaml
generated
3
apps/web/pnpm-lock.yaml
generated
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
470
apps/web/src/pages/health/articleEditor/ArticleEditor.tsx
Normal file
470
apps/web/src/pages/health/articleEditor/ArticleEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx
Normal file
239
apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('已选择封面图');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx
Normal file
203
apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/pages/health/articleEditor/articleTemplates.ts
Normal file
156
apps/web/src/pages/health/articleEditor/articleTemplates.ts
Normal 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];
|
||||
}
|
||||
1
apps/web/src/pages/health/articleEditor/index.ts
Normal file
1
apps/web/src/pages/health/articleEditor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ArticleEditor';
|
||||
78
apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts
Normal file
78
apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user