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": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
|
"snabbdom": "^3.6.3",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
3
apps/web/pnpm-lock.yaml
generated
3
apps/web/pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.14.0
|
specifier: ^7.14.0
|
||||||
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
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:
|
zustand:
|
||||||
specifier: ^5.0.12
|
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))
|
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 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 ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
|
||||||
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
|
||||||
|
const BannerManage = lazy(() => import('./pages/health/BannerManage'));
|
||||||
|
const MediaLibrary = lazy(() => import('./pages/health/MediaLibrary'));
|
||||||
|
|
||||||
function FrozenRoute() {
|
function FrozenRoute() {
|
||||||
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
|
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/articles/:id/edit" element={<ArticleEditor />} />
|
||||||
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
|
<Route path="/health/article-categories" element={<ArticleCategoryManage />} />
|
||||||
<Route path="/health/article-tags" element={<ArticleTagManage />} />
|
<Route path="/health/article-tags" element={<ArticleTagManage />} />
|
||||||
|
<Route path="/health/banners" element={<BannerManage />} />
|
||||||
|
<Route path="/health/media-library" element={<MediaLibrary />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</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