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 全部通过。
241 lines
6.2 KiB
TypeScript
241 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* RichTextEditor - 富文本编辑器组件
|
||
*
|
||
* 维护者:ai13(teacher-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;
|