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,258 @@
"use client";
/**
* Calendar - 课案日历组件
*
* 维护者ai13teacher-portal
* 关联lesson-plans/calendar 页面复用
*
* 特性:
* - 月视图,支持前后月切换
* - 日期点击回调
* - 日期标记(有课案的日期高亮)
* - 纯 CSS + var(--*) 设计令牌
*/
import { useState, useMemo, useCallback } from "react";
export interface CalendarEvent {
date: string; // ISO date string YYYY-MM-DD
title: string;
type?: "lesson" | "exam" | "meeting";
}
export interface CalendarProps {
/** 初始月份(默认当前月) */
initialDate?: Date;
/** 事件列表 */
events?: CalendarEvent[];
/** 日期点击回调 */
onDateClick?: (date: Date) => void;
/** 月份切换回调 */
onMonthChange?: (date: Date) => void;
}
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
const WEEKDAYS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function getEventColor(type: CalendarEvent["type"]): string {
switch (type) {
case "exam":
return "var(--color-danger)";
case "meeting":
return "var(--color-warning)";
case "lesson":
default:
return "var(--color-accent)";
}
}
export function Calendar({
initialDate = new Date(),
events = [],
onDateClick,
onMonthChange,
}: CalendarProps): React.ReactElement {
const [currentDate, setCurrentDate] = useState(initialDate);
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const evt of events) {
const existing = map.get(evt.date) || [];
existing.push(evt);
map.set(evt.date, existing);
}
return map;
}, [events]);
const days = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const cells: Array<{ date: Date | null; isCurrentMonth: boolean }> = [];
// 上月填充
for (let i = 0; i < startWeekday; i++) {
cells.push({ date: null, isCurrentMonth: false });
}
// 当月
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), isCurrentMonth: true });
}
// 下月填充(补齐 6 行 = 42 格)
while (cells.length < 42) {
cells.push({ date: null, isCurrentMonth: false });
}
return cells;
}, [currentDate]);
const handlePrevMonth = useCallback(() => {
const prev = new Date(
currentDate.getFullYear(),
currentDate.getMonth() - 1,
1,
);
setCurrentDate(prev);
onMonthChange?.(prev);
}, [currentDate, onMonthChange]);
const handleNextMonth = useCallback(() => {
const next = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
1,
);
setCurrentDate(next);
onMonthChange?.(next);
}, [currentDate, onMonthChange]);
const today = formatDate(new Date());
return (
<div
className="w-full"
style={{
background: "var(--bg-surface)",
borderRadius: "var(--radius-card)",
padding: "var(--space-md)",
}}
>
{/* 月份导航 */}
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={handlePrevMonth}
className="px-3 py-1 text-sm transition-opacity hover:opacity-70"
style={{
color: "var(--color-ink-muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="上一月"
>
</button>
<h3
className="text-base font-medium"
style={{
color: "var(--color-ink)",
fontFamily: "var(--font-family-serif)",
}}
>
{currentDate.getFullYear()} {currentDate.getMonth() + 1}
</h3>
<button
type="button"
onClick={handleNextMonth}
className="px-3 py-1 text-sm transition-opacity hover:opacity-70"
style={{
color: "var(--color-ink-muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="下一月"
>
</button>
</div>
{/* 星期表头 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day, i) => (
<div
key={`wd-${i}`}
className="text-center text-xs uppercase tracking-wide py-2"
style={{ color: "var(--color-ink-subtle)" }}
>
{day}
</div>
))}
</div>
{/* 日期网格 */}
<div className="grid grid-cols-7 gap-1">
{days.map((cell, i) => {
if (!cell.date) {
return <div key={`empty-${i}`} className="min-h-[60px]" />;
}
const dateStr = formatDate(cell.date);
const dayEvents = eventsByDate.get(dateStr) || [];
const isToday = dateStr === today;
return (
<button
type="button"
key={`day-${dateStr}`}
onClick={() => onDateClick?.(cell.date!)}
className="min-h-[60px] p-1 text-left transition-colors hover:bg-[var(--bg-subtle)]"
style={{
background: isToday
? "var(--color-accent-subtle)"
: "transparent",
borderRadius: "var(--radius-default)",
border: isToday
? "1px solid var(--color-accent)"
: "1px solid transparent",
cursor: "pointer",
}}
>
<div
className="text-xs mb-1"
style={{
color: isToday
? "var(--color-accent)"
: "var(--color-ink-muted)",
fontWeight: isToday ? "600" : "400",
}}
>
{cell.date.getDate()}
</div>
{/* 事件标记 */}
<div className="space-y-0.5">
{dayEvents.slice(0, 2).map((evt, j) => (
<div
key={`evt-${j}`}
className="text-[10px] truncate px-1 py-0.5"
style={{
background: getEventColor(evt.type),
color: "var(--color-ink-on-accent)",
borderRadius: "2px",
}}
title={evt.title}
>
{evt.title}
</div>
))}
{dayEvents.length > 2 && (
<div
className="text-[10px]"
style={{ color: "var(--color-ink-subtle)" }}
>
+{dayEvents.length - 2}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
}
export { WEEKDAYS, WEEKDAYS_EN, formatDate };
export default Calendar;

View File

@@ -0,0 +1,409 @@
"use client";
/**
* Chart - SVG 图表统一封装组件
*
* 维护者ai13teacher-portal
* 关联project_rules §3.10 设计令牌(禁止硬编码颜色,使用 var(--*)
*
* 支持:
* - bar柱状图
* - line折线图
* - pie饼图
*
* 纯 SVG 实现,无额外 npm 依赖
* 颜色使用 var(--color-*) 语义令牌
*/
import { useMemo } from "react";
export type ChartType = "bar" | "line" | "pie";
export interface ChartDataPoint {
label: string;
value: number;
color?: string;
}
export interface ChartProps {
type: ChartType;
data: ChartDataPoint[];
width?: number;
height?: number;
title?: string;
/** 数值格式化函数 */
formatValue?: (value: number) => string;
}
export function Chart({
type,
data,
width = 600,
height = 300,
title,
formatValue,
}: ChartProps): React.ReactElement {
const fmt = formatValue ?? ((v: number) => String(v));
if (type === "bar") {
return (
<BarChart
data={data}
width={width}
height={height}
title={title}
fmt={fmt}
/>
);
}
if (type === "line") {
return (
<LineChart
data={data}
width={width}
height={height}
title={title}
fmt={fmt}
/>
);
}
return (
<PieChart
data={data}
width={width}
height={height}
title={title}
fmt={fmt}
/>
);
}
// ============ BarChart ============
interface SubChartProps {
data: ChartDataPoint[];
width: number;
height: number;
title?: string;
fmt: (v: number) => string;
}
function BarChart({
data,
width,
height,
title,
fmt,
}: SubChartProps): React.ReactElement {
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(...data.map((d) => d.value), 1);
const barWidth = chartWidth / data.length / 1.5;
const gap = (chartWidth - barWidth * data.length) / (data.length + 1);
const yTicks = useMemo(() => {
const ticks: number[] = [];
const step = maxValue / 4;
for (let i = 0; i <= 4; i++) ticks.push(step * i);
return ticks;
}, [maxValue]);
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={title || "柱状图"}
>
{title && (
<text
x={width / 2}
y={15}
textAnchor="middle"
fontSize="14"
fill="var(--color-ink)"
>
{title}
</text>
)}
{/* Y 轴刻度 */}
{yTicks.map((tick, i) => {
const y = padding.top + chartHeight - (tick / maxValue) * chartHeight;
return (
<g key={`y-${i}`}>
<line
x1={padding.left}
y1={y}
x2={padding.left + chartWidth}
y2={y}
stroke="var(--color-rule)"
strokeWidth="0.5"
/>
<text
x={padding.left - 5}
y={y + 4}
textAnchor="end"
fontSize="10"
fill="var(--color-ink-muted)"
>
{fmt(tick)}
</text>
</g>
);
})}
{/* 柱子 */}
{data.map((d, i) => {
const barHeight = (d.value / maxValue) * chartHeight;
const x = padding.left + gap + i * (barWidth + gap);
const y = padding.top + chartHeight - barHeight;
return (
<g key={`bar-${i}`}>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={d.color || "var(--color-accent)"}
rx="2"
/>
<text
x={x + barWidth / 2}
y={padding.top + chartHeight + 15}
textAnchor="middle"
fontSize="10"
fill="var(--color-ink-muted)"
>
{d.label}
</text>
</g>
);
})}
{/* X/Y 轴 */}
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={padding.top + chartHeight}
stroke="var(--color-rule-strong)"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top + chartHeight}
x2={padding.left + chartWidth}
y2={padding.top + chartHeight}
stroke="var(--color-rule-strong)"
strokeWidth="1"
/>
</svg>
);
}
// ============ LineChart ============
function LineChart({
data,
width,
height,
title,
fmt,
}: SubChartProps): React.ReactElement {
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(...data.map((d) => d.value), 1);
const minValue = Math.min(...data.map((d) => d.value), 0);
const range = maxValue - minValue || 1;
const points = data.map((d, i) => {
const x = padding.left + (i / Math.max(data.length - 1, 1)) * chartWidth;
const y =
padding.top + chartHeight - ((d.value - minValue) / range) * chartHeight;
return { x, y, ...d };
});
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={title || "折线图"}
>
{title && (
<text
x={width / 2}
y={15}
textAnchor="middle"
fontSize="14"
fill="var(--color-ink)"
>
{title}
</text>
)}
{/* 网格线 */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio, i) => {
const y = padding.top + chartHeight - ratio * chartHeight;
const tickVal = minValue + ratio * range;
return (
<g key={`grid-${i}`}>
<line
x1={padding.left}
y1={y}
x2={padding.left + chartWidth}
y2={y}
stroke="var(--color-rule)"
strokeWidth="0.5"
/>
<text
x={padding.left - 5}
y={y + 4}
textAnchor="end"
fontSize="10"
fill="var(--color-ink-muted)"
>
{fmt(tickVal)}
</text>
</g>
);
})}
{/* 折线 */}
<path
d={pathD}
fill="none"
stroke="var(--color-accent)"
strokeWidth="2"
/>
{/* 数据点 */}
{points.map((p, i) => (
<g key={`pt-${i}`}>
<circle cx={p.x} cy={p.y} r="3" fill="var(--color-accent)" />
<text
x={p.x}
y={padding.top + chartHeight + 15}
textAnchor="middle"
fontSize="10"
fill="var(--color-ink-muted)"
>
{p.label}
</text>
</g>
))}
{/* 轴 */}
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={padding.top + chartHeight}
stroke="var(--color-rule-strong)"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top + chartHeight}
x2={padding.left + chartWidth}
y2={padding.top + chartHeight}
stroke="var(--color-rule-strong)"
strokeWidth="1"
/>
</svg>
);
}
// ============ PieChart ============
function PieChart({
data,
width,
height,
title,
}: SubChartProps): React.ReactElement {
const total = data.reduce((sum, d) => sum + d.value, 0) || 1;
const cx = width / 2;
const cy = height / 2 - 10;
const radius = Math.min(width, height) / 3;
const defaultColors = [
"var(--color-accent)",
"var(--color-success)",
"var(--color-warning)",
"var(--color-info)",
"var(--color-danger)",
];
let cumulativeAngle = -Math.PI / 2;
const slices = data.map((d, i) => {
const angle = (d.value / total) * 2 * Math.PI;
const startAngle = cumulativeAngle;
const endAngle = cumulativeAngle + angle;
cumulativeAngle = endAngle;
const x1 = cx + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
const largeArc = angle > Math.PI ? 1 : 0;
return {
d: `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
color: d.color || defaultColors[i % defaultColors.length],
label: d.label,
value: d.value,
percent: (d.value / total) * 100,
};
});
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={title || "饼图"}
>
{title && (
<text
x={width / 2}
y={15}
textAnchor="middle"
fontSize="14"
fill="var(--color-ink)"
>
{title}
</text>
)}
{slices.map((s, i) => (
<path
key={`slice-${i}`}
d={s.d}
fill={s.color}
stroke="var(--bg-paper)"
strokeWidth="1"
/>
))}
{/* 图例 */}
{slices.map((s, i) => (
<g
key={`legend-${i}`}
transform={`translate(${width - 120}, ${20 + i * 18})`}
>
<rect width="10" height="10" fill={s.color} rx="1" />
<text x="15" y="9" fontSize="10" fill="var(--color-ink-muted)">
{s.label} ({s.percent.toFixed(1)}%)
</text>
</g>
))}
</svg>
);
}
export default Chart;

View File

@@ -15,13 +15,15 @@
* - Modal模态对话框
* - Form轻量级表单Form/FormField/FormInput/FormSelect/FormTextarea/FormCheckbox/SubmitButton
* - cn类名合并工具project_rules §3.9
* - ChartSVG 图表统一封装bar/line/pie
* - Calendar月视图日历组件
* - RichTextEditor富文本编辑器contentEditable 轻量实现)
*
* 后续扩展P2+
* 后续扩展P3+
* - AppShellMF Shell 布局)
* - shadcn/ui 基础组件Button/Input/Select/Toast
* - A11y 工具集useA11yId/mergeA11yProps/describeInput/focus-trap
* - RichTextEditorTiptap 封装
* - Chartrecharts 封装)
* - RichTextEditor 升级为 Tiptap 封装
*/
export { ErrorBoundary } from "./error-boundary.js";
@@ -72,3 +74,12 @@ export type {
} from "./form.js";
export { cn } from "./utils/cn.js";
export { Chart } from "./chart.js";
export type { ChartProps, ChartType, ChartDataPoint } from "./chart.js";
export { Calendar } from "./calendar.js";
export type { CalendarProps, CalendarEvent } from "./calendar.js";
export { RichTextEditor } from "./rich-text-editor.js";
export type { RichTextEditorProps } from "./rich-text-editor.js";

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;