From f4b09858c41f9d6a1a3e89cd25e21e3de564befa Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 11 May 2026 02:18:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=87=E7=AB=A0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=87=8D=E8=AE=BE=E8=AE=A1=20=E2=80=94=20?= =?UTF-8?q?=E5=85=AC=E4=BC=97=E5=8F=B7=E9=A3=8E=E6=A0=BC=E4=B8=89=E6=A0=8F?= =?UTF-8?q?=E5=B8=83=E5=B1=80=20+=20styled-block=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左栏样式组件库(标题/内容/区块 14 种模板,5 种配色主题) - 中间 Notion 风格编辑区(标题置顶 + wangEditor + 自定义 styled-block) - 右栏 iPhone 仿真预览(匹配小程序暖奶油配色) - 设置面板移至 Drawer 抽屉按需打开 - 注册 wangEditor 自定义模块保留模板内联样式 - 使用 snabbdom VNode + insertNode API 解决样式被剥离问题 --- apps/web/package.json | 1 + apps/web/pnpm-lock.yaml | 3 + apps/web/src/App.tsx | 6 +- apps/web/src/pages/health/ArticleEditor.tsx | 557 ------------------ .../health/articleEditor/ArticleEditor.tsx | 470 +++++++++++++++ .../articleEditor/ArticlePhonePreview.tsx | 239 ++++++++ .../articleEditor/ArticleSettingsDrawer.tsx | 206 +++++++ .../articleEditor/ArticleStyleLibrary.tsx | 203 +++++++ .../health/articleEditor/articleTemplates.ts | 156 +++++ .../src/pages/health/articleEditor/index.ts | 1 + .../health/articleEditor/styledBlockPlugin.ts | 78 +++ 11 files changed, 1362 insertions(+), 558 deletions(-) delete mode 100644 apps/web/src/pages/health/ArticleEditor.tsx create mode 100644 apps/web/src/pages/health/articleEditor/ArticleEditor.tsx create mode 100644 apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx create mode 100644 apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx create mode 100644 apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx create mode 100644 apps/web/src/pages/health/articleEditor/articleTemplates.ts create mode 100644 apps/web/src/pages/health/articleEditor/index.ts create mode 100644 apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4e627ce..14cfdc5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 4face1f..0e7831b 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -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)) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5f4ee35..71e6ea4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ; @@ -326,6 +328,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/apps/web/src/pages/health/ArticleEditor.tsx b/apps/web/src/pages/health/ArticleEditor.tsx deleted file mode 100644 index b33bc76..0000000 --- a/apps/web/src/pages/health/ArticleEditor.tsx +++ /dev/null @@ -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(undefined); - const [selectedTagIds, setSelectedTagIds] = useState([]); - const [sortOrder, setSortOrder] = useState(0); - const [version, setVersion] = useState(0); - - // 选项数据 - const [categories, setCategories] = useState<{ id: string; name: string }[]>([]); - const [tags, setTags] = useState([]); - - // UI 状态 - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [editor, setEditor] = useState(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>( - () => ({ - excludeKeys: [ - 'group-video', - 'insertLink', - 'editLink', - 'unLink', - 'viewLink', - 'codeView', - ], - }), - [], - ); - - const editorConfig = useMemo>( - () => ({ - 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 ( -
- -
- ); - } - - return ( -
- {/* 页面标题栏 */} -
- - - - - - - -
- - {/* 主体布局: 左侧编辑区 + 右侧设置面板 */} -
- {/* 左侧: 富文本编辑器 — key 变化时强制重新挂载,防止 WangEditor toolbar 重复创建 */} -
-
- -
-
- setContent(editorInstance.getHtml())} - mode="default" - style={{ - minHeight: 500, - background: isDark ? '#111827' : '#FFFFFF', - color: isDark ? '#e2e8f0' : '#1e293b', - }} - /> -
-
- - {/* 右侧: 设置面板 */} -
- {/* 标题 */} -
- - setTitle(e.target.value)} - placeholder="请输入文章标题" - maxLength={200} - showCount - /> -
- - {/* 分类 */} -
- - ({ label: t.name, value: t.id }))} - maxTagCount={5} - /> -
- - {/* 摘要 */} -
- - setSummary(e.target.value)} - placeholder="请输入文章摘要" - rows={3} - maxLength={500} - showCount - /> -
- - {/* 封面图 */} -
- - - setCoverImage(e.target.value)} - placeholder="请输入封面图片 URL 或上传文件" - style={{ flex: 1 }} - /> - - { - try { - const result = await uploadFile(file); - setCoverImage(result.url); - message.success('封面图上传成功'); - } catch { - message.error('封面图上传失败'); - } - return false; - }} - > - - - - {coverImage && ( -
- 封面预览 { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> -
- )} -
- - {/* Slug */} -
- - setSlug(e.target.value)} - placeholder="例如: health-tips-for-elderly" - /> -
- - {/* 排序 */} -
- - setSortOrder(Number(e.target.value))} - placeholder="0" - /> -
-
-
- - setMediaPickerOpen(false)} - onSelect={(url) => { - setCoverImage(url); - message.success('已选择封面图'); - }} - /> -
- ); -} diff --git a/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx b/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx new file mode 100644 index 0000000..2055c68 --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx @@ -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(undefined); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [sortOrder, setSortOrder] = useState(0); + const [version, setVersion] = useState(0); + + // 选项数据 + const [categories, setCategories] = useState<{ id: string; name: string }[]>([]); + const [tags, setTags] = useState([]); + + // UI 状态 + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [editor, setEditor] = useState(null); + const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false); + const [selectedThemeId, setSelectedThemeId] = useState('red'); + const [previewContent, setPreviewContent] = useState(''); + + // 命令式 toolbar + const toolbarContainerRef = useRef(null); + const toolbarInstanceRef = useRef(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>( + () => ({ + excludeKeys: [ + 'group-video', + 'insertLink', + 'editLink', + 'unLink', + 'viewLink', + 'codeView', + ], + }), + [], + ); + + const editorConfig = useMemo>( + () => ({ + 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 ( +
+ +
+ ); + } + + const borderColor = isDark ? '#1e293b' : '#f1f5f9'; + const bgPrimary = isDark ? '#111827' : '#FFFFFF'; + const bgSecondary = isDark ? '#0f172a' : '#f8fafc'; + + return ( +
+ {/* 页面标题栏 */} +
+ + + + + + + + + +
+ + {/* 三栏布局 */} +
+ {/* 左栏:样式组件库 */} + + + {/* 中间:标题 + 工具栏 + 编辑器 */} +
+ {/* 标题输入 — Notion 风格大字 */} +
+ setTitle(e.target.value)} + placeholder="请输入文章标题..." + variant="borderless" + maxLength={200} + style={{ + fontSize: 28, + fontWeight: 700, + color: isDark ? '#e2e8f0' : '#1e293b', + padding: 0, + lineHeight: 1.4, + }} + /> +
+ + {/* 工具栏 */} +
+ + {/* 编辑器 */} +
+ setContent(editorInstance.getHtml())} + mode="default" + style={{ + minHeight: 400, + background: bgPrimary, + color: isDark ? '#e2e8f0' : '#1e293b', + }} + /> +
+
+ + {/* 右栏:手机预览 */} + c.id === categoryId)?.name} + summary={summary} + coverImage={coverImage} + isDark={isDark} + /> +
+ + {/* 设置抽屉 */} + 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} + /> +
+ ); +} diff --git a/apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx b/apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx new file mode 100644 index 0000000..a76997a --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/ArticlePhonePreview.tsx @@ -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 ( +
+
+ 📱 实时预览 · 小程序端 +
+ + {/* iPhone 外壳 */} +
+ {/* 灵动岛 */} +
+ + {/* 状态栏 */} +
+ 9:41 +
+ + + + +
+
+
+
+
+ + {/* 内容区 */} +
+ {/* 作用域样式 — 匹配小程序 article/detail/index.scss */} + + +
+ {/* 头部:标题+元信息 */} +
+ {coverImage && ( + 封面 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} + {title || '文章标题'} +
+ {category && {category}} + 健康管理中心 + {today} +
+
+ + {/* 摘要 */} + {summary && ( +
+
{summary}
+
+ )} + + {/* 正文内容 */} +
+ {content && content !== '


' ? ( +
+ ) : ( +
在左侧编辑器中输入文章内容,此处将实时显示预览效果...
+ )} +
+
+
+ + {/* Home Indicator */} +
+
+
+ ); +} diff --git a/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx b/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx new file mode 100644 index 0000000..994706e --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx @@ -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 ( + <> + +
+ {/* 分类 */} +
+ + ({ label: t.name, value: t.id }))} + maxTagCount={5} + /> +
+ + {/* 摘要 */} +
+ + onSummaryChange(e.target.value)} + placeholder="请输入文章摘要" + rows={3} + maxLength={500} + showCount + /> +
+ + {/* 封面图 */} +
+ + + onCoverImageChange(e.target.value)} + placeholder="输入 URL 或上传文件" + style={{ flex: 1 }} + /> + + { + try { + const result = await uploadFile(file); + onCoverImageChange(result.url); + message.success('封面图上传成功'); + } catch { + message.error('封面图上传失败'); + } + return false; + }} + > + + + + {coverImage && ( +
+ 封面预览 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+ + {/* Slug */} +
+ + onSlugChange(e.target.value)} + placeholder="例如: health-tips-for-elderly" + /> +
+ + {/* 排序 */} +
+ + onSortOrderChange(Number(e.target.value))} + placeholder="0" + /> +
+
+
+ + setMediaPickerOpen(false)} + onSelect={(url) => { + onCoverImageChange(url); + message.success('已选择封面图'); + }} + /> + + ); +} diff --git a/apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx b/apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx new file mode 100644 index 0000000..d34aeba --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/ArticleStyleLibrary.tsx @@ -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 ( + <> +
{label}
+ {templates.map((t) => ( +
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'; + }} + > +
+ {t.name} +
+
+ {t.preview} +
+
+ ))} + + ); + }; + + return ( +
+ {/* 搜索框 */} +
+ setSearchKeyword(e.target.value)} + allowClear + style={{ borderRadius: 8 }} + /> +
+ + {/* 配色方案 */} +
配色方案
+
+ {COLOR_THEMES.map((c) => ( +
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)', + }} + /> + ))} +
+ + {/* 模板列表 */} + {renderSection('标题样式', headingFiltered)} + {renderSection('内容模板', contentFiltered)} + {renderSection('区块组件', blockFiltered)} + + {/* 无结果 */} + {filteredTemplates.length === 0 && ( +
+ 未找到匹配的模板 +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/health/articleEditor/articleTemplates.ts b/apps/web/src/pages/health/articleEditor/articleTemplates.ts new file mode 100644 index 0000000..5e8cff0 --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/articleTemplates.ts @@ -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: `
标题文本
`, + }, + { + id: 'heading-simple', + name: '简约', + category: 'heading', + preview: '下划线标题 ──', + html: `
标题文本
`, + }, + { + id: 'heading-rounded', + name: '圆标', + category: 'heading', + preview: '■ 标签式标题', + html: `
标题文本
`, + }, + { + id: 'heading-centered', + name: '居中', + category: 'heading', + preview: '居中标题', + html: `
标题文本
`, + }, +]; + +export const CONTENT_TEMPLATES: StyleTemplate[] = [ + { + id: 'blockquote', + name: '引用框', + category: 'content', + preview: '▎引用文字...', + html: `
引用内容请在此处编辑
`, + }, + { + id: 'tip-warning', + name: '提示框 · 警告', + category: 'content', + preview: '⚠ 温馨提示', + html: `
⚠ 温馨提示:请在此处编辑警告内容。
`, + }, + { + id: 'tip-info', + name: '提示框 · 信息', + category: 'content', + preview: 'ℹ️ 补充说明', + html: `
ℹ️ 补充说明:请在此处编辑信息内容。
`, + }, + { + id: 'list-ordered', + name: '有序列表', + category: 'content', + preview: '① ② ③', + html: `
1.第一项内容
2.第二项内容
3.第三项内容
`, + }, + { + id: 'list-unordered', + name: '无序列表', + category: 'content', + preview: '• • •', + html: `
第一项内容
第二项内容
第三项内容
`, + }, + { + id: 'card-image-text', + name: '图文卡片', + category: 'content', + preview: '[图] 文字说明', + html: `
图片
图文卡片的文字描述内容请在此处编辑。
`, + }, + { + id: 'card-data', + name: '数据卡片', + category: 'content', + preview: '血压 120/80', + html: `
120/80
血压 (mmHg)
72
心率 (bpm)
`, + }, +]; + +export const BLOCK_TEMPLATES: StyleTemplate[] = [ + { + id: 'divider', + name: '分割线', + category: 'block', + preview: '─ ─ ─ ─', + html: `
`, + }, + { + id: 'section-header', + name: '章节标题', + category: 'block', + preview: '§ 带编号章节', + html: `
1章节标题
`, + }, + { + id: 'table', + name: '数据表格', + category: 'block', + preview: '⊞ 3×2 表格', + html: `
项目
数值
备注
收缩压
120 mmHg
正常
舒张压
80 mmHg
正常
`, + }, +]; + +/** 所有模板合并列表 */ +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]; +} diff --git a/apps/web/src/pages/health/articleEditor/index.ts b/apps/web/src/pages/health/articleEditor/index.ts new file mode 100644 index 0000000..107cab8 --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/index.ts @@ -0,0 +1 @@ +export { default } from './ArticleEditor'; diff --git a/apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts b/apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts new file mode 100644 index 0000000..f56227b --- /dev/null +++ b/apps/web/src/pages/health/articleEditor/styledBlockPlugin.ts @@ -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 { + const result: Record = {}; + 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; + 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; + const style = (node.style as string) || ''; + const innerHtml = (node.innerHtml as string) || ''; + return `
${innerHtml}
`; + }, +}; + +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 };