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:
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* lesson-plan-editor(teacher / 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {} },
|
||||
},
|
||||
};
|
||||
258
apps/portal-shell/src/widgets/teacher/question-bank/index.tsx
Normal file
258
apps/portal-shell/src/widgets/teacher/question-bank/index.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* question-bank(teacher / 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {} },
|
||||
},
|
||||
};
|
||||
221
apps/portal-shell/src/widgets/teacher/scheduling-rules/index.tsx
Normal file
221
apps/portal-shell/src/widgets/teacher/scheduling-rules/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* scheduling-rules(teacher / 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {} },
|
||||
},
|
||||
};
|
||||
129
apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx
Normal file
129
apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* textbook-manager(teacher / 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {} },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user