Files
Edu/packages/ui-components/src/rich-text-editor.tsx
SpecialX 0066afc55e feat(teacher-portal): v2 下游核查 + push-gateway 接入 + 测试扩展至 87 + ui-components 12/12
v2 核查结论:

- iam/push-gateway/api-gateway 已就绪

- teacher-bff teacher 域仍是 P2 占位(56/61 operations 不可用)

- 5 个 gRPC target 留空走降级模式 B

- 保留 MSW mock,记录 12 项 teacher-bff 待补工作到 nextstep-v2.md

v2 完成工作:

- push-gateway WebSocket 接入(环境变量 + URL query token + Reconnect 协议)

- 单元测试扩展(usePermission 15 + useAuth 9,总计 87/87 passed)

- ui-components 剩余 3 组件(Chart/Calendar/RichTextEditor,12/12)

- 设计令牌完整迁移(无 hsl/hex 字面量)

- i18n 5/55 页面 + 26 模块 key

- 性能优化(size-limit 9 项 + 5 页面懒加载)

- useAuth 迁移阻塞核查(iam 未实现 Set-Cookie,修正 v1 假设)

文档:新增 nextstep-v2.md;workline.md §5.7 添加 v2 工作记录。

验证:typecheck + lint + 87/87 tests + arch:scan 全部通过。
2026-07-14 08:47:18 +08:00

241 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* RichTextEditor - 富文本编辑器组件
*
* 维护者ai13teacher-portal
* 关联lesson-plans/edit + exams/edit-rich 复用
*
* 特性:
* - 基于 contentEditable 的轻量富文本编辑(无 Tiptap 依赖)
* - 工具栏:加粗 / 斜体 / 下划线 / 列表 / 标题 / 链接
* - 受控模式value + onChange
* - 设计令牌var(--*) 颜色 / 字体 / 间距
*
* 注意P3+ 阶段可替换为 Tiptap 封装(需安装 @tiptap/core + @tiptap/react
* 当前 contentEditable 实现满足 P7 基础需求
*/
import { useCallback, useRef, useEffect } from "react";
export interface RichTextEditorProps {
/** 初始 HTML 内容 */
value?: string;
/** 占位符 */
placeholder?: string;
/** 内容变更回调 */
onChange?: (html: string) => void;
/** 是否只读 */
readOnly?: boolean;
/** 最小高度 */
minHeight?: number;
}
export function RichTextEditor({
value = "",
placeholder = "请输入内容...",
onChange,
readOnly = false,
minHeight = 200,
}: RichTextEditorProps): React.ReactElement {
const editorRef = useRef<HTMLDivElement>(null);
const isInternalChange = useRef(false);
// 同步外部 value 到 editor
useEffect(() => {
if (editorRef.current && !isInternalChange.current) {
if (editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value;
}
}
isInternalChange.current = false;
}, [value]);
const handleInput = useCallback(() => {
if (!editorRef.current) return;
isInternalChange.current = true;
const html = editorRef.current.innerHTML;
onChange?.(html);
}, [onChange]);
const exec = useCallback(
(command: string, val?: string) => {
if (readOnly) return;
document.execCommand(command, false, val);
handleInput();
editorRef.current?.focus();
},
[handleInput, readOnly],
);
const handleLink = useCallback(() => {
if (readOnly) return;
const url = window.prompt("输入链接 URL");
if (url) {
exec("createLink", url);
}
}, [exec, readOnly]);
const toolbarButtons = readOnly ? null : (
<div
className="flex items-center gap-1 p-2 border-b flex-wrap"
style={{
borderColor: "var(--color-rule)",
background: "var(--bg-subtle)",
borderRadius: "var(--radius-default) var(--radius-default) 0 0",
}}
>
<ToolbarButton label="加粗" onClick={() => exec("bold")} icon="B" bold />
<ToolbarButton
label="斜体"
onClick={() => exec("italic")}
icon="I"
italic
/>
<ToolbarButton
label="下划线"
onClick={() => exec("underline")}
icon="U"
underline
/>
<ToolbarDivider />
<ToolbarButton
label="标题"
onClick={() => exec("formatBlock", "<h3>")}
icon="H"
/>
<ToolbarButton
label="正文"
onClick={() => exec("formatBlock", "<p>")}
icon="¶"
/>
<ToolbarDivider />
<ToolbarButton
label="无序列表"
onClick={() => exec("insertUnorderedList")}
icon="•"
/>
<ToolbarButton
label="有序列表"
onClick={() => exec("insertOrderedList")}
icon="1."
/>
<ToolbarDivider />
<ToolbarButton label="链接" onClick={handleLink} icon="🔗" />
<ToolbarButton
label="清除格式"
onClick={() => exec("removeFormat")}
icon="✕"
/>
</div>
);
return (
<div
className="w-full overflow-hidden"
style={{
border: "1px solid var(--color-rule)",
borderRadius: "var(--radius-card)",
background: "var(--bg-paper)",
}}
>
{toolbarButtons}
<div
ref={editorRef}
contentEditable={!readOnly}
suppressContentEditableWarning
onInput={handleInput}
data-placeholder={placeholder}
className="rich-text-editor-content"
style={{
minHeight: `${minHeight}px`,
padding: "var(--space-md)",
outline: "none",
fontFamily: "var(--font-family-sans)",
fontSize: "var(--font-size-body)",
color: "var(--color-ink)",
lineHeight: "1.8",
}}
/>
<style>{`
.rich-text-editor-content:empty::before {
content: attr(data-placeholder);
color: var(--color-ink-subtle);
pointer-events: none;
}
.rich-text-editor-content h3 {
font-family: var(--font-family-serif);
font-size: var(--font-size-heading-3);
margin: var(--space-sm) 0;
}
.rich-text-editor-content ul,
.rich-text-editor-content ol {
padding-left: var(--space-lg);
margin: var(--space-xs) 0;
}
.rich-text-editor-content a {
color: var(--color-accent);
text-decoration: underline;
}
.rich-text-editor-content blockquote {
border-left: 3px solid var(--color-rule);
padding-left: var(--space-md);
margin: var(--space-sm) 0;
color: var(--color-ink-muted);
}
`}</style>
</div>
);
}
// ============ 工具栏按钮 ============
interface ToolbarButtonProps {
label: string;
onClick: () => void;
icon: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
function ToolbarButton({
label,
onClick,
icon,
bold,
italic,
underline,
}: ToolbarButtonProps): React.ReactElement {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
className="w-8 h-8 flex items-center justify-center text-sm transition-colors hover:bg-[var(--color-accent-subtle)]"
style={{
color: "var(--color-ink-muted)",
borderRadius: "var(--radius-button)",
fontWeight: bold ? "700" : "400",
fontStyle: italic ? "italic" : "normal",
textDecoration: underline ? "underline" : "none",
cursor: "pointer",
}}
>
{icon}
</button>
);
}
function ToolbarDivider(): React.ReactElement {
return (
<div
className="w-px h-5 mx-1"
style={{ background: "var(--color-rule)" }}
/>
);
}
export default RichTextEditor;