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:
SpecialX
2026-07-14 08:47:18 +08:00
parent ae6fe23be6
commit 0066afc55e
9 changed files with 1642 additions and 79 deletions

View File

@@ -0,0 +1,240 @@
"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;