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:
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: {} },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user