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 全部通过。
This commit is contained in:
240
packages/ui-components/src/rich-text-editor.tsx
Normal file
240
packages/ui-components/src/rich-text-editor.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user