feat(portal-shell): extract domain API layer and migrate 31 widgets

Task 4-10 of portal-shell data abstraction plan (M1-M2).

Add 7 domain API modules under src/lib/api/ (parent/admin/teacher/
student/universal/sidebar/topbar), each exposing semantic hooks that
wrap useWidgetQuery/useWidgetMutation and return flattened domain
models. Widget code now imports from @/lib/api instead of inlining
gql literals.

- 31 widgets migrated (gql literal count in widgets: 0)
- 7 test files (85 cases, all passing)
- topbar.useNotifications renamed to useNotificationBell to avoid
  barrel export collision with universal.useNotifications
- typecheck + lint (0 errors) + test (85/85) verified
This commit is contained in:
SpecialX
2026-07-17 13:07:24 +08:00
parent f623dcf4a7
commit 2910a90271
73 changed files with 8206 additions and 87 deletions

View File

@@ -0,0 +1,216 @@
"use client";
/**
* lesson-plan-editorteacher / main
*
* 备课画布:左侧备课列表 + 右侧编辑区。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 lessonPlans 数据,
* 通过 useWidgetMutation 调用 saveLessonPlan 保存。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
useLessonPlans,
useSaveLessonPlan,
type LessonPlan,
type SaveLessonPlanInput,
} from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
function createEmptyDraft(): LessonPlan {
return { id: "", title: "", objectives: "", content: "", resources: [] };
}
export default function LessonPlanEditor(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const { data, loading, refetch } = useLessonPlans(classId);
const { run: saveLessonPlan, loading: saving } = useSaveLessonPlan();
const [draft, setDraft] = useState<LessonPlan>(createEmptyDraft());
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<p className="text-small text-ink-muted"></p>
</section>
);
}
const plans = data ?? [];
const handleSelect = (plan: LessonPlan): void => {
setDraft({ ...plan, resources: [...plan.resources] });
};
const handleNew = (): void => {
setDraft(createEmptyDraft());
};
const handleResourceAdd = (): void => {
setDraft((d) => ({ ...d, resources: [...d.resources, ""] }));
};
const handleResourceChange = (index: number, value: string): void => {
setDraft((d) => {
const next = [...d.resources];
next[index] = value;
return { ...d, resources: next };
});
};
const handleResourceRemove = (index: number): void => {
setDraft((d) => ({
...d,
resources: d.resources.filter((_, i) => i !== index),
}));
};
const handleSave = async (): Promise<void> => {
if (!draft.title.trim()) {
return;
}
const input: SaveLessonPlanInput = {
classId,
id: draft.id || undefined,
title: draft.title,
objectives: draft.objectives,
content: draft.content,
resources: draft.resources.filter((r) => r.trim().length > 0),
};
try {
const saved = await saveLessonPlan(input);
setDraft((d) => ({ ...d, id: saved.id }));
await refetch();
} catch {
/* toast: 保存失败 */
}
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex items-center justify-between">
<h3 className="text-heading-3 text-ink"></h3>
<button
type="button"
onClick={handleNew}
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
>
</button>
</div>
<div className="mt-sm flex gap-md">
<ul className="w-64 shrink-0 space-y-sm">
{plans.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
plans.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => handleSelect(p)}
className={`w-full rounded-button border border-rule px-sm py-xs text-left text-small ${
draft.id === p.id
? "bg-subtle text-ink"
: "bg-surface text-ink"
}`}
>
{p.title || "未命名备课"}
</button>
</li>
))
)}
</ul>
<div className="flex-1 space-y-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<input
type="text"
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="请输入备课标题"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<textarea
value={draft.objectives}
onChange={(e) =>
setDraft((d) => ({ ...d, objectives: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
rows={3}
placeholder="请输入教学目标"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<textarea
value={draft.content}
onChange={(e) =>
setDraft((d) => ({ ...d, content: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
rows={4}
placeholder="请输入教学内容"
/>
</label>
<div className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<ul className="space-y-xs">
{draft.resources.map((r, i) => (
<li key={i} className="flex gap-xs">
<input
type="text"
value={r}
onChange={(e) => handleResourceChange(i, e.target.value)}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="资源名称或链接"
/>
<button
type="button"
onClick={() => handleResourceRemove(i)}
className="rounded-button bg-subtle px-sm py-xs text-small text-ink"
>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={handleResourceAdd}
className="self-start rounded-button bg-subtle px-sm py-xs text-small text-ink"
>
</button>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving || !draft.title.trim()}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent disabled:opacity-50"
>
{saving ? "保存中" : "保存"}
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,23 @@
/**
* lesson-plan-editor 插件清单teacher / main
*
* 备课画布:左侧备课列表 + 右侧编辑区,按 classId 过滤。
* 通过 useWidgetMutation 调用 saveLessonPlan 保存备课内容。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "lesson-plan-editor",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "备课画布",
description: "备课内容编辑与教学资源管理(按 classId 过滤)",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 2 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,258 @@
"use client";
/**
* question-bankteacher / main
*
* 题库管理:按题型与难度筛选题目,展示题干、选项与答案,支持新建题目。
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 questions 数据。
* bankId 从 URL Search Params 读取。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useQuestionBank, type Question } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const TYPE_LABELS: Record<string, string> = {
single_choice: "单选",
multiple_choice: "多选",
fill_blank: "填空",
short_answer: "简答",
essay: "论述",
};
const DIFFICULTY_LABELS: Record<string, string> = {
easy: "简单",
medium: "中等",
hard: "困难",
};
const QUESTION_TYPES = Object.keys(TYPE_LABELS);
const DIFFICULTIES = Object.keys(DIFFICULTY_LABELS);
interface NewQuestion {
type: string;
difficulty: string;
content: string;
answer: string;
}
function createEmptyNewQuestion(): NewQuestion {
return { type: "single_choice", difficulty: "easy", content: "", answer: "" };
}
export default function QuestionBank(_props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const bankId = searchParams.get("bankId") ?? "";
const [typeFilter, setTypeFilter] = useState("");
const [difficultyFilter, setDifficultyFilter] = useState("");
const [showForm, setShowForm] = useState(false);
const [newQuestion, setNewQuestion] = useState<NewQuestion>(
createEmptyNewQuestion,
);
const [localQuestions, setLocalQuestions] = useState<Question[]>([]);
const { data, loading } = useQuestionBank(bankId, {
type: typeFilter || undefined,
limit: 50,
});
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
if (!bankId) {
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<p className="text-small text-ink-muted"></p>
</section>
);
}
const serverQuestions = data ?? [];
let questions = [...localQuestions, ...serverQuestions];
if (difficultyFilter) {
questions = questions.filter((q) => q.difficulty === difficultyFilter);
}
const handleAdd = (): void => {
if (!newQuestion.content.trim()) {
return;
}
const created: Question = {
id: `local-${Date.now()}`,
type: newQuestion.type,
difficulty: newQuestion.difficulty,
content: newQuestion.content,
options: [],
answer: newQuestion.answer,
tags: [],
};
setLocalQuestions((list) => [created, ...list]);
setNewQuestion(createEmptyNewQuestion());
setShowForm(false);
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<div className="flex items-center justify-between">
<h3 className="text-heading-3 text-ink"></h3>
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="rounded-button bg-accent px-sm py-xs text-small text-ink-onAccent"
>
{showForm ? "收起新建" : "新建题目"}
</button>
</div>
<div className="mt-sm flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
<option value=""></option>
{QUESTION_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<select
value={difficultyFilter}
onChange={(e) => setDifficultyFilter(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
<option value=""></option>
{DIFFICULTIES.map((d) => (
<option key={d} value={d}>
{DIFFICULTY_LABELS[d]}
</option>
))}
</select>
</label>
</div>
{showForm ? (
<div className="mt-sm space-y-md rounded-card bg-subtle p-md">
<div className="flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<select
value={newQuestion.type}
onChange={(e) =>
setNewQuestion((q) => ({ ...q, type: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
{QUESTION_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<select
value={newQuestion.difficulty}
onChange={(e) =>
setNewQuestion((q) => ({ ...q, difficulty: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
{DIFFICULTIES.map((d) => (
<option key={d} value={d}>
{DIFFICULTY_LABELS[d]}
</option>
))}
</select>
</label>
</div>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<textarea
value={newQuestion.content}
onChange={(e) =>
setNewQuestion((q) => ({ ...q, content: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
rows={3}
placeholder="请输入题干"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<textarea
value={newQuestion.answer}
onChange={(e) =>
setNewQuestion((q) => ({ ...q, answer: e.target.value }))
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
rows={2}
placeholder="请输入答案"
/>
</label>
<button
type="button"
onClick={handleAdd}
disabled={!newQuestion.content.trim()}
className="rounded-button bg-accent px-md py-xs text-small text-ink-onAccent disabled:opacity-50"
>
</button>
</div>
) : null}
<ul className="mt-sm space-y-md">
{questions.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
questions.map((q) => (
<li key={q.id} className="rounded-button border border-rule p-sm">
<div className="flex flex-wrap gap-xs">
<span className="rounded-button bg-accent px-xs py-xs text-tiny text-ink-onAccent">
{TYPE_LABELS[q.type] ?? q.type}
</span>
<span className="rounded-button bg-subtle px-xs py-xs text-tiny text-ink">
{DIFFICULTY_LABELS[q.difficulty] ?? q.difficulty}
</span>
{q.tags.map((tag) => (
<span
key={tag}
className="rounded-button bg-subtle px-xs py-xs text-tiny text-ink-muted"
>
{tag}
</span>
))}
</div>
<p className="mt-xs text-body text-ink">{q.content}</p>
{q.options.length > 0 ? (
<ul className="mt-xs space-y-xs">
{q.options.map((opt, i) => (
<li key={i} className="text-small text-ink-muted">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
) : null}
<p className="mt-xs text-small text-ink-muted">
{q.answer}
</p>
</li>
))
)}
</ul>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* question-bank 插件清单teacher / main
*
* 题库管理:按题型与难度筛选题目,支持新建题目。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "question-bank",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "题库管理",
description: "按题型与难度筛选题目并支持新建题目",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,221 @@
"use client";
/**
* scheduling-rulesteacher / main
*
* 排课规则:以表格展示班级排课规则,支持行内编辑并通过 useWidgetMutation 保存。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook
*/
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
useSchedulingRules,
useUpdateSchedulingRule,
type SchedulingRule,
type SchedulingRuleInput,
} from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const DAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
function dayName(dayOfWeek: number): string {
return DAY_NAMES[dayOfWeek - 1] ?? `${dayOfWeek}`;
}
export default function SchedulingRules(
_props: PluginProps,
): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const { data, loading, refetch } = useSchedulingRules(classId);
const { run: updateRule, loading: saving } = useUpdateSchedulingRule();
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<SchedulingRule | null>(null);
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<p className="text-small text-ink-muted"></p>
</section>
);
}
const rules = data ?? [];
const handleEdit = (rule: SchedulingRule): void => {
setEditingId(rule.id);
setDraft({ ...rule });
};
const handleCancel = (): void => {
setEditingId(null);
setDraft(null);
};
const handleSave = async (): Promise<void> => {
if (!draft) {
return;
}
const input: SchedulingRuleInput = {
dayOfWeek: draft.dayOfWeek,
periods: draft.periods,
subject: draft.subject,
teacherId: draft.teacherId,
room: draft.room,
};
try {
await updateRule({ id: draft.id, input });
setEditingId(null);
setDraft(null);
await refetch();
} catch {
/* toast: 保存失败 */
}
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rules.length === 0 ? (
<p className="mt-sm text-small text-ink-muted"></p>
) : (
<table className="mt-sm w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{rules.map((rule) => {
const isEditing = editingId === rule.id;
if (isEditing && draft) {
return (
<tr key={rule.id} className="border-b border-rule">
<td className="py-xs">
<select
value={draft.dayOfWeek}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, dayOfWeek: Number(e.target.value) } : d,
)
}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
>
{DAY_NAMES.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
</td>
<td className="py-xs">
<input
type="text"
value={draft.periods}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, periods: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.subject}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, subject: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.teacherId}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, teacherId: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<input
type="text"
value={draft.room}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, room: e.target.value } : d,
)
}
className="w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
/>
</td>
<td className="py-xs">
<div className="flex gap-xs">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-button bg-accent px-sm py-xs text-tiny text-ink-onAccent disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleCancel}
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
>
</button>
</div>
</td>
</tr>
);
}
return (
<tr key={rule.id} className="border-b border-rule">
<td className="py-xs text-ink">{dayName(rule.dayOfWeek)}</td>
<td className="py-xs text-ink">{rule.periods}</td>
<td className="py-xs text-ink">{rule.subject}</td>
<td className="py-xs text-ink">{rule.teacherId}</td>
<td className="py-xs text-ink">{rule.room}</td>
<td className="py-xs">
<button
type="button"
onClick={() => handleEdit(rule)}
className="rounded-button bg-subtle px-sm py-xs text-tiny text-ink"
>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* scheduling-rules 插件清单teacher / main
*
* 排课规则:以表格展示班级排课规则,支持行内编辑并保存。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "scheduling-rules",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "排课规则",
description: "查看并编辑班级排课规则(按 classId 过滤)",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};

View File

@@ -0,0 +1,129 @@
"use client";
/**
* textbook-managerteacher / main
*
* 教材管理:按科目与年级筛选教材,点击教材查看章节列表。
* 通过 useWidgetQuery 查询 apollo-router → content 子图的 textbooks 数据。
*
* 关联portal-shell spec §5.6 统一 Hook
*/
import { useState } from "react";
import { useTextbooks } from "@/lib/api";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function TextbookManager(
_props: PluginProps,
): React.ReactElement {
const [subjectInput, setSubjectInput] = useState("");
const [gradeInput, setGradeInput] = useState("");
const [appliedSubject, setAppliedSubject] = useState("");
const [appliedGrade, setAppliedGrade] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, loading } = useTextbooks({
subjectId: appliedSubject || undefined,
grade: appliedGrade || undefined,
});
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const textbooks = data ?? [];
const selected = textbooks.find((t) => t.id === selectedId) ?? null;
const handleApply = (): void => {
setAppliedSubject(subjectInput.trim());
setAppliedGrade(gradeInput.trim());
setSelectedId(null);
};
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<div className="mt-sm flex gap-md">
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"> ID</span>
<input
type="text"
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="可选,输入科目 ID"
/>
</label>
<label className="flex flex-col space-y-xs">
<span className="text-small text-ink-muted"></span>
<input
type="text"
value={gradeInput}
onChange={(e) => setGradeInput(e.target.value)}
className="rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
placeholder="可选,如:三年级"
/>
</label>
<button
type="button"
onClick={handleApply}
className="self-end rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
</div>
<div className="mt-md flex gap-md">
<ul className="flex-1 space-y-sm">
{textbooks.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
textbooks.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => setSelectedId(t.id)}
className={
selectedId === t.id
? "w-full rounded-button border border-rule bg-subtle p-sm text-left"
: "w-full rounded-button border border-rule bg-surface p-sm text-left"
}
>
<p className="text-body text-ink">{t.title}</p>
<p className="text-small text-ink-muted">
{t.author} · {t.publisher}
</p>
<p className="text-tiny text-ink-muted">ISBN: {t.isbn}</p>
</button>
</li>
))
)}
</ul>
<div className="flex-1">
{selected ? (
<div className="rounded-card border border-rule bg-surface p-md">
<h4 className="text-body text-ink">{selected.title}</h4>
<p className="mt-xs text-small text-ink-muted">
{selected.author} · {selected.publisher}
</p>
<h5 className="mt-md text-small text-ink"></h5>
<ol className="mt-xs space-y-xs">
{selected.chapters.map((c, i) => (
<li key={c.id} className="text-small text-ink">
{i + 1}. {c.title}
</li>
))}
</ol>
</div>
) : (
<div className="rounded-card border border-rule bg-surface p-md text-small text-ink-muted">
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
/**
* textbook-manager 插件清单teacher / main
*
* 教材管理:按科目与年级筛选教材,点击查看章节列表。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "textbook-manager",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "教材管理",
description: "按科目与年级筛选教材并查看章节",
category: "teacher",
requiredRoles: ["teacher"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: {},
propsSchema: { type: "object", properties: {} },
},
};