+ {/* 搜索框 */}
+
+ 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: `
`,
+ },
+ {
+ 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: `
`,
+ },
+];
+
+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 };