feat: add four new student modules (rotation, skill-video, exam-prep, academic) with backend APIs and frontend pages; add exam-prep question history with DB persistence

This commit is contained in:
freedakgmail
2026-06-04 11:50:25 +08:00
commit 995f4ecfbd
399 changed files with 73529 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# NestJS 后端地址(开发环境)。/api/* 会被代理到这里,避免浏览器跨域。
BACKEND_URL=http://localhost:3000
+27
View File
@@ -0,0 +1,27 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# typescript
*.tsbuildinfo
next-env.d.ts
+37
View File
@@ -0,0 +1,37 @@
# 医科高校 AI 学习中心 — Web 前端(新版)
参考 `GovAi/apps/web` 的现代化技术栈重建的前端,与原 `frontend/` 并存(原前端保留不动)。
## 技术栈
- **Next.js 15** App Router + **React 19**
- **Tailwind CSS v4**oklch 主题变量,医学青蓝配色,支持暗色模式)
- **shadcn 风格 UI 组件**(自实现,零额外 Radix 依赖)
- **Zustand** 管理认证状态(替代旧版 React Context
- **TanStack Query** 处理数据请求与缓存
- **sonner** 全局 Toast**lucide-react** 图标
## 与后端的契约
完全沿用原前端的后端契约,无需改动 NestJS:
- 所有请求走 `/api/*`,由 `next.config.mjs``rewrites` 代理到 `BACKEND_URL`(默认 `http://localhost:3000`)。
- 令牌存于 `localStorage`(键 `cac.token`),自动附加 `Authorization: Bearer <token>`
- 错误解析后端统一错误体(`AppError.toJSON()`)。
## 功能模块
- **登录 / 注册**:支持三角色注册与内置测试账户一键登录。
- **学生端**:学习空间、能力画像、职业规划、课程对练、临床对话对练、研究资料查询、AI 协同训练。
- **导师端**:题目审核、成果点评、带教学生画像。
- **管理端**:技能治理、权限与合规。
## 开发
```bash
cp .env.example .env.local # 按需修改 BACKEND_URL
npm install
npm run dev # 默认 http://localhost:4100
```
> 注:原前端 `frontend/` 默认运行于 `:4000`,本前端使用 `:4100`,可同时运行对比。
+20
View File
@@ -0,0 +1,20 @@
{
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// 工作区存在多个 lockfile,显式指定本应用为文件追踪根,避免 Next 推断到上层目录。
outputFileTracingRoot: import.meta.dirname,
// 将 /api/* 代理到 NestJS 后端,避免浏览器跨域并统一前端调用路径。
async rewrites() {
const backend = process.env.BACKEND_URL ?? 'http://localhost:3000';
return [
{
source: '/api/:path*',
destination: `${backend}/api/:path*`,
},
];
},
};
export default nextConfig;
+6379
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "college-ai-center-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 4100",
"build": "next build",
"start": "next start -p 4100",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
"next": "^15.5.19",
"next-themes": "^0.4.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint-config-next": "^15.5.19",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+187
View File
@@ -0,0 +1,187 @@
"use client";
/** 权限与合规:查看三类角色权限范围、审计日志与越权拒绝事件(可按操作者过滤)。 */
import { useCallback, useEffect, useState } from "react";
import { ShieldCheck } from "lucide-react";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { adminComplianceApi } from "@/lib/services";
/* eslint-disable @typescript-eslint/no-explicit-any */
export default function AdminCompliancePage() {
const [scopes, setScopes] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [denials, setDenials] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actorId, setActorId] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [sc, logs, dn] = await Promise.all([
adminComplianceApi.permissionScopes(),
adminComplianceApi.auditLogs(actorId || undefined),
adminComplianceApi.denialEvents(actorId || undefined),
]);
setScopes(Array.isArray(sc) ? sc : []);
setAuditLogs(Array.isArray(logs) ? logs : []);
setDenials(Array.isArray(dn) ? dn : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, [actorId]);
useEffect(() => {
load();
}, [load]);
return (
<div className="space-y-6">
<PageHeading
icon={<ShieldCheck className="size-5" />}
title="权限与合规"
description="查看角色权限范围、审计日志与越权拒绝事件。"
/>
<UsageGuide
steps={[
{ title: "查看角色权限", detail: "顶部按学生 / 导师 / 管理员列出各自的权限范围。" },
{ title: "按操作者过滤", detail: "在输入框填写操作者标识,点击「查询」缩小范围。" },
{ title: "审阅审计日志", detail: "左侧查看操作审计记录,追溯关键行为。" },
{ title: "排查越权事件", detail: "右侧查看被拒绝的越权访问,及时发现异常。" },
]}
tip="越权拒绝事件是合规风险信号,建议定期结合操作者标识排查。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{scopes.map((sc: any, i: number) => (
<div
key={sc.role ?? i}
className="rounded-lg border border-border p-3"
>
<p className="mb-2 text-sm font-semibold text-primary">
{sc.role}
</p>
<ul className="space-y-1 text-xs text-muted-foreground">
{(sc.permissions ?? []).map((p: any, pi: number) => (
<li key={pi}>
{typeof p === "string" ? (
<>· {p}</>
) : (
<>
·{" "}
<span className="font-medium">{p.resourceType}</span>
{Array.isArray(p.actions) ? p.actions.join(" / ") : ""}
</>
)}
</li>
))}
</ul>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardAction>
<div className="flex items-center gap-2">
<Input
className="w-48"
value={actorId}
onChange={(e) => setActorId(e.target.value)}
placeholder="按操作者标识过滤"
/>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-2 text-sm font-semibold text-foreground">
{auditLogs.length}
</h3>
{auditLogs.length === 0 ? (
<EmptyState message="暂无审计日志。" />
) : (
<ul className="space-y-2">
{auditLogs.map((log: any, i: number) => (
<li
key={log.id ?? i}
className="rounded-lg border border-border p-3 text-xs text-muted-foreground"
>
<pre className="scrollbar-thin overflow-auto">
{JSON.stringify(log, null, 2)}
</pre>
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-foreground">
{denials.length}
</h3>
{denials.length === 0 ? (
<EmptyState message="暂无越权拒绝事件。" />
) : (
<ul className="space-y-2">
{denials.map((d: any, i: number) => (
<li
key={d.id ?? i}
className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-xs text-destructive"
>
<pre className="scrollbar-thin overflow-auto">
{JSON.stringify(d, null, 2)}
</pre>
</li>
))}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { AdminShell } from "@/components/layout/app-shell";
import { ADMIN_NAV } from "@/lib/navigation";
import { Role } from "@/lib/types";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AdminShell requiredRole={Role.Administrator} nav={ADMIN_NAV}>
{children}
</AdminShell>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { FlaskConical, ShieldCheck, ToggleRight } from "lucide-react";
import { FeatureGrid } from "@/components/feature-grid";
import { StatCard } from "@/components/display";
import { WelcomeBanner } from "@/components/welcome-banner";
import { SkeletonStats } from "@/components/skeletons";
import { ADMIN_NAV } from "@/lib/navigation";
import { adminSkillsApi } from "@/lib/services";
import { useAuthStore } from "@/stores/auth";
export default function AdminHome() {
const user = useAuthStore((s) => s.user);
const { data: skills, isLoading } = useQuery({
queryKey: ["admin-skills"],
queryFn: () => adminSkillsApi.list(),
});
const total = skills?.length ?? 0;
const enabled = skills?.filter((s) => s.enabled).length ?? 0;
return (
<div className="space-y-6">
<WelcomeBanner
greeting="管理控制台"
title={user?.username ?? "管理员"}
description="治理技能定义、管理角色权限与合规审计。"
icon={<ShieldCheck />}
/>
{isLoading ? (
<SkeletonStats count={3} />
) : (
<div className="stagger grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label="技能总数"
value={total}
icon={<FlaskConical />}
hint="已登记技能定义"
tone="primary"
/>
<StatCard
label="已启用"
value={enabled}
icon={<ToggleRight />}
hint={total ? `${Math.round((enabled / total) * 100)}% 启用率` : "—"}
tone="success"
/>
<StatCard
label="合规审计"
value="实时"
icon={<ShieldCheck />}
hint="权限与越权事件"
tone="warning"
/>
</div>
)}
<div>
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<FeatureGrid items={ADMIN_NAV} />
</div>
</div>
);
}
+372
View File
@@ -0,0 +1,372 @@
"use client";
/**
* 技能治理:列出技能定义、查看详情、启用,并以「引导表单 + JSON 编辑器」新增/修改技能。
*/
import { useCallback, useEffect, useState } from "react";
import { FlaskConical } from "lucide-react";
import { toast } from "sonner";
import { InfoRow } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { adminSkillsApi } from "@/lib/services";
import type { SkillAuditLogEntry, SkillDefinition } from "@/lib/types";
import { formatDateTime } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
const FIVE_ELEMENTS_TEMPLATE = JSON.stringify(
{
inputSpec: { fields: [{ name: "items", type: "array", required: true }] },
processingLogic: { strategy: "summarize", model: "mock" },
knowledgeSources: [{ type: "PUBMED", weight: 1 }],
outputFormat: { type: "structured", schema: "summary" },
credibilityRule: { minEvidenceLevel: "B", annotate: true },
},
null,
2,
);
export default function AdminSkillsPage() {
const [skills, setSkills] = useState<SkillDefinition[]>([]);
const [auditLogs, setAuditLogs] = useState<SkillAuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [skillId, setSkillId] = useState("research-summary");
const [skillName, setSkillName] = useState("研究资料总结技能");
const [enabled, setEnabled] = useState(false);
const [elementsJson, setElementsJson] = useState(FIVE_ELEMENTS_TEMPLATE);
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [detail, setDetail] = useState<SkillDefinition | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, logs] = await Promise.all([
adminSkillsApi.list(),
adminSkillsApi.auditLogs(),
]);
setSkills(Array.isArray(s) ? s : []);
setAuditLogs(Array.isArray(logs) ? logs : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleUpsert(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
let elements: any;
try {
elements = JSON.parse(elementsJson);
} catch {
setFormError("五要素 JSON 格式不正确,请检查后重试。");
return;
}
setSubmitting(true);
try {
await adminSkillsApi.upsert({
id: skillId,
name: skillName,
enabled,
inputSpec: elements.inputSpec,
processingLogic: elements.processingLogic,
knowledgeSources: elements.knowledgeSources,
outputFormat: elements.outputFormat,
credibilityRule: elements.credibilityRule,
});
toast.success("技能定义已提交");
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleEnable(id: string) {
setError(null);
try {
await adminSkillsApi.enable(id);
toast.success("技能已启用");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "启用失败");
}
}
function handleEdit(s: SkillDefinition) {
setSkillId(s.id);
setSkillName(s.name);
setEnabled(s.enabled);
setElementsJson(
JSON.stringify(
{
inputSpec: s.inputSpec ?? {},
processingLogic: s.processingLogic ?? {},
knowledgeSources: s.knowledgeSources ?? [],
outputFormat: s.outputFormat ?? {},
credibilityRule: s.credibilityRule ?? {},
},
null,
2,
),
);
setDetail(s);
}
const enabledCount = skills.filter((s) => s.enabled).length;
return (
<div className="space-y-6">
<PageHeading
icon={<FlaskConical className="size-5" />}
title="技能治理"
description="管理技能定义的五要素,启用并审计变更。"
/>
<UsageGuide
steps={[
{ title: "浏览技能列表", detail: "查看已登记技能及启用状态,点「详情 / 编辑」展开。" },
{ title: "编辑五要素", detail: "在表单中填写标识、名称,并用 JSON 编辑五要素定义。" },
{ title: "提交定义", detail: "点击「提交技能定义」新增或更新;可用模板快速起步。" },
{ title: "启用与审计", detail: "对未启用技能点「启用」;所有变更记录在审计日志中。" },
]}
tip="五要素 = 输入规格 / 处理逻辑 / 知识源 / 输出格式 / 可信度规则;JSON 格式错误会被拦截。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardAction>
<div className="flex gap-2">
<Badge variant="success">{enabledCount} </Badge>
<Badge variant="muted">{skills.length} </Badge>
</div>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : skills.length === 0 ? (
<EmptyState message="技能库为空,可在下方新增技能定义。" />
) : (
<ul className="divide-y divide-border">
{skills.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="truncate font-medium text-foreground">{s.name}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{s.id}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{s.enabled ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="muted"></Badge>
)}
<Button variant="ghost" size="sm" onClick={() => setDetail(s)}>
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(s)}>
</Button>
{!s.enabled && (
<Button
variant="outline"
size="sm"
onClick={() => handleEnable(s.id)}
>
</Button>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{detail && (
<Card>
<CardHeader>
<CardTitle> · {detail.name}</CardTitle>
<CardAction>
<Button variant="ghost" size="sm" onClick={() => setDetail(null)}>
</Button>
</CardAction>
</CardHeader>
<CardContent>
<InfoRow label="标识">{detail.id}</InfoRow>
<InfoRow label="名称">{detail.name}</InfoRow>
<InfoRow label="状态">
{detail.enabled ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="muted"></Badge>
)}
</InfoRow>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<ElementCard title="输入规格" data={detail.inputSpec} />
<ElementCard title="AI 处理逻辑" data={detail.processingLogic} />
<ElementCard title="知识源绑定" data={detail.knowledgeSources} />
<ElementCard title="输出格式" data={detail.outputFormat} />
<ElementCard title="可信度标注规则" data={detail.credibilityRule} />
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle> / </CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleUpsert} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<Label></Label>
<Input
value={skillId}
onChange={(e) => setSkillId(e.target.value)}
required
/>
</div>
<div>
<Label></Label>
<Input
value={skillName}
onChange={(e) => setSkillName(e.target.value)}
required
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm text-foreground/80">
<input
type="checkbox"
className="size-4 accent-[var(--primary)]"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</label>
</div>
</div>
<div>
<Label>
inputSpec / processingLogic / knowledgeSources /
outputFormat / credibilityRule
</Label>
<Textarea
className="font-mono text-xs"
rows={14}
value={elementsJson}
onChange={(e) => setElementsJson(e.target.value)}
spellCheck={false}
/>
</div>
{formError && <ErrorBanner message={formError} />}
<div className="flex gap-3">
<Button type="submit" disabled={submitting}>
{submitting ? "提交中…" : "提交技能定义"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setElementsJson(FIVE_ELEMENTS_TEMPLATE)}
>
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{auditLogs.length === 0 ? (
<EmptyState message="暂无审计日志。" />
) : (
<ul className="divide-y divide-border">
{auditLogs.map((log, i) => (
<li key={(log.id as string) ?? i} className="py-3">
<div className="flex items-center gap-2">
{log.action && <Badge variant="info">{String(log.action)}</Badge>}
{log.skillId && (
<span className="text-sm text-foreground">
{String(log.skillId)}
</span>
)}
{log.timestamp && (
<span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(String(log.timestamp))}
</span>
)}
</div>
{(log.actorId || log.actorRole) && (
<p className="mt-1 text-xs text-muted-foreground">
{String(log.actorId ?? "")}
{log.actorRole ? `${String(log.actorRole)}` : ""}
</p>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
/** 技能五要素之一的小卡片。 */
function ElementCard({ title, data }: { title: string; data: unknown }) {
return (
<div className="rounded-lg border border-border p-3">
<p className="mb-1.5 text-xs font-semibold text-muted-foreground">{title}</p>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded bg-muted/60 p-2 text-xs text-muted-foreground">
{JSON.stringify(data ?? {}, null, 2)}
</pre>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-geist-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/*
* 医科高校 AI 学习中心 — 主题
* 以青绿/医学蓝为主色,参考 GovAi 的 oklch 色彩体系,营造专业、清爽的医学场景观感。
*/
:root {
--background: oklch(0.985 0.004 200);
--foreground: oklch(0.155 0.02 220);
--card: oklch(1 0 0);
--card-foreground: oklch(0.155 0.02 220);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.155 0.02 220);
--primary: oklch(0.52 0.11 200);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.96 0.015 200);
--secondary-foreground: oklch(0.30 0.06 210);
--muted: oklch(0.96 0.01 200);
--muted-foreground: oklch(0.50 0.02 215);
--accent: oklch(0.95 0.03 190);
--accent-foreground: oklch(0.30 0.07 205);
--destructive: oklch(0.55 0.22 25);
--success: oklch(0.62 0.14 155);
--success-foreground: oklch(0.99 0 0);
--warning: oklch(0.75 0.15 75);
--warning-foreground: oklch(0.27 0.05 75);
--border: oklch(0.91 0.01 210);
--input: oklch(0.91 0.01 210);
--ring: oklch(0.52 0.11 200);
--chart-1: oklch(0.52 0.13 200);
--chart-2: oklch(0.60 0.14 160);
--chart-3: oklch(0.62 0.15 260);
--chart-4: oklch(0.70 0.14 75);
--chart-5: oklch(0.58 0.20 25);
--radius: 0.625rem;
--sidebar: oklch(0.975 0.008 200);
--sidebar-foreground: oklch(0.155 0.02 220);
--sidebar-primary: oklch(0.50 0.12 200);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.02 195);
--sidebar-accent-foreground: oklch(0.30 0.07 205);
--sidebar-border: oklch(0.91 0.01 210);
--sidebar-ring: oklch(0.52 0.11 200);
}
.dark {
--background: oklch(0.16 0.02 220);
--foreground: oklch(0.985 0.003 200);
--card: oklch(0.21 0.025 220);
--card-foreground: oklch(0.985 0.003 200);
--popover: oklch(0.21 0.025 220);
--popover-foreground: oklch(0.985 0.003 200);
--primary: oklch(0.68 0.12 195);
--primary-foreground: oklch(0.16 0.02 220);
--secondary: oklch(0.28 0.025 220);
--secondary-foreground: oklch(0.985 0.003 200);
--muted: oklch(0.28 0.025 220);
--muted-foreground: oklch(0.70 0.02 210);
--accent: oklch(0.30 0.04 200);
--accent-foreground: oklch(0.985 0.003 200);
--destructive: oklch(0.70 0.19 22);
--success: oklch(0.68 0.14 155);
--success-foreground: oklch(0.16 0.02 220);
--warning: oklch(0.80 0.15 80);
--warning-foreground: oklch(0.20 0.04 80);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.62 0.12 195);
--chart-1: oklch(0.68 0.13 195);
--chart-2: oklch(0.68 0.14 160);
--chart-3: oklch(0.68 0.15 260);
--chart-4: oklch(0.76 0.14 75);
--chart-5: oklch(0.64 0.20 25);
--sidebar: oklch(0.21 0.025 220);
--sidebar-foreground: oklch(0.985 0.003 200);
--sidebar-primary: oklch(0.62 0.14 195);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.30 0.04 200);
--sidebar-accent-foreground: oklch(0.985 0.003 200);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.62 0.12 195);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* 细滚动条,贴合整体清爽风格 */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 9999px;
}
/* 背景细网格/光晕,用于工作台与登录页 */
.bg-grid {
background-image:
linear-gradient(to right, color-mix(in oklch, var(--border) 60%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--border) 60%, transparent) 1px, transparent 1px);
background-size: 28px 28px;
}
}
/* ========================= 微动效 ========================= */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-left {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@keyframes bar-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes pulse-soft {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@layer utilities {
.animate-fade-in {
animation: fade-in 0.4s ease-out both;
}
.animate-fade-in-up {
animation: fade-in-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.animate-fade-in-left {
animation: fade-in-left 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out both;
}
.animate-pulse-soft {
animation: pulse-soft 2.5s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* 子项依次入场(用于卡片网格 / 列表) */
.stagger > * {
animation: fade-in-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger > *:nth-child(1) { animation-delay: 0.02s; }
.stagger > *:nth-child(2) { animation-delay: 0.06s; }
.stagger > *:nth-child(3) { animation-delay: 0.1s; }
.stagger > *:nth-child(4) { animation-delay: 0.14s; }
.stagger > *:nth-child(5) { animation-delay: 0.18s; }
.stagger > *:nth-child(6) { animation-delay: 0.22s; }
.stagger > *:nth-child(7) { animation-delay: 0.26s; }
.stagger > *:nth-child(8) { animation-delay: 0.3s; }
/* 骨架屏微光 */
.skeleton-shimmer {
position: relative;
overflow: hidden;
}
.skeleton-shimmer::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in oklch, var(--foreground) 6%, transparent),
transparent
);
animation: shimmer 1.5s infinite;
}
/* 毛玻璃卡片 */
.glass-card {
background: color-mix(in oklch, var(--card) 85%, transparent);
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
}
/* 学习中心渐变文字 */
.text-gradient {
background: linear-gradient(135deg, var(--primary), oklch(0.60 0.14 160));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-fade-in-up,
.animate-fade-in-left,
.animate-scale-in,
.animate-pulse-soft,
.animate-float,
.stagger > * {
animation: none !important;
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AI 学习中心 · 医科高校",
description: "面向医科类高校的 AI 学习中心 — 学习成果沉淀、能力画像、临床对练与循证研究一体化平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full">
<Providers>{children}</Providers>
<Toaster position="top-center" richColors />
</body>
</html>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
/**
* 登录 / 注册页(品牌化分屏)。
*
* 左侧品牌叙事,右侧表单。支持登录/注册切换、三角色注册与测试账户一键登录。
*/
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
Bot,
HeartPulse,
Search,
Sparkles,
Stethoscope,
Target,
} from "lucide-react";
import { toast } from "sonner";
import { ErrorBanner } from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ApiError } from "@/lib/api";
import { ROLE_LABELS, Role } from "@/lib/types";
import { cn } from "@/lib/utils";
import { homeForRole, useAuthStore } from "@/stores/auth";
const TEST_ACCOUNTS: { username: string; password: string; role: Role }[] = [
{ username: "student", password: "student123", role: Role.Student },
{ username: "mentor", password: "mentor123", role: Role.Mentor },
{ username: "admin", password: "admin123", role: Role.Administrator },
];
const HIGHLIGHTS = [
{ icon: Sparkles, title: "胜任力画像", desc: "六维医学胜任力量化" },
{ icon: Target, title: "执业发展", desc: "个性化执业路径规划" },
{ icon: Stethoscope, title: "临床模拟", desc: "模拟问诊 · 多维评估" },
{ icon: Search, title: "循证检索", desc: "PICO 框架 · 证据分级" },
{ icon: Bot, title: "医学 AI 协作", desc: "人机协作胜任力训练" },
];
export default function LoginPage() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const login = useAuthStore((s) => s.login);
const register = useAuthStore((s) => s.register);
const router = useRouter();
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<Role>(Role.Student);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [quickRole, setQuickRole] = useState<Role | null>(null);
useEffect(() => {
if (!isLoading && user) {
router.replace(homeForRole(user.role));
}
}, [isLoading, user, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const u =
mode === "login"
? await login(username, password)
: await register({ username, password, role, displayName });
toast.success(`欢迎,${u.username}`);
router.replace(homeForRole(u.role));
} catch (err) {
setError(err instanceof ApiError ? (err.body.message ?? "操作失败") : "网络错误,请确认后端服务已启动");
} finally {
setSubmitting(false);
}
}
async function quickLogin(acct: (typeof TEST_ACCOUNTS)[number]) {
setError(null);
setQuickRole(acct.role);
try {
const u = await login(acct.username, acct.password);
toast.success(`已以${ROLE_LABELS[acct.role]}身份登录`);
router.replace(homeForRole(u.role));
} catch (err) {
setError(
err instanceof ApiError
? `${ROLE_LABELS[acct.role]}测试账户登录失败:${err.body.message ?? ""}`
: "网络错误,请确认后端服务已启动",
);
setQuickRole(null);
}
}
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* 左侧品牌区 */}
<div className="relative hidden flex-col justify-between overflow-hidden bg-gradient-to-br from-[oklch(0.38_0.10_215)] via-primary to-[oklch(0.48_0.13_190)] p-10 text-primary-foreground lg:flex">
<div className="bg-grid pointer-events-none absolute inset-0 opacity-[0.05]" />
<div className="pointer-events-none absolute -top-24 -right-16 size-80 rounded-full bg-white/8 blur-3xl" />
<div className="pointer-events-none absolute -bottom-32 -left-10 size-96 rounded-full bg-white/5 blur-3xl" />
<div className="animate-pulse-soft pointer-events-none absolute top-1/3 right-1/4 size-3 rounded-full bg-white/20" />
<div className="relative flex items-center gap-2.5 font-bold">
<span className="flex size-9 items-center justify-center rounded-xl bg-white/15 backdrop-blur-sm">
<HeartPulse className="size-5" />
</span>
<div>
<span className="text-base font-bold tracking-wide">AI </span>
<span className="ml-2 text-xs font-normal text-primary-foreground/60"></span>
</div>
</div>
<div className="relative">
<h2 className="text-3xl leading-tight font-bold md:text-4xl">
<br />
</h2>
<p className="mt-3 max-w-md text-sm leading-relaxed text-primary-foreground/75">
</p>
<div className="mt-8 grid grid-cols-2 gap-3">
{HIGHLIGHTS.map((h) => {
const Icon = h.icon;
return (
<div
key={h.title}
className="flex items-start gap-3 rounded-xl bg-white/10 p-3.5 ring-1 ring-white/10 backdrop-blur-sm transition-colors hover:bg-white/15"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-white/15">
<Icon className="size-4.5" />
</span>
<div>
<p className="text-sm font-semibold">{h.title}</p>
<p className="text-xs text-primary-foreground/65">{h.desc}</p>
</div>
</div>
);
})}
</div>
</div>
<p className="relative text-xs text-primary-foreground/50">
© {new Date().getFullYear()} AI · · · · ·
</p>
</div>
{/* 右侧表单区 */}
<div className="flex items-center justify-center bg-background px-4 py-10">
<div className="w-full max-w-md animate-fade-in-up">
<div className="mb-6 text-center lg:hidden">
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-[oklch(0.48_0.13_190)] text-primary-foreground shadow-lg shadow-primary/20">
<HeartPulse className="size-7" />
</div>
<h1 className="text-2xl font-bold text-foreground">AI </h1>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
<div className="rounded-2xl bg-card p-6 shadow-sm ring-1 ring-foreground/10">
<div className="mb-1 text-lg font-semibold text-foreground">
{mode === "login" ? "登录账户" : "创建账户"}
</div>
<p className="mb-5 text-sm text-muted-foreground">
{mode === "login" ? "欢迎回来,请输入凭据。" : "注册以体验三端功能。"}
</p>
<div className="mb-5 grid grid-cols-2 gap-1 rounded-lg bg-muted p-1">
{(["login", "register"] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setError(null);
}}
className={cn(
"rounded-md py-2 text-sm font-medium transition",
mode === m
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{m === "login" ? "登录" : "注册"}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="至少 3 个字符"
autoComplete="username"
required
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 8 个字符"
autoComplete={mode === "login" ? "current-password" : "new-password"}
required
/>
</div>
{mode === "register" && (
<>
<div>
<Label htmlFor="displayName"></Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="如:张三"
required
/>
</div>
<div>
<Label></Label>
<div className="grid grid-cols-3 gap-2">
{Object.values(Role).map((r) => (
<button
type="button"
key={r}
onClick={() => setRole(r)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition",
role === r
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:bg-muted",
)}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
</div>
</>
)}
{error && <ErrorBanner message={error} />}
<Button type="submit" className="w-full" size="lg" disabled={submitting}>
{submitting ? "处理中…" : mode === "login" ? "登录" : "注册并登录"}
</Button>
</form>
<div className="mt-5 border-t border-border pt-4">
<p className="mb-2 text-center text-xs text-muted-foreground">
·
</p>
<div className="grid grid-cols-3 gap-2">
{TEST_ACCOUNTS.map((acct) => (
<button
key={acct.role}
type="button"
onClick={() => quickLogin(acct)}
disabled={quickRole !== null}
className="flex flex-col items-center rounded-lg border border-border px-2 py-2 text-center transition hover:border-primary hover:bg-primary/5 disabled:opacity-60"
>
<span className="text-sm font-medium text-foreground">
{ROLE_LABELS[acct.role]}
</span>
<span className="mt-0.5 text-[11px] text-muted-foreground">
{quickRole === acct.role ? "登录中…" : acct.username}
</span>
</button>
))}
</div>
<p className="mt-2 text-center text-[11px] text-muted-foreground/70">
student123 / mentor123 / admin123
</p>
</div>
</div>
<p className="mt-4 text-center text-xs text-muted-foreground/70">
http://localhost:3000
</p>
</div>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
/** 成果点评:对指定学习成果添加点评,并查看该成果的全部点评。 */
import { useState } from "react";
import { MessageSquareText } from "lucide-react";
import { toast } from "sonner";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { mentorApi } from "@/lib/services";
/* eslint-disable @typescript-eslint/no-explicit-any */
export default function MentorCommentsPage() {
const [achievementId, setAchievementId] = useState("");
const [comment, setComment] = useState("");
const [comments, setComments] = useState<any[] | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function loadComments() {
if (!achievementId) return;
setError(null);
setBusy(true);
try {
const v = await mentorApi.listComments(achievementId);
setComments(Array.isArray(v) ? v : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setBusy(false);
}
}
async function addComment(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await mentorApi.addComment(achievementId, comment);
setComment("");
toast.success("点评已提交");
await loadComments();
} catch (err) {
setError(err instanceof ApiError ? err.message : "点评失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<MessageSquareText className="size-5" />}
title="教学点评"
description="对学生的学业成果进行教学性点评,指导临床与科研能力提升,点评对学生可见。"
/>
<UsageGuide
steps={[
{ title: "输入成果标识", detail: "填写学生学习成果的标识编号。" },
{ title: "查看已有点评", detail: "点击「查看点评」加载该成果的历史点评。" },
{ title: "填写点评内容", detail: "输入对该成果的具体点评意见。" },
{ title: "提交点评", detail: "点击「提交点评」,点评将对该学生可见。" },
]}
tip="点评一旦提交即对学生可见,建议给出具体、可操作的改进建议。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={addComment} className="space-y-4">
<div>
<Label></Label>
<div className="flex gap-2">
<Input
value={achievementId}
onChange={(e) => setAchievementId(e.target.value)}
placeholder="输入学习成果的标识"
required
/>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={loadComments}
disabled={!achievementId || busy}
>
</Button>
</div>
</div>
<div>
<Label></Label>
<Textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="填写对该成果的点评(对学生可见)"
required
/>
</div>
{error && <ErrorBanner message={error} />}
<Button type="submit" disabled={busy}>
{busy ? "提交中…" : "提交点评"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{busy && <Loading />}
{comments === null ? (
<EmptyState message="输入成果标识并点击「查看点评」。" />
) : comments.length === 0 ? (
<EmptyState message="该成果暂无点评。" />
) : (
<ul className="space-y-2">
{comments.map((c: any, i: number) => (
<li
key={c.id ?? i}
className="rounded-lg border border-border p-3 text-sm text-foreground/80"
>
{c.comment ?? c.content ?? JSON.stringify(c)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { PortalShell } from "@/components/layout/app-shell";
import { MENTOR_NAV } from "@/lib/navigation";
import { Role } from "@/lib/types";
export default function MentorLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PortalShell requiredRole={Role.Mentor} nav={MENTOR_NAV}>
{children}
</PortalShell>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { ClipboardCheck, MessageSquareText, Users } from "lucide-react";
import { FeatureGrid } from "@/components/feature-grid";
import { StatCard } from "@/components/display";
import { WelcomeBanner } from "@/components/welcome-banner";
import { MENTOR_NAV } from "@/lib/navigation";
import { useAuthStore } from "@/stores/auth";
export default function MentorHome() {
const user = useAuthStore((s) => s.user);
return (
<div className="space-y-6">
<WelcomeBanner
greeting="临床带教工作台"
title={user?.username ?? "导师"}
description="审核医学题库质量、点评学生学业成果、追踪带教学生胜任力发展。"
icon={<Users />}
/>
<div className="stagger grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label="题库审核"
value="待处理"
icon={<ClipboardCheck />}
hint="把控出题质量"
tone="primary"
/>
<StatCard
label="教学点评"
value="进行中"
icon={<MessageSquareText />}
hint="指导学生成长"
tone="success"
/>
<StatCard
label="学生胜任力"
value="画像"
icon={<Users />}
hint="合规脱敏访问"
tone="warning"
/>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<FeatureGrid items={MENTOR_NAV} />
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
/** 题目审核:输入题目标识,通过或退回(退回须填原因)。 */
import { useState } from "react";
import { ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
import { ErrorBanner, PageHeading } from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { mentorApi } from "@/lib/services";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
export default function MentorReviewPage() {
const [questionId, setQuestionId] = useState("");
const [decision, setDecision] = useState<"approve" | "return">("approve");
const [reason, setReason] = useState("");
const [result, setResult] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
setResult(null);
try {
const v = await mentorApi.reviewQuestion(questionId, {
decision,
reason: decision === "return" ? reason : undefined,
});
setResult(v);
toast.success(decision === "approve" ? "已通过" : "已退回");
} catch (err) {
setError(err instanceof ApiError ? err.message : "审核失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<ClipboardCheck className="size-5" />}
title="题库审核"
description="审核学生提交的医学练习题,把控题库质量,通过或退回并说明原因。"
/>
<UsageGuide
steps={[
{ title: "输入题目标识", detail: "填写待审核题目的标识编号。" },
{ title: "选择审核决定", detail: "「通过」或「退回」。" },
{ title: "退回须填原因", detail: "选择退回时,需说明原因供学生修改。" },
{ title: "提交审核", detail: "点击「提交审核」,结果会显示在下方。" },
]}
tip="通过的题目才会进入学生可对练题库;退回原因会反馈给出题学生。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div>
<Label></Label>
<Input
value={questionId}
onChange={(e) => setQuestionId(e.target.value)}
placeholder="输入待审核题目的标识"
required
/>
</div>
<div>
<Label></Label>
<div className="flex gap-2">
{(["approve", "return"] as const).map((d) => (
<button
type="button"
key={d}
onClick={() => setDecision(d)}
className={cn(
"rounded-lg border px-4 py-2 text-sm transition",
decision === d
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:bg-muted",
)}
>
{d === "approve" ? "通过" : "退回"}
</button>
))}
</div>
</div>
{decision === "return" && (
<div>
<Label>退</Label>
<Textarea
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="说明退回原因"
required
/>
</div>
)}
{error && <ErrorBanner message={error} />}
<Button type="submit" disabled={busy}>
{busy ? "提交中…" : "提交审核"}
</Button>
</form>
{result && (
<div className="mt-4">
<h3 className="mb-2 text-sm font-semibold text-foreground">
</h3>
<pre className="scrollbar-thin overflow-auto rounded-lg bg-muted/60 p-3 text-xs text-muted-foreground">
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
/** 带教学生画像:列出所带学生 → 查看某学生的画像(合规授权与脱敏由后端负责)。 */
import { useCallback, useEffect, useState } from "react";
import { Users } from "lucide-react";
import { RawDetails } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { mentorApi } from "@/lib/services";
import type { StudentProfileView } from "@/lib/types";
import { cn } from "@/lib/utils";
export default function MentorStudentsPage() {
const [students, setStudents] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [profile, setProfile] = useState<StudentProfileView | null>(null);
const [activeStudent, setActiveStudent] = useState<string | null>(null);
const [profileError, setProfileError] = useState<string | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const v = await mentorApi.listStudents();
setStudents(Array.isArray(v) ? v : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function viewProfile(studentId: string) {
setActiveStudent(studentId);
setProfile(null);
setProfileError(null);
setProfileLoading(true);
try {
setProfile(await mentorApi.viewStudentProfile(studentId));
} catch (err) {
setProfileError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setProfileLoading(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Users className="size-5" />}
title="学生胜任力"
description="查看带教学生的胜任力画像与发展情况(合规脱敏授权由系统保障)。"
/>
<UsageGuide
steps={[
{ title: "选择学生", detail: "在左侧列表点击一名带教学生。" },
{ title: "查看画像", detail: "右侧展示该学生的能力画像与字段明细。" },
{ title: "留意授权状态", detail: "顶部标签显示完整访问 / 按授权范围 / 无授权。" },
{ title: "理解脱敏", detail: "敏感字段会按合规规则自动脱敏显示为「已脱敏」。" },
]}
tip="访问受合规授权约束,仅可见授权范围内的信息;所有查看行为均会被审计记录。"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : students.length === 0 ? (
<EmptyState message="暂无带教学生。" />
) : (
<ul className="space-y-1">
{students.map((s) => (
<li key={s}>
<button
onClick={() => viewProfile(s)}
className={cn(
"w-full rounded-lg px-3 py-2 text-left text-sm transition",
activeStudent === s
? "bg-primary/10 font-medium text-primary"
: "text-foreground/80 hover:bg-muted",
)}
>
{s}
</button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{!activeStudent ? (
<EmptyState message="从左侧选择一名学生查看画像。" />
) : profileLoading ? (
<Loading />
) : profileError ? (
<ErrorBanner message={profileError} />
) : profile ? (
<ProfileViewCard profile={profile} />
) : null}
</CardContent>
</Card>
</div>
</div>
);
}
/** 学生画像视图:授权状态 + 字段(敏感字段脱敏标记)。 */
function ProfileViewCard({ profile }: { profile: StudentProfileView }) {
return (
<div className="space-y-4">
{profile.notice && (
<p className="rounded-lg bg-warning/15 px-4 py-3 text-sm text-warning-foreground">
{profile.notice}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{profile.fullAccess ? (
<Badge variant="success">访</Badge>
) : profile.authorized ? (
<Badge variant="info"></Badge>
) : (
<Badge variant="destructive"></Badge>
)}
{profile.redactedFieldKeys?.length > 0 && (
<Badge variant="warning">
{profile.redactedFieldKeys.length}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{profile.purpose}
</span>
</div>
<div className="divide-y divide-border">
{profile.fields?.map((f) => (
<div
key={f.key}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="flex items-center gap-1.5 text-muted-foreground">
{f.key}
{f.sensitive && <Badge variant="warning"></Badge>}
</span>
<span
className={
f.redacted ? "text-muted-foreground/50" : "font-medium text-foreground"
}
>
{f.redacted
? "已脱敏"
: typeof f.value === "object"
? JSON.stringify(f.value)
: String(f.value)}
</span>
</div>
))}
</div>
<RawDetails data={profile} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { FullPageLoading } from "@/components/feedback";
import { homeForRole, useAuthStore } from "@/stores/auth";
/** 入口页:依据登录态与角色重定向到对应工作台或登录页。 */
export default function HomePage() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
useEffect(() => {
if (isLoading) return;
router.replace(user ? homeForRole(user.role) : "/login");
}, [user, isLoading, router]);
return <FullPageLoading label="正在进入…" />;
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import {
BookOpenText,
MessageSquareText,
Send,
Users,
} from "lucide-react";
import { RawDetails } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { academicApi } from "@/lib/services";
const TYPES = [
{ value: "journal-club", label: "文献汇报" },
{ value: "case-discussion", label: "病例讨论" },
{ value: "seminar", label: "专题讲座" },
{ value: "rounds", label: "教学查房" },
];
export default function AcademicPage() {
const [records, setRecords] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [type, setType] = useState("");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [mentorFeedback, setMentorFeedback] = useState("");
const [submitting, setSubmitting] = useState(false);
async function loadRecords() {
setLoading(true);
setError(null);
try {
const res = await academicApi.listRecords();
setRecords(Array.isArray(res) ? res : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await academicApi.addRecord({
type: type || undefined,
title,
content,
mentorFeedback: mentorFeedback || undefined,
});
setType("");
setTitle("");
setContent("");
setMentorFeedback("");
await loadRecords();
} catch (err) {
setError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Users className="size-5" />}
title="学术交流"
description="文献汇报、病例讨论记录与导师反馈,沉淀学术交流轨迹。"
/>
<UsageGuide
steps={[
{ title: "选择交流类型", detail: "文献汇报、病例讨论、专题讲座或教学查房。" },
{ title: "填写主题与内容", detail: "记录汇报主题、核心观点与个人收获。" },
{ title: "记录导师反馈", detail: "将导师的点评与建议同步记录,便于后续回顾。" },
{ title: "查看交流轨迹", detail: "按时间线查看所有学术交流记录与成长脉络。" },
]}
tip="高质量的学术交流记录是胜任力画像中「学术与科研」维度的重要数据来源,建议每次活动后 24 小时内完成记录。"
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<Label></Label>
<Select value={type} onChange={(e) => setType(e.target.value)} required>
<option value=""></option>
{TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:糖尿病肾病最新诊疗进展"
required
/>
</div>
<div className="sm:col-span-2">
<Label></Label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="记录汇报内容、核心观点、个人收获…"
rows={4}
required
/>
</div>
<div className="sm:col-span-2">
<Label></Label>
<Textarea
value={mentorFeedback}
onChange={(e) => setMentorFeedback(e.target.value)}
placeholder="导师点评、建议与改进方向…"
rows={3}
/>
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" disabled={submitting}>
{submitting ? "提交中…" : "提交记录"}
</Button>
<Button type="button" variant="outline" onClick={loadRecords} disabled={loading}>
{loading ? "加载中…" : "刷新记录"}
</Button>
</div>
{error && (
<div className="sm:col-span-2">
<ErrorBanner message={error} />
</div>
)}
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpenText className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : records.length === 0 ? (
<EmptyState message="暂无学术交流记录,请添加第一条记录。" />
) : (
<div className="space-y-4">
{records.map((r: any, i: number) => (
<div
key={r.id ?? i}
className="rounded-lg border border-border p-4"
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">
{TYPES.find((t) => t.value === r.type)?.label ?? r.type}
</Badge>
<span className="text-sm font-semibold text-foreground">{r.title}</span>
</div>
{r.date && (
<span className="text-xs text-muted-foreground">{r.date}</span>
)}
</div>
<p className="text-sm leading-relaxed text-foreground/80">{r.content}</p>
{r.mentorFeedback && (
<div className="mt-3 rounded-lg bg-muted/50 p-3">
<p className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
<MessageSquareText className="size-3" />
</p>
<p className="text-sm text-foreground/80">{r.mentorFeedback}</p>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
/** 职业规划:设定职业目标 → 关联岗位胜任力模型 → 生成动态闭环发展规划。 */
import { useCallback, useEffect, useState } from "react";
import { BookMarked, Target } from "lucide-react";
import { toast } from "sonner";
import { InfoRow, RawDetails, ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { careerApi } from "@/lib/services";
import type {
CareerGoalAssociation,
CompetencyGap,
DevelopmentPlan,
} from "@/lib/types";
export default function CareerPage() {
const [goal, setGoal] = useState<CareerGoalAssociation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [goalId, setGoalId] = useState("");
const [goalTitle, setGoalTitle] = useState("");
const [goalDesc, setGoalDesc] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [plan, setPlan] = useState<DevelopmentPlan | null>(null);
const [planError, setPlanError] = useState<string | null>(null);
const [planning, setPlanning] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setGoal(await careerApi.getGoal());
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleSetGoal(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
setSubmitting(true);
try {
await careerApi.setGoal({
id: goalId,
title: goalTitle,
description: goalDesc || undefined,
});
toast.success("职业目标已更新");
await load();
} catch (err) {
if (err instanceof ApiError) {
const recs = (err.body.details as { recommendedGoals?: unknown[] })
?.recommendedGoals;
setFormError(
recs?.length
? `${err.message}(建议目标:${recs
.map((r) => {
const g = r as { title?: string; id?: string };
return g.title ?? g.id ?? String(r);
})
.join("、")}`
: err.message,
);
} else {
setFormError("设定失败");
}
} finally {
setSubmitting(false);
}
}
async function handleGeneratePlan() {
const gid = goal?.goal?.id ?? goalId;
if (!gid) {
setPlanError("请先设定职业目标");
return;
}
setPlanError(null);
setPlanning(true);
try {
setPlan(await careerApi.generatePlan(gid));
} catch (err) {
setPlanError(err instanceof ApiError ? err.message : "生成失败");
} finally {
setPlanning(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Target className="size-5" />}
title="执业发展"
description="设定临床医师、住院医师等执业方向,关联岗位胜任力模型,生成个性化发展规划。"
/>
<UsageGuide
steps={[
{ title: "设定职业目标", detail: "填写目标标识(如「临床医师」)、名称与可选描述。" },
{ title: "查看胜任力模型", detail: "设定后系统自动关联岗位所需的各维度能力要求。" },
{ title: "生成发展规划", detail: "点击「生成发展规划」对比当前能力与目标,找出差距。" },
{ title: "按建议提升", detail: "针对每项差距查看建议行动与推荐学习资源 / 对练任务。" },
]}
tip="若目标标识无法识别,系统会给出推荐目标;先在「成长档案」积累学业成果可让差距分析更准确。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={handleSetGoal}
className="grid grid-cols-1 gap-4 sm:grid-cols-3"
>
<div>
<Label></Label>
<Input
value={goalId}
onChange={(e) => setGoalId(e.target.value)}
placeholder="如:临床医师"
required
/>
</div>
<div>
<Label></Label>
<Input
value={goalTitle}
onChange={(e) => setGoalTitle(e.target.value)}
placeholder="如:临床医师"
required
/>
</div>
<div>
<Label></Label>
<Input
value={goalDesc}
onChange={(e) => setGoalDesc(e.target.value)}
/>
</div>
{formError && (
<div className="sm:col-span-3">
<ErrorBanner message={formError} />
</div>
)}
<div className="sm:col-span-3">
<Button type="submit" disabled={submitting}>
{submitting ? "提交中…" : "设定目标"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardAction>
<Button
variant="outline"
size="sm"
onClick={handleGeneratePlan}
disabled={planning}
>
{planning ? "生成中…" : "生成发展规划"}
</Button>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : !goal ? (
<EmptyState message="尚未设定职业目标。" />
) : (
<div className="space-y-4">
<div className="rounded-lg bg-muted/50 p-4">
<InfoRow label="职业目标">{goal.goal?.title}</InfoRow>
<InfoRow label="参照框架">{goal.model?.framework}</InfoRow>
{goal.goal?.description && (
<InfoRow label="目标说明">{goal.goal.description}</InfoRow>
)}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{goal.model?.dimensions?.map((d) => (
<div
key={d.dimension}
className="rounded-lg border border-border p-3"
>
<p className="mb-2 text-sm font-semibold text-primary">
{d.dimensionName}
</p>
<div className="space-y-2">
{d.competencyTags?.map((t) => (
<ScoreBar
key={t.tagId}
label={t.tagName ?? t.tagId}
score={t.requiredLevel}
tone="primary"
/>
))}
</div>
</div>
))}
</div>
</div>
)}
{planError && (
<div className="mt-4">
<ErrorBanner message={planError} />
</div>
)}
</CardContent>
</Card>
{plan && <DevelopmentPlanView plan={plan} />}
</div>
);
}
/** 发展规划展示:能力差距项 + 建议行动 + 推荐资源。 */
function DevelopmentPlanView({ plan }: { plan: DevelopmentPlan }) {
const gapCount = plan.gaps?.length ?? 0;
const missingCount = plan.gaps?.filter((g) => g.missingData).length ?? 0;
return (
<Card>
<CardHeader>
<CardTitle> · {plan.goalTitle}</CardTitle>
<CardAction>
<div className="flex gap-2">
<Badge variant="warning">{gapCount} </Badge>
{missingCount > 0 && (
<Badge variant="muted">{missingCount} </Badge>
)}
</div>
</CardAction>
</CardHeader>
<CardContent>
{gapCount === 0 ? (
<EmptyState message="未发现能力差距,已达到目标岗位要求。" />
) : (
<ul className="space-y-4">
{plan.gaps.map((gap) => (
<GapItem key={gap.tagId} gap={gap} />
))}
</ul>
)}
<RawDetails data={plan} />
</CardContent>
</Card>
);
}
function GapItem({ gap }: { gap: CompetencyGap }) {
return (
<li className="rounded-lg border border-border p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="font-medium text-foreground">{gap.tagName}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{gap.dimensionName}
</p>
</div>
{gap.missingData ? (
<Badge variant="muted"></Badge>
) : (
<Badge variant="warning"></Badge>
)}
</div>
<ScoreBar
label="当前水平"
score={gap.missingData ? "insufficient_data" : gap.currentLevel}
required={gap.requiredLevel}
tone="destructive"
/>
{gap.suggestedActions?.length > 0 && (
<div className="mt-3">
<p className="mb-1 text-xs font-semibold text-muted-foreground">
</p>
<ul className="space-y-1">
{gap.suggestedActions.map((a, i) => (
<li key={i} className="text-sm text-foreground/80">
· {a.description}
<span className="ml-1 text-xs text-muted-foreground">
{a.targetLevel}
</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-3">
<p className="mb-1 flex items-center gap-1 text-xs font-semibold text-muted-foreground">
<BookMarked className="size-3.5" />
/
</p>
{gap.recommendedResources?.length > 0 ? (
<div className="flex flex-wrap gap-2">
{gap.recommendedResources.map((r) => (
<span
key={r.id}
className="rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary"
>
{r.type === "practice_task" ? "🎯 " : "📘 "}
{r.title}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
{gap.resourceNote ?? "暂无可推荐资源"}
</p>
)}
</div>
</li>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import { BriefcaseMedical, ChevronRight, Send } from "lucide-react";
import { CredibilityBadge, RawDetails, ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { caseReasoningApi } from "@/lib/services";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
const STEP_LABELS: Record<string, string> = {
chief_complaint: "主诉与现病史",
history: "既往史与个人史",
physical_exam: "体格检查",
lab_results: "辅助检查",
differential: "鉴别诊断",
diagnosis: "最终诊断",
treatment: "治疗方案",
};
export default function CaseReasoningPage() {
const [cases, setCases] = useState<any[]>([]);
const [session, setSession] = useState<any>(null);
const [steps, setSteps] = useState<any[]>([]);
const [answer, setAnswer] = useState("");
const [report, setReport] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
after?.(await fn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
const currentStep = session?.currentStep ?? session?.step ?? null;
const stepLabel = currentStep ? (STEP_LABELS[currentStep] ?? currentStep) : null;
return (
<div className="space-y-6">
<PageHeading
icon={<BriefcaseMedical className="size-5" />}
title="病例推演"
description="渐进式披露临床信息,训练系统化的鉴别诊断与临床决策思维。"
/>
<UsageGuide
steps={[
{ title: "选择病例", detail: "从病例库中选择一个临床案例,或由 AI 随机生成。" },
{ title: "逐步推理", detail: "系统依次呈现主诉、病史、体查、检查结果,每步需写出你的分析。" },
{ title: "鉴别诊断", detail: "基于已有信息列出鉴别诊断,说明支持与排除依据。" },
{ title: "总结评估", detail: "完成全部步骤后获得临床推理能力评估报告。" },
]}
tip="尽量在每一步做出独立判断后再查看下一步信息,这样能最大化锻炼临床思维。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button
disabled={busy}
onClick={() =>
run(
() => caseReasoningApi.listCases(),
(v: any) => setCases(Array.isArray(v) ? v : []),
)
}
>
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => caseReasoningApi.startSession("random"),
(v: any) => {
setSession(v);
setSteps([]);
setReport(null);
},
)
}
>
</Button>
</div>
{cases.length > 0 && (
<ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{cases.map((c: any, i: number) => (
<li
key={c.id ?? i}
className={cn(
"cursor-pointer rounded-lg border p-3 transition hover:border-primary/40",
session?.caseId === c.id
? "border-primary bg-primary/5"
: "border-border",
)}
onClick={() =>
run(
() => caseReasoningApi.startSession(c.id),
(v: any) => {
setSession(v);
setSteps([]);
setReport(null);
},
)
}
>
<p className="text-sm font-medium text-foreground">
{c.title ?? c.name ?? `病例 ${i + 1}`}
</p>
{c.category && (
<Badge variant="info" className="mt-1">{c.category}</Badge>
)}
{c.difficulty && (
<span className="ml-2 text-xs text-muted-foreground">
{c.difficulty}
</span>
)}
</li>
))}
</ul>
)}
{error && !session && (
<div className="mt-4"><ErrorBanner message={error} /></div>
)}
</CardContent>
</Card>
{session && !report && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{stepLabel && (
<Badge variant="info">{stepLabel}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{/* 已完成的步骤 */}
{steps.length > 0 && (
<div className="mb-4 space-y-3">
{steps.map((s: any, i: number) => (
<div
key={i}
className="rounded-lg border border-border bg-muted/30 p-3"
>
<div className="mb-1 flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
{i + 1}
</span>
<span className="text-xs font-semibold text-foreground">
{STEP_LABELS[s.step] ?? s.step}
</span>
</div>
{s.disclosure && (
<p className="mt-1 text-sm text-foreground/80">{s.disclosure}</p>
)}
<p className="mt-1 text-sm text-muted-foreground">
{s.studentAnswer}
</p>
{s.feedback && (
<p className="mt-1 text-xs text-primary">{s.feedback}</p>
)}
</div>
))}
</div>
)}
{/* 当前信息披露 */}
{session.currentDisclosure && (
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/[0.04] p-4">
<p className="mb-1 text-xs font-semibold text-primary">
</p>
<p className="text-sm text-foreground">
{session.currentDisclosure}
</p>
</div>
)}
<div className="space-y-3">
<Textarea
rows={3}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="写出你对当前信息的分析、初步判断或鉴别诊断思路…"
/>
<div className="flex gap-3">
<Button
disabled={busy || !answer}
onClick={() =>
run(
() =>
caseReasoningApi.submitReasoning(session.id, {
step: currentStep ?? "unknown",
answer,
}),
(v: any) => {
setSteps((prev) => [
...prev,
{
step: currentStep,
studentAnswer: answer,
disclosure: session.currentDisclosure,
feedback: v?.feedback,
},
]);
setAnswer("");
if (v?.session) setSession(v.session);
else if (v?.nextStep) {
setSession((s: any) => ({
...s,
currentStep: v.nextStep,
currentDisclosure: v.disclosure,
}));
}
},
)
}
>
<Send className="size-4" />
{stepLabel && (
<ChevronRight className="ml-1 size-3 text-primary-foreground/60" />
)}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => caseReasoningApi.finishSession(session.id),
(v: any) => setReport(v),
)
}
>
</Button>
</div>
</div>
{busy && <Loading />}
{error && <div className="mt-4"><ErrorBanner message={error} /></div>}
</CardContent>
</Card>
)}
{report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{report.dimensions?.map((d: any) => (
<ScoreBar
key={d.dimension}
label={d.dimensionName ?? d.dimension}
score={d.score}
tone={typeof d.score === "number" && d.score >= 60 ? "success" : "warning"}
/>
))}
{report.summary && (
<div className="rounded-lg bg-muted/50 p-4 text-sm text-foreground/80">
{report.summary}
</div>
)}
{report.annotation && (
<CredibilityBadge annotation={report.annotation} />
)}
</div>
<RawDetails data={report} />
</CardContent>
</Card>
)}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
"use client";
/**
* 临床情景对话对练:输入情景标识发起会话 → 逐轮对话 → 结束生成三维评估报告。
*/
import { useState } from "react";
import { Send, Stethoscope } from "lucide-react";
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from "@/components/display";
import { ErrorBanner, Loading, PageHeading } from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { clinicalApi } from "@/lib/services";
import type { DialogueReport } from "@/lib/types";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ChatTurn {
role: "student" | "system";
text: string;
}
export default function ClinicalPage() {
const [scenarioId, setScenarioId] = useState("");
const [session, setSession] = useState<any>(null);
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [input, setInput] = useState("");
const [report, setReport] = useState<DialogueReport | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
after?.(await fn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
function extractSystemReply(v: any): string {
return (
v?.systemResponse ??
v?.reply ??
v?.message ??
v?.turn?.systemResponse ??
JSON.stringify(v)
);
}
function sendTurn() {
if (!input || busy || !session) return;
const text = input;
setTurns((t) => [...t, { role: "student", text }]);
setInput("");
run(
() => clinicalApi.sendTurn(session.id, { studentInput: text }),
(v: any) =>
setTurns((t) => [...t, { role: "system", text: extractSystemReply(v) }]),
);
}
return (
<div className="space-y-6">
<PageHeading
icon={<Stethoscope className="size-5" />}
title="临床模拟"
description="模拟真实问诊、查体与医患沟通情景,与 AI 患者交互后获得多维评估反馈。"
/>
<UsageGuide
steps={[
{ title: "输入情景标识", detail: "如「胸痛分诊」,选择要演练的临床情景。" },
{ title: "开始对话", detail: "点击「开始对话」进入情景,AI 扮演患者 / 同行角色。" },
{ title: "逐轮问诊", detail: "在输入框输入问诊或处置内容,回车或「发送」推进对话。" },
{ title: "结束并评估", detail: "点击「结束并评估」获得沟通、临床思维等三维评分与点评。" },
]}
tip="尽量像真实问诊一样有条理地展开;评估报告含可信度标注,注意核验 AI 给出的信息。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[220px] flex-1">
<Label></Label>
<Input
value={scenarioId}
onChange={(e) => setScenarioId(e.target.value)}
placeholder="如:胸痛分诊"
/>
</div>
<Button
disabled={!scenarioId || busy}
onClick={() =>
run(
() => clinicalApi.start(scenarioId),
(v: any) => {
setSession(v);
setTurns([]);
setReport(null);
},
)
}
>
</Button>
</div>
{error && (
<div className="mt-4">
<ErrorBanner message={error} />
</div>
)}
</CardContent>
</Card>
{session && !report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="scrollbar-thin mb-4 max-h-96 space-y-3 overflow-auto">
{turns.length === 0 && (
<p className="text-sm text-muted-foreground">
</p>
)}
{turns.map((t, i) => (
<div
key={i}
className={cn(
"flex",
t.role === "student" ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-2 text-sm",
t.role === "student"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{t.text}
</div>
</div>
))}
</div>
{busy && <Loading />}
<div className="flex gap-3">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入对话内容…"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendTurn();
}
}}
/>
<Button disabled={!input || busy} onClick={sendTurn}>
<Send />
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => clinicalApi.finish(session.id),
(v: any) => setReport(v),
)
}
>
</Button>
</div>
</CardContent>
</Card>
)}
{report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<StatTile
label="综合评分"
value={report.overallScore?.toFixed?.(0) ?? report.overallScore}
tone="primary"
/>
<div className="text-sm text-muted-foreground">
{report.turnCount}
</div>
</div>
<div className="space-y-3">
{report.dimensions?.map((d) => (
<div key={d.dimension}>
<ScoreBar
label={d.dimensionName}
score={d.score}
tone={d.score >= 60 ? "success" : "warning"}
/>
{d.comment && (
<p className="mt-1 text-xs text-muted-foreground">{d.comment}</p>
)}
</div>
))}
</div>
<div className="mt-4">
<CredibilityBadge annotation={report.annotation} />
</div>
<RawDetails data={report} />
</CardContent>
</Card>
)}
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
/**
* AI 协同训练:列出协同训练任务 → 完成任务并评估四个协同能力维度 → 查看导师点评。
*/
import { useCallback, useEffect, useState } from "react";
import { Bot } from "lucide-react";
import { toast } from "sonner";
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { collaborationApi } from "@/lib/services";
import type { CollaborationAssessment } from "@/lib/types";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
export default function CollaborationPage() {
const [tasks, setTasks] = useState<any[]>([]);
const [comments, setComments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<any>(null);
const [content, setContent] = useState("");
const [adopted, setAdopted] = useState(false);
const [assessment, setAssessment] = useState<CollaborationAssessment | null>(
null,
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [t, c] = await Promise.all([
collaborationApi.listTasks(),
collaborationApi.listComments(),
]);
setTasks(Array.isArray(t) ? t : []);
setComments(Array.isArray(c) ? c : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleEvaluate() {
if (!activeTask) return;
setSubmitError(null);
setSubmitting(true);
try {
const v = await collaborationApi.evaluateTask(
activeTask.id ?? activeTask.taskId,
{ content, adoptedUnverifiedAiOutput: adopted },
);
setAssessment(v);
toast.success("评估完成");
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : "评估失败");
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Bot className="size-5" />}
title="医学 AI 协作"
description="在 AI 辅助下完成临床决策与科研任务,评估人机协作胜任力四维度。"
/>
<UsageGuide
steps={[
{ title: "选择训练任务", detail: "从任务列表中点击一个协同训练任务进入。" },
{ title: "完成并描述产出", detail: "在「任务说明 / 产出」中描述你的完成过程与结果。" },
{ title: "如实勾选 AI 使用", detail: "若直接采用了未经核验的 AI 输出,请勾选对应项。" },
{ title: "提交并查看评估", detail: "提交后获得四维协同能力评分、点评与可信度标注。" },
]}
tip="如实标注 AI 使用情况会影响评估与是否需要来源核验;导师点评会显示在页面底部。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : tasks.length === 0 ? (
<EmptyState message="暂无训练任务。" />
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{tasks.map((t: any, i: number) => (
<li
key={t.id ?? i}
className={cn(
"cursor-pointer rounded-lg border p-3 transition",
activeTask?.id === t.id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40",
)}
onClick={() => {
setActiveTask(t);
setAssessment(null);
setContent("");
setAdopted(false);
}}
>
<p className="text-sm font-medium text-foreground">
{t.title ?? t.name ?? t.id}
</p>
{t.dimension && (
<p className="mt-1 text-xs text-muted-foreground">
{t.dimension}
</p>
)}
{t.description && (
<p className="mt-1 text-xs text-muted-foreground">
{t.description}
</p>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
{activeTask && (
<Card>
<CardHeader>
<CardTitle>{activeTask.title ?? activeTask.id}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<Label> / </Label>
<Textarea
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="描述你的完成过程与产出"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground/80">
<input
type="checkbox"
className="size-4 accent-[var(--primary)]"
checked={adopted}
onChange={(e) => setAdopted(e.target.checked)}
/>
AI
</label>
{submitError && <ErrorBanner message={submitError} />}
<Button disabled={submitting} onClick={handleEvaluate}>
{submitting ? "评估中…" : "提交并评估"}
</Button>
</div>
{assessment && (
<div className="mt-4">
{assessment.requiresSourceVerification && (
<p className="mb-3 rounded-lg bg-warning/15 px-4 py-3 text-sm text-warning-foreground">
{assessment.verificationPrompt ?? "请对 AI 输出进行来源核验"}
</p>
)}
<div className="mb-4">
<StatTile
label="综合评分"
value={
typeof assessment.overallScore === "number"
? assessment.overallScore.toFixed(0)
: "数据不足"
}
tone="primary"
/>
</div>
<h3 className="mb-2 text-sm font-semibold text-foreground">
</h3>
<div className="space-y-3">
{assessment.dimensions?.map((d) => (
<div key={d.dimension}>
<ScoreBar
label={d.dimensionName}
score={d.score}
tone={
typeof d.score === "number" && d.score >= 60
? "success"
: "warning"
}
/>
{d.comment && (
<p className="mt-1 text-xs text-muted-foreground">
{d.comment}
</p>
)}
</div>
))}
</div>
<div className="mt-4">
<CredibilityBadge annotation={assessment.annotation} />
</div>
<RawDetails data={assessment} />
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{comments.length === 0 ? (
<EmptyState message="暂无导师点评。" />
) : (
<ul className="space-y-2">
{comments.map((c: any, i: number) => (
<li
key={c.id ?? i}
className="rounded-lg border border-border p-3 text-sm text-foreground/80"
>
{c.comment ?? c.content ?? JSON.stringify(c)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+447
View File
@@ -0,0 +1,447 @@
"use client";
import { useState } from "react";
import {
Award,
BookOpen,
CheckCircle2,
Target,
Timer,
} from "lucide-react";
import { ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { examPrepApi } from "@/lib/services";
const SUBJECTS = [
{ value: "internal", label: "内科学" },
{ value: "surgery", label: "外科学" },
{ value: "pediatrics", label: "儿科学" },
{ value: "obgyn", label: "妇产科学" },
{ value: "pharmacology", label: "药理学" },
{ value: "pathology", label: "病理学" },
{ value: "diagnostics", label: "诊断学" },
{ value: "ethics", label: "医学伦理" },
];
export default function ExamPrepPage() {
const [subject, setSubject] = useState("");
const [mode, setMode] = useState("auto");
const [question, setQuestion] = useState<any>(null);
const [answer, setAnswer] = useState("");
const [result, setResult] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generateQuestion() {
setBusy(true);
setError(null);
setResult(null);
setAnswer("");
try {
const q = await examPrepApi.generateQuestion({ subject: subject || undefined, mode });
setQuestion(q);
} catch (err) {
setError(err instanceof ApiError ? err.message : "生成失败");
} finally {
setBusy(false);
}
}
async function submitAnswer() {
if (!answer.trim() || !question) return;
setBusy(true);
setError(null);
try {
const r = await examPrepApi.submitAnswer(question.id ?? "current", { answer });
setResult(r);
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setBusy(false);
}
}
async function loadStats() {
setBusy(true);
setError(null);
try {
const s = await examPrepApi.getStats();
setStats(s);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setBusy(false);
}
}
async function loadHistory() {
setBusy(true);
setError(null);
try {
const h = await examPrepApi.listHistory();
setHistory(Array.isArray(h) ? h : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setBusy(false);
}
}
async function handleDeleteRecord(recordId: string) {
setBusy(true);
setError(null);
try {
await examPrepApi.deleteHistory(recordId);
await loadHistory();
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "删除失败");
} finally {
setBusy(false);
}
}
async function handleDeleteAll() {
if (!confirm("确定要清空所有答题记录吗?此操作不可恢复。")) return;
setBusy(true);
setError(null);
try {
await examPrepApi.deleteAllHistory();
await loadHistory();
await loadStats();
} catch (err) {
setError(err instanceof ApiError ? err.message : "清空失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Award className="size-5" />}
title="执业医师备考"
description="针对执业医师考试的专项题库与进度追踪,强化薄弱科目。"
/>
<UsageGuide
steps={[
{ title: "选择科目", detail: "选择内科学、外科学等薄弱科目进行专项训练。" },
{ title: "生成试题", detail: "AI 根据执医考试大纲生成模拟题,覆盖高频考点。" },
{ title: "作答与解析", detail: "提交答案后获得详细解析与相关知识点扩展。" },
{ title: "追踪进度", detail: "系统自动统计各科目正确率,定位薄弱环节。" },
]}
tip="执业医师考试注重临床思维,建议在作答时写出推理过程,而非仅给出结论。"
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="size-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-40"
>
<option value=""></option>
{SUBJECTS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
<Select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="w-32"
>
<option value="auto"></option>
<option value="builtin"></option>
<option value="ai">AI生成</option>
</Select>
<Button onClick={generateQuestion} disabled={busy}>
{busy ? "生成中…" : "生成试题"}
</Button>
{selectedRecord && (
<Button variant="outline" onClick={() => setSelectedRecord(null)} disabled={busy}>
</Button>
)}
</div>
{error && <ErrorBanner message={error} />}
{question && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/15 bg-primary/[0.03] p-4">
<p className="text-sm leading-relaxed text-foreground">
{question.content ?? question.question ?? JSON.stringify(question)}
</p>
{question.options && (
<ul className="mt-3 space-y-1">
{question.options.map((opt: string, i: number) => (
<li key={i} className="text-sm text-foreground/80">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
)}
{question.subject && (
<Badge variant="info" className="mt-3">
{SUBJECTS.find((s) => s.value === question.subject)?.label ?? question.subject}
</Badge>
)}
</div>
{!result && (
<div className="space-y-2">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="输入你的答案或推理过程…"
rows={3}
/>
<Button onClick={submitAnswer} disabled={busy || !answer.trim()}>
</Button>
</div>
)}
{result && (
<div className="space-y-3">
<div
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
result.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive"
}`}
>
{result.correct ? (
<><CheckCircle2 className="size-5" /> </>
) : (
<><Timer className="size-5" /> </>
)}
</div>
{result.explanation && (
<div className="rounded-lg bg-muted/50 p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm leading-relaxed text-foreground/80">
{result.explanation}
</p>
</div>
)}
<Button
onClick={() => {
setQuestion(null);
setResult(null);
setAnswer("");
}}
>
</Button>
</div>
)}
</div>
)}
{selectedRecord && !question && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/15 bg-primary/[0.03] p-4">
<p className="text-sm leading-relaxed text-foreground">{selectedRecord.content}</p>
{selectedRecord.options && selectedRecord.options.length > 0 && (
<ul className="mt-3 space-y-1">
{selectedRecord.options.map((opt: string, i: number) => (
<li key={i} className="text-sm text-foreground/80">
{String.fromCharCode(65 + i)}. {opt}
</li>
))}
</ul>
)}
<Badge variant="info" className="mt-3">
{SUBJECTS.find((s) => s.value === selectedRecord.subject)?.label ?? selectedRecord.subject}
</Badge>
</div>
<div className="space-y-2">
<div className="rounded-lg bg-muted/50 p-3">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm text-foreground/80">{selectedRecord.userAnswer}</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm text-foreground/80">{selectedRecord.correctAnswer}</p>
</div>
<div
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
selectedRecord.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive"
}`}
>
{selectedRecord.correct ? (
<><CheckCircle2 className="size-5" /> </>
) : (
<><Timer className="size-5" /> </>
)}
</div>
{selectedRecord.explanation && (
<div className="rounded-lg bg-muted/50 p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm leading-relaxed text-foreground/80">{selectedRecord.explanation}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setSelectedRecord(null)}
>
</Button>
</div>
</div>
)}
{!question && !error && !selectedRecord && (
<EmptyState message="点击「生成试题」开始执业医师备考训练。" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadStats} disabled={busy}>
{busy ? "加载中…" : "刷新进度"}
</Button>
<Button variant="outline" size="sm" onClick={loadHistory} disabled={busy}>
{busy ? "加载中…" : "刷新记录"}
</Button>
{history.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleDeleteAll} disabled={busy} className="text-destructive hover:text-destructive">
</Button>
)}
</div>
{stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-lg font-bold text-foreground">{stats.totalAnswered ?? 0}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-lg font-bold text-foreground">{stats.correctRate ?? "—"}%</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
{stats.bySubject && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
{Object.entries(stats.bySubject).map(([key, val]: [string, any]) => {
const label = SUBJECTS.find((s) => s.value === key)?.label ?? key;
const hasData = val.correctRate !== null && val.correctRate !== undefined;
return (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
{hasData ? (
<span className={`font-medium ${
val.correctRate >= 80 ? "text-success" : val.correctRate >= 60 ? "text-warning" : "text-destructive"
}`}>
{val.correctRate}%
</span>
) : (
<span className="text-xs text-muted-foreground/60"></span>
)}
</div>
);
})}
</div>
)}
</div>
) : (
<EmptyState message="暂无统计数据,请先开始练习。" />
)}
{/* 答题历史 */}
{history.length > 0 && (
<div className="mt-5 space-y-2">
<p className="text-xs font-medium text-muted-foreground">{history.length}</p>
<div className="max-h-80 space-y-2 overflow-y-auto">
{history.map((h: any) => (
<div
key={h.id}
onClick={() => {
setSelectedRecord(h);
setQuestion(null);
setResult(null);
setAnswer("");
}}
className="relative cursor-pointer rounded-lg border border-border p-3 transition-colors hover:bg-muted/50"
>
<div className="mb-1 flex items-center justify-between">
<Badge variant={h.correct ? "success" : "destructive"}>
{h.correct ? "正确" : "错误"}
</Badge>
<div className="flex items-center gap-1">
<span className="text-[11px] text-muted-foreground">
{h.createdAt ? new Date(h.createdAt).toLocaleString() : ""}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRecord(h.id);
}}
className="rounded p-1 text-muted-foreground/50 hover:bg-muted hover:text-destructive"
title="删除"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</div>
<p className="line-clamp-2 text-xs text-foreground/90">{h.content}</p>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{h.userAnswer}</span>
<span>{h.correctAnswer}</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useState, useRef, useEffect } from "react";
import {
BookOpenText,
ExternalLink,
MessageCircleQuestion,
Send,
Trash2,
} from "lucide-react";
import { CredibilityBadge, RawDetails } from "@/components/display";
import {
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { knowledgeApi } from "@/lib/services";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Message {
role: "user" | "assistant";
content: string;
sources?: any[];
annotation?: any;
raw?: any;
}
export default function KnowledgePage() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages]);
async function handleSend() {
if (!input.trim() || busy) return;
const question = input.trim();
setInput("");
setMessages((prev) => [...prev, { role: "user", content: question }]);
setError(null);
setBusy(true);
try {
const prev = messages.map((m) => `${m.role === "user" ? "问" : "答"}${m.content}`).join("\n");
const res: any = await knowledgeApi.ask({ question, context: prev || undefined });
const answer: Message = {
role: "assistant",
content: res?.answer ?? res?.content ?? JSON.stringify(res),
sources: res?.sources ?? res?.references,
annotation: res?.annotation ?? res?.credibility,
raw: res,
};
setMessages((prev) => [...prev, answer]);
} catch (err) {
setError(err instanceof ApiError ? err.message : "请求失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<MessageCircleQuestion className="size-5" />}
title="医学问答"
description="向 AI 提问医学概念、机制与鉴别要点,获得带来源标注的专业解答。"
/>
<UsageGuide
steps={[
{ title: "输入问题", detail: "使用自然语言提出医学问题,例如「二甲双胍的禁忌症有哪些?」" },
{ title: "获取解答", detail: "AI 将给出包含知识点的专业回答,并标注可信度。" },
{ title: "参考来源", detail: "每个回答附带教材或指南来源,方便进一步查证。" },
{ title: "追问与拓展", detail: "支持多轮对话,可以就答案中的概念继续深入追问。" },
]}
tip="问题越具体、越有临床情境,AI 的回答质量越高。例如比起「糖尿病治疗」,「2 型糖尿病合并慢性肾病 3 期的降糖方案」能获得更有针对性的回答。"
/>
<Card className="flex flex-col overflow-hidden">
<CardHeader className="shrink-0 border-b border-border">
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
{messages.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
setMessages([]);
setError(null);
}}
>
<Trash2 className="mr-1 size-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col p-0">
{/* 消息区域 */}
<div
ref={scrollRef}
className="flex-1 space-y-4 overflow-y-auto p-4"
style={{ maxHeight: "clamp(300px, 50vh, 520px)" }}
>
{messages.length === 0 && !busy && (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
</div>
)}
{messages.map((m, i) => (
<div
key={i}
className={cn(
"flex gap-3",
m.role === "user" ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed",
m.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted/60 text-foreground",
)}
>
<p className="whitespace-pre-wrap">{m.content}</p>
{/* 来源标注 */}
{m.sources && m.sources.length > 0 && (
<div className="mt-3 space-y-1 border-t border-border/30 pt-2">
<p className="flex items-center gap-1 text-[11px] font-semibold text-muted-foreground">
<BookOpenText className="size-3" />
</p>
<ul className="space-y-0.5">
{m.sources.map((s: any, j: number) => (
<li key={j} className="text-xs text-muted-foreground">
{j + 1}. {typeof s === "string" ? s : s.title ?? s.name ?? JSON.stringify(s)}
{s.url && (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="ml-1 inline-flex items-center text-primary hover:underline"
>
<ExternalLink className="size-2.5" />
</a>
)}
</li>
))}
</ul>
</div>
)}
{m.annotation && (
<div className="mt-2">
<CredibilityBadge annotation={m.annotation} />
</div>
)}
</div>
</div>
))}
{busy && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-xl bg-muted/60 px-4 py-3 text-sm text-muted-foreground">
<Loading />
</div>
</div>
)}
</div>
{/* 输入区 */}
<div className="shrink-0 border-t border-border bg-background p-4">
{error && (
<div className="mb-3"><ErrorBanner message={error} /></div>
)}
<div className="flex gap-3">
<Textarea
rows={2}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的医学问题,例如:心力衰竭的 NYHA 分级标准是什么?"
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<Button
disabled={busy || !input.trim()}
onClick={handleSend}
className="self-end"
>
<Send className="size-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { PortalShell } from "@/components/layout/app-shell";
import { STUDENT_NAV, STUDENT_NAV_GROUPS } from "@/lib/navigation";
import { Role } from "@/lib/types";
export default function StudentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PortalShell requiredRole={Role.Student} nav={STUDENT_NAV} groups={STUDENT_NAV_GROUPS}>
{children}
</PortalShell>
);
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
/** 学习空间:新增学习成果 + 按筛选条件分页检索。 */
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { EmptyState, ErrorBanner, PageHeading } from "@/components/feedback";
import { SkeletonList } from "@/components/skeletons";
import { UsageGuide } from "@/components/usage-guide";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { ApiError } from "@/lib/api";
import { learningSpaceApi } from "@/lib/services";
import { ACHIEVEMENT_TYPE_LABELS, AchievementType } from "@/lib/types";
import { formatDate } from "@/lib/utils";
export default function LearningSpacePage() {
const queryClient = useQueryClient();
const [filterType, setFilterType] = useState<string>("");
const [pageNum, setPageNum] = useState(1);
// 表单状态
const [type, setType] = useState<AchievementType>(AchievementType.CourseRecord);
const [title, setTitle] = useState("");
const [occurredAt, setOccurredAt] = useState(() =>
new Date().toISOString().slice(0, 10),
);
const [formError, setFormError] = useState<string | null>(null);
const listKey = ["achievements", filterType, pageNum] as const;
const { data: page, isLoading, error } = useQuery({
queryKey: listKey,
queryFn: () =>
learningSpaceApi.listAchievements({
type: filterType ? (filterType as AchievementType) : undefined,
page: pageNum,
pageSize: 10,
}),
});
const addMutation = useMutation({
mutationFn: () =>
learningSpaceApi.addAchievement({
type,
title,
occurredAt: new Date(occurredAt).toISOString(),
}),
onSuccess: () => {
setTitle("");
setFormError(null);
setPageNum(1);
toast.success("已新增学业成果");
queryClient.invalidateQueries({ queryKey: ["achievements"] });
},
onError: (err) => {
setFormError(err instanceof ApiError ? err.message : "新增失败");
},
});
const totalPages = page ? Math.max(1, Math.ceil(page.total / page.pageSize)) : 1;
return (
<div className="space-y-6">
<PageHeading
icon={<BookOpen className="size-5" />}
title="成长档案"
description="记录课程学分、临床见习、科研产出等医学学业成果,沉淀你的成长轨迹。"
/>
<UsageGuide
steps={[
{ title: "选择成果类型", detail: "课程记录、实验报告、临床见习、科研成果等共 9 类。" },
{ title: "填写标题与日期", detail: "如「完成内科学期末考试」,并选择发生日期。" },
{ title: "提交新增", detail: "点击「新增成果」保存,成果会进入下方列表。" },
{ title: "筛选与翻页", detail: "用右上角类型下拉筛选,底部按钮翻页查看历史记录。" },
]}
tip="持续录入学业成果,是生成「胜任力画像」与「执业发展」规划的数据基础。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
addMutation.mutate();
}}
className="grid grid-cols-1 gap-4 sm:grid-cols-4"
>
<div>
<Label></Label>
<Select
value={type}
onChange={(e) => setType(e.target.value as AchievementType)}
>
{Object.values(AchievementType).map((t) => (
<option key={t} value={t}>
{ACHIEVEMENT_TYPE_LABELS[t]}
</option>
))}
</Select>
</div>
<div className="sm:col-span-2">
<Label></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:完成内科学期末考试"
required
/>
</div>
<div>
<Label></Label>
<Input
type="date"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
required
/>
</div>
{formError && (
<div className="sm:col-span-4">
<ErrorBanner message={formError} />
</div>
)}
<div className="sm:col-span-4">
<Button type="submit" disabled={addMutation.isPending}>
{addMutation.isPending ? "提交中…" : "新增成果"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardAction>
<Select
className="w-36"
value={filterType}
onChange={(e) => {
setFilterType(e.target.value);
setPageNum(1);
}}
>
<option value=""></option>
{Object.values(AchievementType).map((t) => (
<option key={t} value={t}>
{ACHIEVEMENT_TYPE_LABELS[t]}
</option>
))}
</Select>
</CardAction>
</CardHeader>
<CardContent>
{isLoading ? (
<SkeletonList rows={5} />
) : error ? (
<ErrorBanner message={error instanceof ApiError ? error.message : "加载失败"} />
) : !page || page.items.length === 0 ? (
<EmptyState message="暂无学业记录,先在上方新增一条吧。" />
) : (
<>
<ul className="divide-y divide-border">
{page.items.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-lg px-2 py-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BookOpen className="size-4" />
</span>
<div>
<p className="font-medium text-foreground">{a.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatDate(a.occurredAt)}
</p>
</div>
</div>
<Badge variant="info">
{ACHIEVEMENT_TYPE_LABELS[a.type] ?? a.type}
</Badge>
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span> {page.total} </span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={pageNum <= 1}
onClick={() => setPageNum((p) => p - 1)}
>
<ChevronLeft />
</Button>
<span className="tabular-nums">
{page.page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={pageNum >= totalPages}
onClick={() => setPageNum((p) => p + 1)}
>
<ChevronRight />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CheckCircle2, Pill } from "lucide-react";
import { RawDetails, ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { medicationApi } from "@/lib/services";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
const CATEGORIES = [
{ value: "", label: "随机场景" },
{ value: "dosage", label: "剂量计算" },
{ value: "interaction", label: "药物相互作用" },
{ value: "prescription", label: "处方审核" },
{ value: "pediatric", label: "儿科用药" },
{ value: "renal", label: "肾功能调整" },
];
export default function MedicationPage() {
const [category, setCategory] = useState("");
const [scenario, setScenario] = useState<any>(null);
const [answer, setAnswer] = useState("");
const [result, setResult] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
after?.(await fn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Pill className="size-5" />}
title="用药安全"
description="练习药物剂量计算、相互作用识别与处方审核,强化安全用药能力。"
/>
<UsageGuide
steps={[
{ title: "选择训练类型", detail: "可选剂量计算、药物相互作用、处方审核、儿科用药、肾功能调整等场景。" },
{ title: "生成情景", detail: "点击「生成训练情景」,AI 将生成一道临床用药问题。" },
{ title: "提交答案", detail: "根据情景信息计算或判断后提交答案。" },
{ title: "查看反馈", detail: "获得正误判定、正确答案解析与相关药学知识点。" },
]}
tip="用药错误是全球医疗差错的首要原因,建议每天练习 3-5 道,重点关注易混淆药物和特殊人群用药。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[180px]">
<Label></Label>
<Select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</Select>
</div>
<Button
disabled={busy}
onClick={() =>
run(
() => medicationApi.generateScenario({ category: category || undefined }),
(v: any) => {
setScenario(v);
setResult(null);
setAnswer("");
},
)
}
>
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => medicationApi.listHistory(),
(v: any) => setHistory(Array.isArray(v) ? v : []),
)
}
>
</Button>
</div>
{error && !scenario && (
<div className="mt-4"><ErrorBanner message={error} /></div>
)}
</CardContent>
</Card>
{scenario && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{scenario.category && (
<Badge variant="info">
{CATEGORIES.find((c) => c.value === scenario.category)?.label ?? scenario.category}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 rounded-lg border border-primary/15 bg-primary/[0.03] p-4">
<p className="text-sm leading-relaxed text-foreground">
{scenario.question ?? scenario.description ?? JSON.stringify(scenario)}
</p>
{scenario.patientInfo && (
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground sm:grid-cols-4">
{scenario.patientInfo.age && <span>{scenario.patientInfo.age}</span>}
{scenario.patientInfo.weight && <span>{scenario.patientInfo.weight}kg</span>}
{scenario.patientInfo.renalFunction && <span>{scenario.patientInfo.renalFunction}</span>}
{scenario.patientInfo.allergies && <span>{scenario.patientInfo.allergies}</span>}
</div>
)}
{scenario.medications && (
<div className="mt-3 flex flex-wrap gap-1.5">
{(Array.isArray(scenario.medications) ? scenario.medications : []).map((m: any, i: number) => (
<Badge key={i} variant="muted">
{typeof m === "string" ? m : m.name ?? JSON.stringify(m)}
</Badge>
))}
</div>
)}
</div>
{!result && (
<div className="space-y-3">
<div>
<Label></Label>
<Textarea
rows={3}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="写出你的计算过程、判断结果或处方修改建议…"
/>
</div>
<Button
disabled={busy || !answer}
onClick={() =>
run(
() =>
medicationApi.submitAnswer(scenario.id ?? "current", { answer }),
(v: any) => setResult(v),
)
}
>
</Button>
</div>
)}
{busy && <Loading />}
{error && scenario && (
<div className="mt-4"><ErrorBanner message={error} /></div>
)}
{result && (
<div className="mt-4 space-y-4">
<div
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-3 text-sm",
result.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive",
)}
>
{result.correct ? (
<><CheckCircle2 className="size-5" /> </>
) : (
<><AlertTriangle className="size-5" /> </>
)}
</div>
{result.correctAnswer && (
<div className="rounded-lg bg-muted/50 p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm text-foreground">{result.correctAnswer}</p>
</div>
)}
{result.explanation && (
<div className="rounded-lg border border-border p-4">
<p className="mb-1 text-xs font-semibold text-muted-foreground"></p>
<p className="text-sm leading-relaxed text-foreground/80">{result.explanation}</p>
</div>
)}
{result.relatedKnowledge && (
<div className="flex flex-wrap gap-2">
{(Array.isArray(result.relatedKnowledge) ? result.relatedKnowledge : []).map((k: any, i: number) => (
<span key={i} className="rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary">
{typeof k === "string" ? k : k.topic ?? JSON.stringify(k)}
</span>
))}
</div>
)}
<Button
onClick={() => {
setScenario(null);
setResult(null);
setAnswer("");
}}
>
</Button>
<RawDetails data={result} />
</div>
)}
</CardContent>
</Card>
)}
{history.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{history.length}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{history.map((h: any, i: number) => (
<li key={h.id ?? i} className="flex items-center justify-between rounded-lg border border-border p-3">
<div>
<p className="text-sm font-medium text-foreground">
{h.question?.slice(0, 60) ?? `练习 ${i + 1}`}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{h.category && (CATEGORIES.find((c) => c.value === h.category)?.label ?? h.category)}
{h.timestamp && ` · ${h.timestamp}`}
</p>
</div>
<Badge variant={h.correct ? "success" : "destructive"}>
{h.correct ? "正确" : "错误"}
</Badge>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
GraduationCap,
Layers,
Sparkles,
TrendingUp,
Zap,
} from "lucide-react";
import { GroupedFeatureGrid } from "@/components/feature-grid";
import { QuickActions } from "@/components/quick-actions";
import { StatCard } from "@/components/display";
import { WelcomeBanner } from "@/components/welcome-banner";
import { SkeletonStats } from "@/components/skeletons";
import { STUDENT_NAV, STUDENT_NAV_GROUPS } from "@/lib/navigation";
import { learningSpaceApi, profileApi } from "@/lib/services";
export default function StudentHome() {
const { data: achievements } = useQuery({
queryKey: ["achievements", "", 1],
queryFn: () => learningSpaceApi.listAchievements({ page: 1, pageSize: 10 }),
});
const { data: profile, isLoading: profileLoading } = useQuery({
queryKey: ["profile"],
queryFn: () => profileApi.generate(),
});
const totalAchievements = achievements?.total ?? 0;
const dims = profile?.dimensions ?? [];
const scored = dims
.map((d) => (typeof d.score === "number" ? d.score : Number(d.score)))
.filter((n) => !Number.isNaN(n));
const avgScore = scored.length
? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
: null;
const dimensionCount = dims.length;
return (
<div className="space-y-6">
{/* Simplified banner */}
<WelcomeBanner
title="学习中心"
description="管理学业档案,追踪能力成长。"
icon={<GraduationCap />}
/>
{/* Stats overview */}
<section>
{profileLoading ? (
<SkeletonStats count={4} />
) : (
<div className="stagger grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label="学业记录"
value={totalAchievements}
icon={<BookOpen className="size-4" />}
hint="累计记录"
tone="primary"
/>
<StatCard
label="能力维度"
value={dimensionCount || "—"}
icon={<Layers className="size-4" />}
hint="画像覆盖"
tone="success"
/>
<StatCard
label="综合能力分"
value={avgScore == null ? "—" : avgScore}
icon={<TrendingUp className="size-4" />}
hint={avgScore == null ? "数据不足" : "六维均值"}
tone="warning"
/>
<StatCard
label="可用模块"
value={STUDENT_NAV.filter((n) => n.desc).length}
icon={<Sparkles className="size-4" />}
hint="学习功能"
tone="primary"
/>
</div>
)}
</section>
{/* Quick actions - one row */}
<section>
<div className="mb-3 flex items-center gap-2">
<Zap className="size-4 text-primary" />
<h2 className="text-sm font-bold tracking-wide text-foreground"></h2>
</div>
<QuickActions />
</section>
{/* Grouped feature modules */}
<section>
<GroupedFeatureGrid groups={STUDENT_NAV_GROUPS} />
</section>
</div>
);
}
+378
View File
@@ -0,0 +1,378 @@
"use client";
/**
* 课程对练:输入课程标识 → 生成题目 → 查看可对练题目 → 发起对练逐题作答 → 结束生成报告。
*/
import { useState } from "react";
import { GraduationCap } from "lucide-react";
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from "@/components/display";
import { RadialProgress } from "@/components/charts";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { practiceApi } from "@/lib/services";
import type { AnswerResult, PracticeReport } from "@/lib/types";
/* eslint-disable @typescript-eslint/no-explicit-any */
export default function PracticePage() {
const [courseId, setCourseId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [questions, setQuestions] = useState<any[]>([]);
const [session, setSession] = useState<any>(null);
const [answer, setAnswer] = useState("");
const [lastResult, setLastResult] = useState<AnswerResult | null>(null);
const [report, setReport] = useState<PracticeReport | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
const v = await fn();
after?.(v);
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
const currentQuestion: any =
session?.questions?.[session?.currentIndex ?? 0] ??
session?.currentQuestion ??
null;
return (
<div className="space-y-6">
<PageHeading
icon={<GraduationCap className="size-5" />}
title="医学题库"
description="基于教学大纲智能出题,在线作答并获得知识薄弱点分析与能力报告。"
/>
<UsageGuide
steps={[
{ title: "输入课程标识", detail: "如「内科学101」,定位要对练的课程。" },
{ title: "生成或查看题目", detail: "「生成题目」按课程内容出题,或「查看可对练题目」加载已有题。" },
{ title: "发起对练并作答", detail: "点击「发起对练」,逐题输入答案(选择题填选项字母)。" },
{ title: "结束生成报告", detail: "完成后「结束并生成报告」,查看正确率、薄弱环节与改进建议。" },
]}
tip="作答有计时,超时将判错;报告中的薄弱环节会回流到你的胜任力画像。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[220px] flex-1">
<Label></Label>
<Input
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
placeholder="如:内科学101"
/>
</div>
<Button
disabled={!courseId || busy}
onClick={() =>
run(
() => practiceApi.generateQuestions(courseId, {}),
(v: any) => setQuestions(v?.questions ?? v?.items ?? []),
)
}
>
</Button>
<Button
variant="outline"
disabled={!courseId || busy}
onClick={() =>
run(
() => practiceApi.listAvailableQuestions(courseId),
(v: any) => setQuestions(Array.isArray(v) ? v : []),
)
}
>
</Button>
<Button
variant="outline"
disabled={!courseId || busy}
onClick={() =>
run(
() => practiceApi.startSession(courseId),
(v: any) => {
setSession(v);
setReport(null);
setLastResult(null);
},
)
}
>
</Button>
</div>
{error && (
<div className="mt-4">
<ErrorBanner message={error} />
</div>
)}
</CardContent>
</Card>
{busy && <Loading />}
{questions.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{questions.length}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{questions.map((q: any, i: number) => (
<li key={q.id ?? i} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-foreground">
{i + 1}. {q.stem ?? q.title ?? q.content ?? "(题干)"}
</p>
{q.reviewStatus && (
<Badge variant="warning">
{q.reviewStatus === "pending" ? "待审核" :
q.reviewStatus === "approved" ? "已通过" :
q.reviewStatus === "returned" ? "已退回" :
q.reviewStatus}
</Badge>
)}
</div>
{Array.isArray(q.options) && (
<ul className="mt-2 space-y-1 text-xs text-muted-foreground">
{q.options.map((o: any, oi: number) => (
<li key={oi}>
{o.key ?? String.fromCharCode(65 + oi)}.{" "}
{o.text ?? o.label ?? String(o)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{session && !report && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{currentQuestion ? (
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">
{currentQuestion.stem ??
currentQuestion.title ??
currentQuestion.content ??
"当前题目"}
</p>
{Array.isArray(currentQuestion.options) && (
<ul className="space-y-1 text-xs text-muted-foreground">
{currentQuestion.options.map((o: any, oi: number) => (
<li key={oi}>
{o.key ?? String.fromCharCode(65 + oi)}.{" "}
{o.text ?? o.label ?? String(o)}
</li>
))}
</ul>
)}
<Textarea
rows={3}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="输入你的作答(选择题填选项字母,分析题填要点)"
/>
<div className="flex gap-3">
<Button
disabled={busy || !answer}
onClick={() =>
run(
() =>
practiceApi.submitAnswer(session.id, {
questionId:
currentQuestion.id ?? currentQuestion.questionId,
answer,
}),
(v: any) => {
setLastResult(v);
setAnswer("");
if (v?.session) setSession(v.session);
},
)
}
>
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => practiceApi.finishSession(session.id),
(v: any) => setReport(v),
)
}
>
</Button>
</div>
</div>
) : (
<EmptyState message="本次对练暂无可作答题目,可直接结束生成报告。" />
)}
{lastResult && (
<div className="mt-4">
<div
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm ${
lastResult.correct
? "bg-success/12 text-success"
: "bg-destructive/12 text-destructive"
}`}
>
<span className="font-medium">
{lastResult.correct ? "✓ 回答正确" : "✗ 回答错误"}
</span>
{lastResult.timedOut && <Badge variant="warning"></Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{(lastResult.elapsedMs / 1000).toFixed(1)}
</span>
</div>
{lastResult.next && (
<p className="mt-2 text-xs text-muted-foreground">
{lastResult.next.position} / {lastResult.next.total}
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{report && <PracticeReportView report={report} />}
</div>
);
}
/** 对练报告:正确率、用时、能力标签统计、薄弱环节与改进建议。 */
function PracticeReportView({ report }: { report: PracticeReport }) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div className="flex shrink-0 items-center justify-center">
<RadialProgress
value={report.accuracy}
size={120}
label={`${report.accuracy.toFixed(0)}%`}
sublabel="正确率"
tone={report.accuracy >= 60 ? "success" : "destructive"}
/>
</div>
<div className="grid flex-1 grid-cols-3 gap-3">
<StatTile label="题目总数" value={report.totalQuestions} />
<StatTile
label="正确 / 错误"
value={`${report.correctCount}/${report.incorrectCount}`}
/>
<StatTile
label="总用时"
value={`${report.totalTimeSeconds}s`}
tone="primary"
/>
</div>
</div>
{report.tagBreakdown?.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-foreground">
</h3>
<div className="space-y-2">
{report.tagBreakdown.map((t) => (
<ScoreBar
key={t.tagId}
label={`${t.tagId}${t.correctCount}/${t.totalQuestions}`}
score={t.correctnessRate}
tone={t.correctnessRate >= 60 ? "success" : "destructive"}
/>
))}
</div>
</div>
)}
{report.weakAreas?.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-foreground">
&lt; 60%
</h3>
<div className="flex flex-wrap gap-2">
{report.weakAreas.map((w) => (
<Badge key={w.tagId} variant="destructive">
{w.tagName ?? w.tagId}{w.correctnessRate.toFixed(0)}%
</Badge>
))}
</div>
</div>
)}
{report.suggestions?.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-foreground"></h3>
<ul className="space-y-3">
{report.suggestions.map((s, i) => (
<li key={i} className="rounded-lg border border-border p-3">
<p className="text-sm text-foreground">{s.content}</p>
<div className="mt-2">
<CredibilityBadge annotation={s.annotation} />
</div>
</li>
))}
</ul>
</div>
)}
<RawDetails data={report} />
</CardContent>
</Card>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
/** 能力画像:生成/刷新六维度画像,雷达图 + 条形可视化展示。 */
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Sparkles } from "lucide-react";
import { RadarChart, type RadarAxis } from "@/components/charts";
import { EmptyState, ErrorBanner, PageHeading } from "@/components/feedback";
import { SkeletonBars } from "@/components/skeletons";
import { UsageGuide } from "@/components/usage-guide";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ApiError } from "@/lib/api";
import { profileApi } from "@/lib/services";
import { DIMENSION_LABELS } from "@/lib/types";
import { cn } from "@/lib/utils";
export default function ProfilePage() {
const { data: profile, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ["profile"],
queryFn: () => profileApi.generate(),
});
const dims = profile?.dimensions ?? [];
const radarAxes: RadarAxis[] = dims.map((d) => {
const num = typeof d.score === "number" ? d.score : Number(d.score);
return {
label: d.dimensionName ?? DIMENSION_LABELS[d.dimension] ?? d.dimension,
value: Number.isNaN(num) ? null : Math.max(0, Math.min(100, num)),
};
});
return (
<div className="space-y-6">
<PageHeading
icon={<Sparkles className="size-5" />}
title="胜任力画像"
description="基于医学教育胜任力框架,从学业成果中生成六维度能力量化画像。"
actions={
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={cn(isFetching && "animate-spin")} />
</Button>
}
/>
<UsageGuide
steps={[
{ title: "录入学业成果", detail: "画像基于「成长档案」的数据生成,数据越全画像越准。" },
{ title: "查看雷达图", detail: "左侧雷达图直观呈现六维度能力的整体形状。" },
{ title: "查看维度明细", detail: "右侧条形展示每个维度分值,「数据不足」表示样本太少。" },
{ title: "刷新画像", detail: "录入新成果后点右上角「刷新画像」重新计算。" },
]}
tip="标记「敏感」的维度仅本人与授权导师可见,导师查看时会自动脱敏。"
/>
{isLoading ? (
<Card>
<CardContent className="pt-5">
<SkeletonBars rows={6} />
</CardContent>
</Card>
) : error ? (
<ErrorBanner message={error instanceof ApiError ? error.message : "加载失败"} />
) : dims.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState message="暂无足够数据生成画像,先在成长档案录入学业成果。" />
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 雷达图 */}
<Card className="animate-fade-in-up lg:col-span-2">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center pb-6">
{radarAxes.length >= 3 ? (
<RadarChart axes={radarAxes} />
) : (
<p className="py-10 text-sm text-muted-foreground">
</p>
)}
</CardContent>
</Card>
{/* 条形明细 */}
<Card className="animate-fade-in-up lg:col-span-3">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pb-5">
<div className="space-y-4">
{dims.map((d) => {
const label =
d.dimensionName ?? DIMENSION_LABELS[d.dimension] ?? d.dimension;
const numericScore =
typeof d.score === "number" ? d.score : Number(d.score);
const insufficient =
d.score === null ||
d.score === undefined ||
d.score === "insufficient_data" ||
Number.isNaN(numericScore);
const score = insufficient ? 0 : numericScore;
return (
<div key={d.dimension}>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="flex items-center gap-2 font-medium text-foreground">
{label}
{d.sensitive && <Badge variant="destructive"></Badge>}
</span>
{insufficient ? (
<Badge variant="warning"></Badge>
) : (
<span className="tabular-nums text-muted-foreground">
{score.toFixed(0)}
</span>
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full origin-left rounded-full",
insufficient ? "bg-warning/60" : "bg-primary",
)}
style={{
width: `${insufficient ? 8 : score}%`,
transition: "width 0.8s cubic-bezier(0.22,1,0.36,1)",
}}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
+370
View File
@@ -0,0 +1,370 @@
"use client";
/**
* 研究资料查询:自然语言问题 → 生成检索式 → 在选定来源检索 → 总结 / 生成引用。
*/
import { useState } from "react";
import { Search } from "lucide-react";
import { CredibilityBadge, InfoRow, RawDetails } from "@/components/display";
import { ErrorBanner, Loading, PageHeading } from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { researchApi } from "@/lib/services";
import type { Citation, SearchQuery, Summary } from "@/lib/types";
import { cn } from "@/lib/utils";
/* eslint-disable @typescript-eslint/no-explicit-any */
const SOURCES: { value: string; label: string }[] = [
{ value: "PUBMED", label: "PubMed" },
{ value: "CNKI", label: "中国知网" },
{ value: "WANFANG", label: "万方数据" },
{ value: "UPTODATE", label: "UpToDate" },
{ value: "COCHRANE", label: "Cochrane" },
];
/** 证据分级 → 配色(越强越绿)。 */
function evidenceVariant(
level: string,
): "success" | "info" | "warning" | "muted" {
if (level.startsWith("1")) return "success";
if (level.startsWith("2")) return "info";
if (level.startsWith("3") || level === "4") return "warning";
return "muted";
}
export default function ResearchPage() {
const [question, setQuestion] = useState("");
const [searchQuery, setSearchQuery] = useState<SearchQuery | null>(null);
const [selectedSources, setSelectedSources] = useState<string[]>(["PUBMED"]);
const [results, setResults] = useState<any>(null);
const [summary, setSummary] = useState<Summary | null>(null);
const [citations, setCitations] = useState<Citation[] | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run<T>(fn: () => Promise<T>, after?: (v: T) => void) {
setError(null);
setBusy(true);
try {
after?.(await fn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "操作失败");
} finally {
setBusy(false);
}
}
const items: any[] = results?.page?.items ?? results?.items ?? [];
function toggleSource(val: string) {
setSelectedSources((prev) =>
prev.includes(val) ? prev.filter((x) => x !== val) : [...prev, val],
);
}
return (
<div className="space-y-6">
<PageHeading
icon={<Search className="size-5" />}
title="循证检索"
description="按 PICO 框架将临床问题转为专业检索式,检索医学数据库、总结证据并生成规范引用。"
/>
<UsageGuide
steps={[
{ title: "描述研究问题", detail: "用自然语言输入问题,系统按 PICO 生成专业检索式。" },
{ title: "选择来源检索", detail: "勾选 PubMed / 中国知网等数据库,点击「检索」获取文献。" },
{ title: "生成总结", detail: "对检索结果一键总结,结论标注是否已核验与证据分级。" },
{ title: "生成引用", detail: "按温哥华格式等导出规范引用,便于写作。" },
]}
tip="标注「未验证」的结论务必回到原文核对;证据等级越高(1 类)可信度越强。"
/>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Textarea
rows={2}
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="用自然语言描述研究问题,如:他汀类药物对老年冠心病患者二级预防的疗效"
/>
<Button
disabled={!question || busy}
onClick={() =>
run(
() => researchApi.generateSearchQuery({ question }),
(v: any) => {
setSearchQuery(v?.query ?? v);
setResults(null);
setSummary(null);
setCitations(null);
},
)
}
>
</Button>
{searchQuery && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<p className="mb-2 font-mono text-sm text-primary">
{searchQuery.expression}
</p>
{searchQuery.meshTerms?.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{searchQuery.meshTerms.map((m) => (
<Badge key={m} variant="info">
MeSH: {m}
</Badge>
))}
</div>
)}
{searchQuery.pico && (
<div className="mt-2 grid grid-cols-2 gap-x-4 text-xs sm:grid-cols-4">
{searchQuery.pico.population && (
<InfoRow label="P 人群">
{searchQuery.pico.population}
</InfoRow>
)}
{searchQuery.pico.intervention && (
<InfoRow label="I 干预">
{searchQuery.pico.intervention}
</InfoRow>
)}
{searchQuery.pico.comparison && (
<InfoRow label="C 对照">
{searchQuery.pico.comparison}
</InfoRow>
)}
{searchQuery.pico.outcome && (
<InfoRow label="O 结局">{searchQuery.pico.outcome}</InfoRow>
)}
</div>
)}
{searchQuery.rationale && (
<p className="mt-2 text-xs text-muted-foreground">
{searchQuery.rationale}
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
{searchQuery && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="mb-3 flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => toggleSource(s.value)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs transition",
selectedSources.includes(s.value)
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:bg-muted",
)}
>
{s.label}
</button>
))}
</div>
<Button
disabled={busy || selectedSources.length === 0}
onClick={() =>
run(
() =>
researchApi.search({
query: searchQuery,
sources: selectedSources,
}),
(v: any) => setResults(v),
)
}
>
</Button>
{results?.empty && (
<p className="mt-4 rounded-lg bg-warning/15 px-4 py-3 text-sm text-warning-foreground">
{results.notice ?? "未找到匹配资料"}
</p>
)}
{items.length > 0 && (
<ul className="mt-4 space-y-2">
{items.map((it: any, i: number) => (
<li key={it.id ?? i} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<a
href={it.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary hover:underline"
>
{it.title}
</a>
{it.source && <Badge variant="info">{it.source}</Badge>}
</div>
{it.authors && (
<p className="mt-1 text-xs text-muted-foreground">
{Array.isArray(it.authors)
? it.authors.join(", ")
: it.authors}
{it.publishedAt && ` · ${it.publishedAt}`}
</p>
)}
{it.abstract && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{it.abstract}
</p>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
)}
{items.length > 0 && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() => researchApi.summarize({ items }),
(v: Summary) => setSummary(v),
)
}
>
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
run(
() =>
researchApi.generateCitation({
items,
format: "VANCOUVER",
}),
(v: Citation[]) => setCitations(v),
)
}
>
</Button>
</div>
{summary && <SummaryView summary={summary} />}
{citations && citations.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-foreground">
{citations.length}
</h3>
<ol className="list-inside list-decimal space-y-1.5 text-sm text-foreground/80">
{citations.map((c, i) => (
<li key={c.itemId ?? i}>{c.text}</li>
))}
</ol>
</div>
)}
</CardContent>
</Card>
)}
{busy && <Loading />}
{error && <ErrorBanner message={error} />}
</div>
);
}
/** 资料总结:结论(含可追溯/未验证)+ 证据分级 + 提示 + 可信度。 */
function SummaryView({ summary }: { summary: Summary }) {
return (
<div className="mt-5">
<p className="mb-3 rounded-lg bg-warning/15 px-3 py-2 text-xs text-warning-foreground">
{summary.notice}
</p>
<h3 className="mb-2 text-sm font-semibold text-foreground"></h3>
<ul className="space-y-2">
{summary.conclusions?.map((c) => (
<li key={c.id} className="rounded-lg border border-border p-3 text-sm">
<p className="text-foreground">{c.statement}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{c.verified ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="destructive">{c.unverifiedLabel ?? "未验证"}</Badge>
)}
{c.citations?.map((s, i) => (
<span key={i} className="text-xs text-muted-foreground">
[{s.title ?? s.id}]
</span>
))}
</div>
</li>
))}
</ul>
{summary.gradedItems?.length > 0 && (
<div className="mt-4">
<h3 className="mb-2 text-sm font-semibold text-foreground"></h3>
<ul className="space-y-1.5">
{summary.gradedItems.map((g) => (
<li
key={g.itemId}
className="flex items-center justify-between gap-3 text-sm"
>
<a
href={g.url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-foreground/80 hover:text-primary hover:underline"
>
{g.title}
</a>
<Badge variant={evidenceVariant(g.evidenceLevel)}>
{g.evidenceLevel}
</Badge>
</li>
))}
</ul>
</div>
)}
<div className="mt-4">
<CredibilityBadge annotation={summary.annotation} />
</div>
<RawDetails data={summary} />
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useState } from "react";
import { CalendarDays, CheckCircle2, Clock, Hospital } from "lucide-react";
import { InfoRow } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { rotationApi } from "@/lib/services";
const DEPTS = [
{ value: "internal", label: "内科" },
{ value: "surgery", label: "外科" },
{ value: "pediatrics", label: "儿科" },
{ value: "obgyn", label: "妇产科" },
{ value: "emergency", label: "急诊科" },
{ value: "orthopedics", label: "骨科" },
{ value: "neurology", label: "神经内科" },
{ value: "cardiology", label: "心内科" },
];
export default function RotationPage() {
const [records, setRecords] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dept, setDept] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [supervisor, setSupervisor] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
async function loadRecords() {
setLoading(true);
setError(null);
try {
const res = await rotationApi.listRecords();
setRecords(Array.isArray(res) ? res : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await rotationApi.addRecord({
department: dept,
startDate,
endDate,
supervisor: supervisor || undefined,
notes: notes || undefined,
});
setDept("");
setStartDate("");
setEndDate("");
setSupervisor("");
setNotes("");
await loadRecords();
} catch (err) {
setError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<CalendarDays className="size-5" />}
title="轮转见习"
description="对接医院轮转安排,记录科室出勤、带教评价与轮转心得。"
/>
<UsageGuide
steps={[
{ title: "选择科室", detail: "从列表中选择当前或即将轮转的临床科室。" },
{ title: "填写时间", detail: "输入轮转开始与结束日期,绑定带教导师。" },
{ title: "记录心得", detail: "随时记录科室学习要点、典型病例与操作体会。" },
{ title: "查看汇总", detail: "系统自动汇总各科室轮转时长与出勤情况。" },
]}
tip="建议每次出科前完成带教评价,评价数据将用于后续胜任力画像的临床能力维度评估。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<Label></Label>
<Select value={dept} onChange={(e) => setDept(e.target.value)} required>
<option value=""></option>
{DEPTS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input
value={supervisor}
onChange={(e) => setSupervisor(e.target.value)}
placeholder="导师姓名"
/>
</div>
<div>
<Label></Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div>
<Label></Label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<div className="sm:col-span-2">
<Label> / </Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="记录科室学习要点、典型病例、操作体会等…"
rows={3}
/>
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" disabled={submitting}>
{submitting ? "提交中…" : "提交记录"}
</Button>
<Button type="button" variant="outline" onClick={loadRecords} disabled={loading}>
{loading ? "加载中…" : "刷新记录"}
</Button>
</div>
{error && (
<div className="sm:col-span-2">
<ErrorBanner message={error} />
</div>
)}
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : records.length === 0 ? (
<EmptyState message="暂无轮转记录,请添加第一条记录。" />
) : (
<div className="space-y-3">
{records.map((r: any, i: number) => (
<div
key={r.id ?? i}
className="flex items-start justify-between rounded-lg border border-border p-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Hospital className="size-4 text-primary" />
<span className="text-sm font-semibold text-foreground">
{DEPTS.find((d) => d.value === r.department)?.label ?? r.department}
</span>
{r.completed && (
<Badge variant="success">
<CheckCircle2 className="mr-1 size-3" />
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{r.startDate} ~ {r.endDate}
</span>
{r.supervisor && <span>{r.supervisor}</span>}
</div>
{r.notes && (
<p className="mt-1 text-xs text-foreground/80">{r.notes}</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import {
BookOpenText,
Play,
Star,
Video,
} from "lucide-react";
import { ScoreBar } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { skillVideoApi } from "@/lib/services";
const CATEGORIES = [
{ value: "suture", label: "缝合技术" },
{ value: "catheter", label: "导尿管置入" },
{ value: "venipuncture", label: "静脉穿刺" },
{ value: "intubation", label: "气管插管" },
{ value: "cpr", label: "心肺复苏" },
{ value: "physical-exam", label: "体格检查" },
{ value: "aseptic", label: "无菌操作" },
{ value: "surgical-scrub", label: "外科刷手" },
];
export default function SkillVideoPage() {
const [videos, setVideos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
async function search() {
setLoading(true);
setError(null);
try {
const res = await skillVideoApi.search({ query: query || undefined });
setVideos(Array.isArray(res) ? res : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Video className="size-5" />}
title="技能视频"
description="手术与操作技能视频学习,支持分段自评与关键点标记。"
/>
<UsageGuide
steps={[
{ title: "选择技能类型", detail: "浏览或搜索缝合、穿刺、插管等临床操作视频。" },
{ title: "观看学习", detail: "视频支持分段播放,关键步骤自动高亮提示。" },
{ title: "标记要点", detail: "在关键时间点添加个人笔记与操作要点。" },
{ title: "自我评估", detail: "观看后根据视频标准进行自评,记录能力成长。" },
]}
tip="建议结合临床模拟模块进行反复练习,视频学习后尽早到模拟环境或临床场景中实操。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((c) => (
<Button
key={c.value}
variant="outline"
size="sm"
onClick={() => {
setQuery(c.label);
search();
}}
>
{c.label}
</Button>
))}
</div>
<div className="mt-4 flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索技能名称,如:清创缝合"
className="max-w-md"
/>
<Button onClick={search} disabled={loading}>
{loading ? "搜索中…" : "搜索"}
</Button>
</div>
{error && <div className="mt-4"><ErrorBanner message={error} /></div>}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{loading ? (
<Loading />
) : videos.length === 0 ? (
<div className="sm:col-span-2 lg:col-span-3">
<EmptyState message="暂无视频记录,请先添加视频或搜索。" />
</div>
) : (
videos.map((v: any, i: number) => (
<Card key={v.id ?? i} className="overflow-hidden">
<div className="relative aspect-video bg-muted">
<div className="absolute inset-0 flex items-center justify-center">
<Button size="icon" variant="secondary" className="rounded-full">
<Play className="size-5" />
</Button>
</div>
{v.duration && (
<Badge variant="secondary" className="absolute bottom-2 right-2">
{v.duration}
</Badge>
)}
</div>
<CardContent className="p-4">
<p className="text-sm font-semibold text-foreground">{v.title ?? `视频 ${i + 1}`}</p>
<p className="mt-1 text-xs text-muted-foreground">
{v.category && (
<Badge variant="outline" className="mr-1">
{CATEGORIES.find((c) => c.value === v.category)?.label ?? v.category}
</Badge>
)}
{v.description}
</p>
{v.myScore != null && (
<div className="mt-3">
<ScoreBar label="我的自评" score={v.myScore} tone="primary" />
</div>
)}
<div className="mt-3 flex items-center gap-1 text-xs text-muted-foreground">
<BookOpenText className="size-3" />
{v.noteCount ?? 0}
<Star className="ml-2 size-3" />
{v.favorite ? "已收藏" : "未收藏"}
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
/**
* 轻量、零依赖的可视化组件(纯 SVG / CSS)。
*
* 仅覆盖仪表盘所需的环形进度、迷你柱状图、雷达图与火花线,避免引入重型图表库。
*/
import { useId } from "react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/* ------------------------------ 环形进度 ------------------------------ */
export function RadialProgress({
value,
size = 96,
stroke = 9,
label,
sublabel,
tone = "primary",
}: {
/** 0-100 */
value: number;
size?: number;
stroke?: number;
label?: ReactNode;
sublabel?: ReactNode;
tone?: "primary" | "success" | "warning" | "destructive";
}) {
const v = Math.max(0, Math.min(100, value));
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const offset = c - (v / 100) * c;
const toneColor: Record<string, string> = {
primary: "var(--primary)",
success: "var(--success)",
warning: "var(--warning)",
destructive: "var(--destructive)",
};
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--muted)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={toneColor[tone]}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 0.8s cubic-bezier(0.22,1,0.36,1)" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
{label !== undefined ? (
<span className="text-lg font-semibold tabular-nums text-foreground">{label}</span>
) : (
<span className="text-lg font-semibold tabular-nums text-foreground">{v.toFixed(0)}%</span>
)}
{sublabel && <span className="text-[10px] text-muted-foreground">{sublabel}</span>}
</div>
</div>
);
}
/* ------------------------------ 迷你柱状图 ------------------------------ */
export interface BarDatum {
label: string;
value: number;
/** 0-100 比例直接给出时优先使用 */
ratio?: number;
}
export function MiniBars({
data,
tone = "primary",
className,
}: {
data: BarDatum[];
tone?: "primary" | "success" | "warning";
className?: string;
}) {
const max = Math.max(1, ...data.map((d) => d.value));
const toneClass: Record<string, string> = {
primary: "bg-primary",
success: "bg-success",
warning: "bg-warning",
};
return (
<div className={cn("flex h-32 items-end gap-2", className)}>
{data.map((d, i) => {
const pct = d.ratio ?? (d.value / max) * 100;
return (
<div key={i} className="flex flex-1 flex-col items-center gap-1.5">
<div className="flex w-full flex-1 items-end">
<div
className={cn("w-full rounded-t-md", toneClass[tone])}
style={{
height: `${Math.max(4, pct)}%`,
transition: "height 0.7s cubic-bezier(0.22,1,0.36,1)",
}}
title={`${d.label}: ${d.value}`}
/>
</div>
<span className="max-w-full truncate text-[10px] text-muted-foreground" title={d.label}>
{d.label}
</span>
</div>
);
})}
</div>
);
}
/* ------------------------------ 雷达图 ------------------------------ */
export interface RadarAxis {
label: string;
/** 0-100null 表示数据不足 */
value: number | null;
}
export function RadarChart({
axes,
size = 240,
className,
}: {
axes: RadarAxis[];
size?: number;
className?: string;
}) {
const gradientId = useId();
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 34;
const n = axes.length;
if (n < 3) {
return null;
}
const angleAt = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
const pointAt = (i: number, ratio: number) => {
const a = angleAt(i);
return [cx + Math.cos(a) * radius * ratio, cy + Math.sin(a) * radius * ratio] as const;
};
const rings = [0.25, 0.5, 0.75, 1];
const valuePoints = axes.map((ax, i) => pointAt(i, ax.value == null ? 0 : ax.value / 100));
const polygon = valuePoints.map((p) => p.join(",")).join(" ");
return (
<svg
viewBox={`0 0 ${size} ${size}`}
className={cn("h-auto w-full max-w-[280px]", className)}
role="img"
>
<defs>
<radialGradient id={gradientId}>
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.12" />
</radialGradient>
</defs>
{/* 网格环 */}
{rings.map((ring, ri) => (
<polygon
key={ri}
points={axes.map((_, i) => pointAt(i, ring).join(",")).join(" ")}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
))}
{/* 轴线 */}
{axes.map((_, i) => {
const [x, y] = pointAt(i, 1);
return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke="var(--border)" strokeWidth={1} />;
})}
{/* 数据多边形 */}
<polygon
points={polygon}
fill={`url(#${gradientId})`}
stroke="var(--primary)"
strokeWidth={2}
strokeLinejoin="round"
style={{ transition: "all 0.6s ease-out" }}
/>
{/* 数据点 */}
{valuePoints.map((p, i) =>
axes[i].value == null ? null : (
<circle key={i} cx={p[0]} cy={p[1]} r={3} fill="var(--primary)" />
),
)}
{/* 轴标签 */}
{axes.map((ax, i) => {
const [x, y] = pointAt(i, 1.16);
return (
<text
key={i}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-muted-foreground"
style={{ fontSize: 10 }}
>
{ax.label}
</text>
);
})}
</svg>
);
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
/**
* 跨页面复用的「领域数据展示」组件。
*
* 将后端返回的结构化结果(评分、可信度标注、能力差距、证据分级等)渲染为统一样式的
* 可读 UI。组件均为纯展示、对缺失字段做防御性处理。
*/
import { CheckCircle2, ShieldAlert } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/** "数据不足"标记(与后端 INSUFFICIENT_DATA / 'insufficient_data' 对齐)。 */
const INSUFFICIENT = "insufficient_data";
/** 判断一个分值是否为"数据不足"。 */
export function isInsufficient(score: unknown): boolean {
return (
score === null ||
score === undefined ||
score === INSUFFICIENT ||
score === "no_data" ||
(typeof score === "string" && Number.isNaN(Number(score)))
);
}
type Tone = "primary" | "success" | "warning" | "destructive";
const BAR_TONES: Record<Tone, string> = {
primary: "bg-primary",
success: "bg-success",
warning: "bg-warning",
destructive: "bg-destructive",
};
/** 0-100 分值条;支持"数据不足"。 */
export function ScoreBar({
label,
score,
required,
tone = "primary",
}: {
label: ReactNode;
score: number | string | null | undefined;
/** 可选:目标/要求水平,渲染为参考刻度线。 */
required?: number;
tone?: Tone;
}) {
const insufficient = isInsufficient(score);
const value = insufficient ? 0 : Math.max(0, Math.min(100, Number(score)));
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-medium text-foreground">{label}</span>
{insufficient ? (
<Badge variant="warning"></Badge>
) : (
<span className="tabular-nums text-muted-foreground">
{value.toFixed(0)}
{typeof required === "number" && (
<span className="text-muted-foreground/60"> / {required}</span>
)}
</span>
)}
</div>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
insufficient ? "bg-warning/60" : BAR_TONES[tone],
)}
style={{ width: `${insufficient ? 8 : value}%` }}
/>
{typeof required === "number" && !insufficient && (
<span
className="absolute top-0 h-full w-0.5 bg-foreground/50"
style={{ left: `${Math.min(100, required)}%` }}
title={`目标水平 ${required}`}
/>
)}
</div>
</div>
);
}
/** 来源引用条目(可信度标注中的 sources)。 */
interface SourceRefLike {
id?: string;
title?: string;
citation?: string;
}
/** 可信度标注:来源 + 置信度 + 是否经核验。 */
export function CredibilityBadge({
annotation,
}: {
annotation?: {
sources?: SourceRefLike[];
confidence?: number;
verified?: boolean;
} | null;
}) {
if (!annotation) return null;
const { sources = [], confidence, verified } = annotation;
return (
<div className="rounded-lg border border-border bg-muted/40 p-3">
<div className="mb-2 flex items-center gap-2">
{verified ? (
<Badge variant="success">
<CheckCircle2 />
</Badge>
) : (
<Badge variant="destructive">
<ShieldAlert />
</Badge>
)}
{typeof confidence === "number" && (
<span className="text-xs text-muted-foreground">
{confidence.toFixed(0)}%
</span>
)}
</div>
{sources.length > 0 ? (
<ul className="space-y-1 text-xs text-muted-foreground">
{sources.map((s, i) => (
<li key={s.id ?? i}>
· {s.title ?? s.id}
{s.citation && (
<span className="text-muted-foreground/70">{s.citation}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground/70"></p>
)}
</div>
);
}
const TILE_TONES: Record<Tone | "muted", string> = {
primary: "text-primary",
success: "text-success",
warning: "text-warning-foreground",
destructive: "text-destructive",
muted: "text-foreground",
};
/** 统计数字小卡。 */
export function StatTile({
label,
value,
tone = "muted",
}: {
label: ReactNode;
value: ReactNode;
tone?: Tone | "muted";
}) {
return (
<div className="rounded-lg border border-border bg-card p-4 text-center">
<p className={cn("text-2xl font-semibold tabular-nums", TILE_TONES[tone])}>
{value}
</p>
<p className="mt-1 text-xs text-muted-foreground">{label}</p>
</div>
);
}
const STAT_ACCENTS: Record<Tone, { ring: string; chip: string }> = {
primary: { ring: "ring-primary/15", chip: "bg-primary/10 text-primary" },
success: { ring: "ring-success/15", chip: "bg-success/12 text-success" },
warning: { ring: "ring-warning/20", chip: "bg-warning/15 text-warning-foreground" },
destructive: {
ring: "ring-destructive/15",
chip: "bg-destructive/12 text-destructive",
},
};
/** 仪表盘统计卡:图标 + 数值 + 说明 +(可选)趋势。 */
export function StatCard({
label,
value,
icon,
hint,
trend,
tone = "primary",
className,
}: {
label: ReactNode;
value: ReactNode;
icon?: ReactNode;
hint?: ReactNode;
trend?: { value: string; direction: "up" | "down" | "flat" };
tone?: Tone;
className?: string;
}) {
const accent = STAT_ACCENTS[tone];
const trendColor =
trend?.direction === "up"
? "text-success"
: trend?.direction === "down"
? "text-destructive"
: "text-muted-foreground";
return (
<div
className={cn(
"group relative overflow-hidden rounded-xl bg-card p-4 ring-1 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg",
accent.ring,
className,
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">
{value}
</p>
</div>
{icon && (
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4.5",
accent.chip,
)}
>
{icon}
</span>
)}
</div>
<div className="mt-2 flex items-center gap-2">
{trend && (
<span className={cn("text-xs font-medium tabular-nums", trendColor)}>
{trend.direction === "up" ? "↑" : trend.direction === "down" ? "↓" : "→"}{" "}
{trend.value}
</span>
)}
{hint && <span className="truncate text-xs text-muted-foreground">{hint}</span>}
</div>
</div>
);
}
/** 键值信息行。 */
export function InfoRow({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="flex gap-3 py-1.5 text-sm">
<span className="w-28 shrink-0 text-muted-foreground">{label}</span>
<span className="text-foreground">{children}</span>
</div>
);
}
/** 可折叠的原始数据(保底查看,默认折叠)。 */
export function RawDetails({ data }: { data: unknown }) {
return (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
</summary>
<pre className="scrollbar-thin mt-2 max-h-80 overflow-auto rounded-lg bg-muted/60 p-3 text-xs text-muted-foreground">
{JSON.stringify(data, null, 2)}
</pre>
</details>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { ArrowRight, ArrowUpRight } from "lucide-react";
import type { NavGroup, NavItem } from "@/lib/navigation";
const GROUP_THEME: Record<string, { gradient: string; accent: string; label: string }> = {
core: { gradient: "from-primary/12 to-primary/4", accent: "bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground", label: "data" },
practice: { gradient: "from-success/12 to-success/4", accent: "bg-success/10 text-success group-hover:bg-success group-hover:text-success-foreground", label: "practice" },
advance: { gradient: "from-chart-4/12 to-chart-4/4", accent: "bg-chart-4/10 text-chart-4 group-hover:bg-chart-4 group-hover:text-white", label: "advance" },
insight: { gradient: "from-chart-3/12 to-chart-3/4", accent: "bg-chart-3/10 text-chart-3 group-hover:bg-chart-3 group-hover:text-white", label: "insight" },
};
function FeatureCard({ item, groupKey }: { item: NavItem; groupKey: string }) {
const Icon = item.icon;
const theme = GROUP_THEME[groupKey] ?? GROUP_THEME.core;
return (
<Link
href={item.href}
className="group relative flex h-full flex-col gap-3 overflow-hidden rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/[0.06] transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:ring-primary/30"
>
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${theme.gradient} opacity-0 transition-opacity duration-300 group-hover:opacity-100`} />
<div className="relative flex items-center justify-between">
<span className={`flex size-11 items-center justify-center rounded-xl transition-colors duration-300 ${theme.accent}`}>
<Icon className="size-5" />
</span>
<ArrowUpRight className="size-4 text-muted-foreground/40 transition-all duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-primary" />
</div>
<div className="relative">
<h3 className="text-base font-semibold text-foreground">{item.label}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{item.desc}
</p>
</div>
</Link>
);
}
export function FeatureGrid({ items }: { items: NavItem[] }) {
const features = items.filter((i) => i.desc);
return (
<div className="stagger grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{features.map((f) => (
<FeatureCard key={f.href} item={f} groupKey={f.group ?? "core"} />
))}
</div>
);
}
export function GroupedFeatureGrid({ groups }: { groups: NavGroup[] }) {
return (
<div className="space-y-8">
{groups.map((g) => (
<section key={g.key} className="animate-fade-in-up">
<div className="mb-4 flex items-center gap-2">
<div className="h-5 w-1 rounded-full bg-primary" />
<h2 className="text-sm font-bold tracking-wide text-foreground">{g.label}</h2>
<span className="text-xs text-muted-foreground">({g.items.length})</span>
<ArrowRight className="ml-auto size-3.5 text-muted-foreground/50" />
</div>
<div className="stagger grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{g.items.map((item) => (
<FeatureCard key={item.href} item={item} groupKey={g.key} />
))}
</div>
</section>
))}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
/** 跨页面复用的加载 / 错误 / 空态等反馈组件。 */
import { AlertCircle, Inbox, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/** 加载提示。 */
export function Loading({
label = "加载中…",
className,
}: {
label?: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-2 py-6 text-sm text-muted-foreground",
className,
)}
>
<Loader2 className="size-4 animate-spin text-primary" />
{label}
</div>
);
}
/** 全屏居中加载(用于路由守卫)。 */
export function FullPageLoading({ label }: { label?: string }) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3">
<Loader2 className="size-7 animate-spin text-primary" />
{label && <p className="text-sm text-muted-foreground">{label}</p>}
</div>
);
}
/** 错误提示条。 */
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{message}</span>
</div>
);
}
/** 空态提示。 */
export function EmptyState({
message,
icon,
}: {
message: string;
icon?: ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 px-4 py-10 text-center text-sm text-muted-foreground">
{icon ?? <Inbox className="size-6 opacity-60" />}
<span>{message}</span>
</div>
);
}
/** 页面标题区块。 */
export function PageHeading({
title,
description,
actions,
icon,
}: {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
icon?: ReactNode;
}) {
return (
<div className="animate-fade-in-up flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
{icon && (
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary/15 to-primary/5 text-primary ring-1 ring-primary/10">
{icon}
</div>
)}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground">
{title}
</h1>
{description && (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{description}</p>
)}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/**
* 已认证页面的统一外壳与角色守卫。
*
* - 非管理端(学生 / 导师):顶部水平导航,无侧边栏(对齐 GovAi 的 portal 布局)。
* - 管理端:顶栏 + 左侧侧边栏(对齐 GovAi 的 admin 布局)。
* - 未登录:重定向到 /login;角色不匹配:提示无权访问并提供返回入口。
*/
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { FullPageLoading } from "@/components/feedback";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { ROLE_PORTAL_TITLE, type NavGroup, type NavItem } from "@/lib/navigation";
import { ROLE_LABELS, type Role } from "@/lib/types";
import { cn } from "@/lib/utils";
import { homeForRole, useAuthStore } from "@/stores/auth";
/** 校验登录态与角色,返回守卫结果。 */
function useRoleGuard(requiredRole: Role) {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.replace("/login");
}
}, [isLoading, user, router]);
return { user, isLoading, router, allowed: !!user && user.role === requiredRole };
}
/** 角色不匹配时的提示页。 */
function ForbiddenView({
currentRole,
requiredRole,
onBack,
}: {
currentRole: Role;
requiredRole: Role;
onBack: () => void;
}) {
return (
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-xl font-semibold text-foreground">访</h1>
<p className="text-sm text-muted-foreground">
{ROLE_LABELS[currentRole]}访
{ROLE_PORTAL_TITLE[requiredRole]}
</p>
<Button onClick={onBack}></Button>
</div>
</div>
);
}
/**
* 非管理端外壳:顶部水平导航,主区域全宽。
*/
export function PortalShell({
requiredRole,
nav,
groups,
children,
}: {
requiredRole: Role;
nav: NavItem[];
groups?: NavGroup[];
children: ReactNode;
}) {
const { user, isLoading, router, allowed } = useRoleGuard(requiredRole);
if (isLoading) return <FullPageLoading label="正在校验登录态…" />;
if (!user) return null;
if (!allowed) {
return (
<ForbiddenView
currentRole={user.role}
requiredRole={requiredRole}
onBack={() => router.replace(homeForRole(user.role))}
/>
);
}
return (
<div className="flex min-h-screen flex-col bg-background">
<Header nav={nav} groups={groups} />
<main className="w-full flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
</div>
);
}
/**
* 管理端外壳:顶栏 + 左侧侧边栏。
*/
export function AdminShell({
requiredRole,
nav,
children,
}: {
requiredRole: Role;
nav: NavItem[];
children: ReactNode;
}) {
const { user, isLoading, router, allowed } = useRoleGuard(requiredRole);
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
// 路由变化时收起移动端侧栏。
useEffect(() => {
setSidebarOpen(false);
}, [pathname]);
if (isLoading) return <FullPageLoading label="正在校验登录态…" />;
if (!user) return null;
if (!allowed) {
return (
<ForbiddenView
currentRole={user.role}
requiredRole={requiredRole}
onBack={() => router.replace(homeForRole(user.role))}
/>
);
}
return (
<div className="flex min-h-screen flex-col">
<Header onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<div className="relative flex flex-1">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={cn(
"fixed inset-y-[3.5rem] left-0 z-40 w-60 shrink-0 border-r border-sidebar-border bg-sidebar transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="border-b border-sidebar-border px-5 py-3">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
{ROLE_PORTAL_TITLE[requiredRole]}
</p>
</div>
<nav className="scrollbar-thin space-y-1 overflow-y-auto p-3">
{nav.map((item) => {
const active =
pathname === item.href ||
(item.href !== homeForRole(requiredRole) &&
pathname.startsWith(item.href + "/"));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-sidebar-primary text-sidebar-primary-foreground font-medium shadow-sm"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" />
{item.label}
</Link>
);
})}
</nav>
</aside>
<main className="min-w-0 flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}
+277
View File
@@ -0,0 +1,277 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { ChevronDown, HeartPulse, LogOut, Menu, X } from "lucide-react";
import type { NavGroup, NavItem } from "@/lib/navigation";
import { ROLE_LABELS } from "@/lib/types";
import { cn } from "@/lib/utils";
import { homeForRole, useAuthStore } from "@/stores/auth";
import { ThemeToggle } from "@/components/theme-toggle";
export function Header({
nav,
groups,
onToggleSidebar,
}: {
nav?: NavItem[];
groups?: NavGroup[];
onToggleSidebar?: () => void;
}) {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const router = useRouter();
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [openGroup, setOpenGroup] = useState<string | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMobileNavOpen(false);
setOpenGroup(null);
}, [pathname]);
// 点击下拉外部时收起分组下拉。
useEffect(() => {
if (!openGroup) return;
function onDocMouseDown(e: MouseEvent) {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setOpenGroup(null);
}
}
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [openGroup]);
function handleLogout() {
logout();
router.replace("/login");
}
const homeHref = user ? homeForRole(user.role) : "/login";
function isActive(href: string): boolean {
if (href === homeHref) return pathname === href;
return pathname === href || pathname.startsWith(href + "/");
}
// 分组模式下,顶部仅平铺「无分组」的直链项(如工作台),其余项收纳进分组下拉。
const topLinks = groups ? (nav ?? []).filter((n) => !n.group) : (nav ?? []);
function renderMobileLink(item: NavItem) {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
active
? "bg-white/15 text-primary-foreground"
: "text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground",
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
}
return (
<header className="sticky top-0 z-50 w-full border-b border-white/10 bg-gradient-to-r from-[oklch(0.38_0.10_210)] via-primary to-[oklch(0.46_0.12_195)] shadow-lg shadow-primary/10">
<div className="flex h-14 items-center gap-2 px-3 md:gap-4 md:px-6">
{onToggleSidebar && (
<button
className="rounded-md p-1.5 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground md:hidden"
onClick={onToggleSidebar}
aria-label="切换菜单"
>
<Menu className="size-5" />
</button>
)}
{nav && !onToggleSidebar && (
<button
className="rounded-md p-1.5 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground md:hidden"
onClick={() => setMobileNavOpen((v) => !v)}
aria-label="切换菜单"
>
{mobileNavOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
)}
<Link
href={homeHref}
className="group flex shrink-0 items-center gap-2.5 font-bold text-primary-foreground"
>
<span className="flex size-8 items-center justify-center rounded-lg bg-white/15 backdrop-blur-sm transition-transform duration-200 group-hover:scale-105">
<HeartPulse className="size-4.5 text-white" />
</span>
<div className="flex flex-col">
<span className="text-sm font-bold tracking-wide leading-tight">AI </span>
<span className="hidden text-[10px] font-normal leading-tight text-primary-foreground/60 sm:block">
</span>
</div>
</Link>
{nav && (
<nav
ref={navRef}
className="hidden items-center gap-0.5 pl-4 md:flex"
>
{topLinks.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200",
active
? "bg-white/20 text-primary-foreground shadow-sm"
: "text-primary-foreground/75 hover:bg-white/10 hover:text-primary-foreground",
)}
>
<Icon className="size-4" />
{item.label}
{active && (
<span className="absolute -bottom-[9px] left-1/2 h-0.5 w-6 -translate-x-1/2 rounded-full bg-white/80" />
)}
</Link>
);
})}
{groups?.map((group) => {
const isOpen = openGroup === group.key;
const groupActive = group.items.some((it) => isActive(it.href));
return (
<div key={group.key} className="relative">
<button
type="button"
onClick={() => setOpenGroup(isOpen ? null : group.key)}
className={cn(
"relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200",
groupActive || isOpen
? "bg-white/20 text-primary-foreground shadow-sm"
: "text-primary-foreground/75 hover:bg-white/10 hover:text-primary-foreground",
)}
>
{group.label}
<ChevronDown
className={cn(
"size-3.5 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
{groupActive && (
<span className="absolute -bottom-[9px] left-1/2 h-0.5 w-6 -translate-x-1/2 rounded-full bg-white/80" />
)}
</button>
{isOpen && (
<div className="animate-scale-in absolute left-0 top-[calc(100%+0.5rem)] z-50 w-64 overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl">
{group.items.map((it) => {
const ItIcon = it.icon;
const itActive = isActive(it.href);
return (
<Link
key={it.href}
href={it.href}
onClick={() => setOpenGroup(null)}
className={cn(
"flex items-start gap-2.5 rounded-lg px-3 py-2 transition-colors",
itActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted",
)}
>
<ItIcon className="mt-0.5 size-4 shrink-0" />
<span className="flex min-w-0 flex-col">
<span className="text-sm font-medium leading-tight">
{it.label}
</span>
{it.desc && (
<span className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{it.desc}
</span>
)}
</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
)}
<div className="relative ml-auto flex items-center gap-2 md:gap-3">
{user && (
<>
<span className="hidden rounded-full bg-white/12 px-2.5 py-0.5 text-[11px] font-medium text-primary-foreground/90 ring-1 ring-white/10 lg:inline">
{ROLE_LABELS[user.role]}
</span>
<ThemeToggle className="hidden sm:flex" />
<button
onClick={() => setMenuOpen((v) => !v)}
onBlur={() => setTimeout(() => setMenuOpen(false), 120)}
className="flex items-center gap-2 rounded-full py-1 pr-1 pl-2 text-primary-foreground transition hover:bg-white/10"
>
<div className="hidden text-right sm:block">
<p className="text-sm leading-tight font-medium">
{user.username}
</p>
</div>
<span className="flex size-8 items-center justify-center rounded-full bg-gradient-to-br from-white/25 to-white/10 text-sm font-semibold text-white ring-1 ring-white/20">
{user.username.charAt(0).toUpperCase()}
</span>
</button>
{menuOpen && (
<div className="animate-scale-in absolute top-12 right-0 w-48 overflow-hidden rounded-xl border border-border bg-popover py-1 text-popover-foreground shadow-xl">
<div className="border-b border-border px-3 py-2.5">
<p className="text-sm font-medium">{user.username}</p>
<p className="text-xs text-muted-foreground">
{ROLE_LABELS[user.role]}
</p>
</div>
<button
onMouseDown={handleLogout}
className="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-foreground transition hover:bg-muted"
>
<LogOut className="size-4" />
退
</button>
</div>
)}
</>
)}
</div>
</div>
{nav && !onToggleSidebar && mobileNavOpen && (
<div className="animate-fade-in border-t border-white/10 bg-[oklch(0.36_0.10_215)] md:hidden">
<nav className="flex flex-col gap-0.5 px-3 py-2">
{topLinks.map((item) => renderMobileLink(item))}
{groups?.map((group) => (
<div key={group.key} className="mt-1.5">
<p className="px-3 pt-1 pb-0.5 text-[11px] font-semibold uppercase tracking-wider text-primary-foreground/50">
{group.label}
</p>
{group.items.map((item) => renderMobileLink(item))}
</div>
))}
</nav>
</div>
)}
</header>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { AlertCircle, ArrowRight, Target, TrendingUp } from "lucide-react";
import { RadialProgress } from "@/components/charts";
import { Card, CardContent } from "@/components/ui/card";
import { DIMENSION_LABELS } from "@/lib/types";
interface LearningProgressProps {
dimensions: Array<{
dimension: string;
dimensionName?: string;
score: number | string | null;
sensitive?: boolean;
}>;
totalAchievements: number;
}
export function LearningProgress({ dimensions, totalAchievements }: LearningProgressProps) {
const scored = dimensions
.map((d) => (typeof d.score === "number" ? d.score : Number(d.score)))
.filter((n) => !Number.isNaN(n));
const avgScore = scored.length
? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
: null;
const weakest = dimensions
.map((d) => ({
...d,
num: typeof d.score === "number" ? d.score : Number(d.score),
}))
.filter((d) => !Number.isNaN(d.num))
.sort((a, b) => a.num - b.num)[0];
return (
<Card className="animate-fade-in-up overflow-hidden">
<CardContent className="pt-5">
{dimensions.length > 0 ? (
<div className="space-y-5">
<div className="flex items-center gap-5">
<RadialProgress
value={avgScore ?? 0}
size={88}
stroke={8}
label={avgScore == null ? "—" : avgScore}
sublabel="综合分"
tone="primary"
/>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-foreground">
</p>
<p className="text-xs text-muted-foreground">
{dimensions.length} · {totalAchievements}
</p>
<Link
href="/student/profile"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<ArrowRight className="size-3" />
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-2.5 sm:grid-cols-2">
{dimensions.slice(0, 8).map((d) => {
const label =
d.dimensionName ?? DIMENSION_LABELS[d.dimension] ?? d.dimension;
const num =
typeof d.score === "number" ? d.score : Number(d.score);
const insufficient = Number.isNaN(num);
const v = insufficient ? 0 : Math.max(0, Math.min(100, num));
return (
<div key={d.dimension}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium text-foreground/80">{label}</span>
<span className="tabular-nums text-muted-foreground">
{insufficient ? "—" : v.toFixed(0)}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full origin-left rounded-full bg-gradient-to-r from-primary to-primary/70"
style={{
width: `${insufficient ? 4 : v}%`,
transition: "width 0.8s cubic-bezier(0.22,1,0.36,1)",
}}
/>
</div>
</div>
);
})}
</div>
{weakest && (
<div className="flex items-start gap-3 rounded-xl bg-muted/50 p-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-warning/10">
<Target className="size-4 text-warning" />
</div>
<div>
<p className="text-xs font-medium text-foreground"></p>
<p className="mt-0.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{weakest.dimensionName ?? DIMENSION_LABELS[weakest.dimension] ?? weakest.dimension}
</span>{" "}
{weakest.num.toFixed(0)}
</p>
<Link
href="/student/practice"
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<ArrowRight className="size-3" />
</Link>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<TrendingUp className="size-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<Link
href="/student/learning-space"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ArrowRight className="size-3" />
</Link>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { useEffect, useRef } from "react";
import { FullPageLoading } from "@/components/feedback";
import { useAuthStore } from "@/stores/auth";
/** 初次挂载时还原会话;还原期间显示全屏加载,避免闪烁。 */
function AuthLoader({ children }: { children: React.ReactNode }) {
const fetchUser = useAuthStore((s) => s.fetchUser);
const isLoading = useAuthStore((s) => s.isLoading);
useEffect(() => {
fetchUser();
}, [fetchUser]);
if (isLoading) {
return <FullPageLoading label="正在校验登录态…" />;
}
return <>{children}</>;
}
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
const queryClientRef = useRef<QueryClient | null>(null);
if (!queryClientRef.current) {
queryClientRef.current = createQueryClient();
}
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<QueryClientProvider client={queryClientRef.current}>
<AuthLoader>{children}</AuthLoader>
</QueryClientProvider>
</ThemeProvider>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import {
Award,
BookOpen,
BriefcaseMedical,
CalendarDays,
GraduationCap,
Sparkles,
Stethoscope,
Search,
type LucideIcon,
Users,
Video,
} from "lucide-react";
interface QuickAction {
href: string;
label: string;
sublabel: string;
icon: LucideIcon;
color: string;
bg: string;
}
const ACTIONS: QuickAction[] = [
{
href: "/student/learning-space",
label: "记录学业",
sublabel: "成长档案",
icon: BookOpen,
color: "text-primary",
bg: "bg-primary/10 group-hover:bg-primary group-hover:text-white",
},
{
href: "/student/practice",
label: "刷题训练",
sublabel: "医学题库",
icon: GraduationCap,
color: "text-success",
bg: "bg-success/10 group-hover:bg-success group-hover:text-white",
},
{
href: "/student/clinical",
label: "模拟问诊",
sublabel: "临床模拟",
icon: Stethoscope,
color: "text-chart-3",
bg: "bg-chart-3/10 group-hover:bg-chart-3 group-hover:text-white",
},
{
href: "/student/research",
label: "文献检索",
sublabel: "循证检索",
icon: Search,
color: "text-warning-foreground",
bg: "bg-warning/12 group-hover:bg-warning group-hover:text-white",
},
{
href: "/student/case-reasoning",
label: "病例推演",
sublabel: "专业精进",
icon: BriefcaseMedical,
color: "text-chart-4",
bg: "bg-chart-4/10 group-hover:bg-chart-4 group-hover:text-white",
},
{
href: "/student/profile",
label: "胜任力画像",
sublabel: "能力评估",
icon: Sparkles,
color: "text-chart-5",
bg: "bg-chart-5/10 group-hover:bg-chart-5 group-hover:text-white",
},
{
href: "/student/rotation",
label: "轮转见习",
sublabel: "科室轮转",
icon: CalendarDays,
color: "text-primary",
bg: "bg-primary/10 group-hover:bg-primary group-hover:text-white",
},
{
href: "/student/skill-video",
label: "技能视频",
sublabel: "操作学习",
icon: Video,
color: "text-chart-3",
bg: "bg-chart-3/10 group-hover:bg-chart-3 group-hover:text-white",
},
{
href: "/student/exam-prep",
label: "执医备考",
sublabel: "资格考试",
icon: Award,
color: "text-warning-foreground",
bg: "bg-warning/12 group-hover:bg-warning group-hover:text-white",
},
{
href: "/student/academic",
label: "学术交流",
sublabel: "文献组会",
icon: Users,
color: "text-chart-4",
bg: "bg-chart-4/10 group-hover:bg-chart-4 group-hover:text-white",
},
];
export function QuickActions() {
return (
<div className="stagger grid grid-cols-3 gap-3 sm:grid-cols-6">
{ACTIONS.map((a) => {
const Icon = a.icon;
return (
<Link
key={a.href}
href={a.href}
className="group flex flex-col items-center gap-2.5 rounded-2xl bg-card p-4 ring-1 ring-foreground/[0.06] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/25"
>
<span className={`flex size-12 items-center justify-center rounded-xl transition-all duration-300 ${a.bg}`}>
<Icon className="size-5.5" />
</span>
<div className="text-center">
<p className="text-sm font-semibold text-foreground">{a.label}</p>
<p className="text-[11px] text-muted-foreground">{a.sublabel}</p>
</div>
</Link>
);
})}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
/** 基础骨架块(带微光)。 */
export function SkeletonBlock({ className }: { className?: string }) {
return (
<div
className={cn(
"skeleton-shimmer rounded-md bg-muted/70",
className,
)}
/>
);
}
/** 卡片骨架。 */
export function SkeletonCard({ className }: { className?: string }) {
return (
<div
className={cn(
"flex flex-col gap-3 rounded-xl bg-card p-5 ring-1 ring-foreground/10",
className,
)}
>
<SkeletonBlock className="h-4 w-1/3" />
<SkeletonBlock className="h-3 w-2/3" />
<SkeletonBlock className="h-3 w-1/2" />
</div>
);
}
/** 列表骨架(多行)。 */
export function SkeletonList({ rows = 4 }: { rows?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="flex-1 space-y-2">
<SkeletonBlock className="h-3.5 w-2/5" />
<SkeletonBlock className="h-3 w-1/4" />
</div>
<SkeletonBlock className="h-5 w-16 rounded-full" />
</div>
))}
</div>
);
}
/** 统计卡网格骨架。 */
export function SkeletonStats({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="space-y-2 rounded-xl bg-card p-4 ring-1 ring-foreground/10"
>
<SkeletonBlock className="h-7 w-1/2" />
<SkeletonBlock className="h-3 w-2/3" />
</div>
))}
</div>
);
}
/** 多维评分骨架(条形)。 */
export function SkeletonBars({ rows = 6 }: { rows?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex justify-between">
<SkeletonBlock className="h-3.5 w-20" />
<SkeletonBlock className="h-3.5 w-8" />
</div>
<SkeletonBlock className="h-2.5 w-full rounded-full" />
</div>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Monitor, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
const OPTIONS = [
{ value: "light", icon: Sun, label: "浅色" },
{ value: "dark", icon: Moon, label: "深色" },
{ value: "system", icon: Monitor, label: "跟随系统" },
] as const;
/** 三态主题切换(浅色 / 深色 / 跟随系统),用于顶栏。 */
export function ThemeToggle({ className }: { className?: string }) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className={cn("h-8 w-[5.25rem] rounded-full bg-white/10", className)} />;
}
return (
<div
className={cn(
"flex items-center gap-0.5 rounded-full border border-white/20 bg-white/10 p-0.5",
className,
)}
>
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const active = (theme ?? "system") === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
aria-label={opt.label}
title={opt.label}
className={cn(
"flex size-7 items-center justify-center rounded-full transition-colors",
active
? "bg-white/90 text-primary shadow-sm"
: "text-primary-foreground/70 hover:text-primary-foreground",
)}
>
<Icon className="size-3.5" />
</button>
);
})}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap [&>svg]:size-3 [&>svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
outline: "border-border text-foreground",
success: "bg-success/15 text-success",
warning: "bg-warning/20 text-warning-foreground",
destructive: "bg-destructive/12 text-destructive",
info: "bg-primary/12 text-primary",
muted: "bg-muted text-muted-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 active:translate-y-px",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
outline:
"border border-input bg-background hover:bg-muted hover:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-muted hover:text-foreground",
destructive:
"bg-destructive text-white hover:bg-destructive/90 shadow-sm",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-[0.8rem]",
lg: "h-10 rounded-lg px-6",
icon: "size-9",
"icon-sm": "size-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, type = "button", ...props }, ref) => {
return (
<button
ref={ref}
type={type}
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+83
View File
@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-4 rounded-xl bg-card py-5 text-sm text-card-foreground shadow-sm ring-1 ring-foreground/10",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"flex items-start justify-between gap-3 px-5 [.border-b]:pb-4",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("shrink-0", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-5", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-5 [.border-t]:pt-4", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"mb-1.5 flex items-center gap-1 text-sm font-medium text-foreground select-none",
className,
)}
{...props}
/>
);
}
export { Label };
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 轻量原生 <select> 封装:保留与设计系统一致的样式与右侧箭头。
*/
function Select({ className, children, ...props }: React.ComponentProps<"select">) {
return (
<div className="relative">
<select
data-slot="select"
className={cn(
"h-9 w-full appearance-none rounded-lg border border-input bg-transparent px-3 pr-9 text-sm shadow-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
export { Select };
+22
View File
@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
data-slot="separator"
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"min-h-16 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Textarea };
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { ChevronDown, Info, Lightbulb } from "lucide-react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface UsageStep {
/** 步骤标题(加粗) */
title: string;
/** 步骤说明 */
detail?: ReactNode;
}
/**
* 页面使用说明卡片:编号步骤 + 可选提示,默认展开、可折叠。
*
* 统一各功能页的「使用说明」呈现,降低上手成本。
*/
export function UsageGuide({
steps,
tip,
title = "使用说明",
defaultOpen = true,
className,
}: {
steps: UsageStep[];
tip?: ReactNode;
title?: string;
defaultOpen?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div
className={cn(
"animate-fade-in overflow-hidden rounded-xl border border-primary/15 bg-primary/[0.03] transition-colors hover:border-primary/25",
className,
)}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-left"
aria-expanded={open}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Info className="size-4" />
</span>
<span className="text-sm font-semibold text-foreground">{title}</span>
<ChevronDown
className={cn(
"ml-auto size-4 text-muted-foreground transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
{open && (
<div className="px-4 pb-4">
<ol className="space-y-2.5">
{steps.map((s, i) => (
<li key={i} className="flex gap-3">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground tabular-nums">
{i + 1}
</span>
<div className="text-sm leading-relaxed">
<span className="font-medium text-foreground">{s.title}</span>
{s.detail && (
<span className="text-muted-foreground">
{" — "}
{s.detail}
</span>
)}
</div>
</li>
))}
</ol>
{tip && (
<div className="mt-3 flex items-start gap-2 rounded-lg bg-warning/10 px-3 py-2 text-xs text-warning-foreground">
<Lightbulb className="mt-0.5 size-3.5 shrink-0" />
<span className="leading-relaxed">{tip}</span>
</div>
)}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function WelcomeBanner({
greeting,
title,
description,
icon,
aside,
className,
}: {
greeting?: ReactNode;
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
aside?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"animate-fade-in-up relative overflow-hidden rounded-2xl bg-gradient-to-br from-[oklch(0.42_0.10_210)] via-primary to-[oklch(0.48_0.13_190)] p-6 text-primary-foreground shadow-lg shadow-primary/15 md:p-8",
className,
)}
>
{/* decorative orbs */}
<div className="pointer-events-none absolute -top-20 -right-10 size-64 rounded-full bg-white/8 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 left-1/4 size-56 rounded-full bg-white/5 blur-3xl" />
<div className="animate-pulse-soft pointer-events-none absolute top-6 right-1/4 size-3 rounded-full bg-white/25" />
<div className="animate-pulse-soft pointer-events-none absolute bottom-8 left-1/6 size-2 rounded-full bg-white/20" style={{ animationDelay: "1s" }} />
<div className="relative flex flex-wrap items-center justify-between gap-6">
<div className="flex items-start gap-4">
{icon && (
<span className="animate-float flex size-14 shrink-0 items-center justify-center rounded-2xl bg-white/15 shadow-lg shadow-black/5 backdrop-blur-sm [&_svg]:size-7">
{icon}
</span>
)}
<div>
{greeting && (
<p className="text-sm font-medium text-primary-foreground/70">{greeting}</p>
)}
<h1 className="mt-0.5 text-2xl font-bold tracking-tight md:text-3xl">{title}</h1>
{description && (
<p className="mt-2 max-w-lg text-sm leading-relaxed text-primary-foreground/80">
{description}
</p>
)}
</div>
</div>
{aside && <div className="shrink-0">{aside}</div>}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 后端 API 调用客户端。
*
* 统一处理:
* - 基础路径:所有请求走 `/api/*`,由 Next.js rewrites 代理到 NestJS(避免跨域)。
* - 鉴权:自动附加 `Authorization: Bearer <token>`token 由 auth store 写入 localStorage)。
* - 错误:非 2xx 响应解析后端统一错误体(AppError.toJSON())并抛出 `ApiError`。
*/
import type { ApiErrorBody } from "./types";
const TOKEN_KEY = "cac.token";
/** 携带后端错误码与 HTTP 状态的错误对象,便于 UI 精细处理。 */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: ApiErrorBody,
) {
super(body.message ?? `请求失败(HTTP ${status}`);
this.name = "ApiError";
}
}
/** 从 localStorage 读取访问令牌(仅浏览器环境)。 */
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(TOKEN_KEY);
}
/** 写入访问令牌。 */
export function setToken(token: string): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_KEY, token);
}
/** 清除访问令牌。 */
export function clearToken(): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_KEY);
}
interface RequestOptions {
method?: string;
body?: unknown;
/** 查询参数;值为 undefined / null / '' 时跳过。 */
query?: Record<string, string | number | undefined | null>;
/** 是否需要鉴权(默认 true)。 */
auth?: boolean;
/** 透传 fetch 选项(如 AbortSignal)。 */
signal?: AbortSignal;
}
function buildUrl(path: string, query?: RequestOptions["query"]): string {
if (!query) return path;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === "") continue;
params.append(key, String(value));
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
}
/** 发起一次 API 请求并返回解析后的 JSON(或 void)。 */
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, query, auth = true, signal } = options;
const headers: Record<string, string> = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (auth) {
const token = getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path, query), {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
const data = text ? safeParse(text) : undefined;
if (!res.ok) {
const errBody: ApiErrorBody =
data && typeof data === "object" ? (data as ApiErrorBody) : { message: text };
throw new ApiError(res.status, errBody);
}
return data as T;
}
function safeParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+202
View File
@@ -0,0 +1,202 @@
import {
Award,
BookOpen,
Bot,
BriefcaseMedical,
CalendarDays,
ClipboardCheck,
FlaskConical,
GraduationCap,
LayoutDashboard,
MessageCircleQuestion,
MessageSquareText,
type LucideIcon,
Pill,
Search,
ShieldCheck,
Sparkles,
Stethoscope,
Target,
Users,
Video,
} from "lucide-react";
import { Role } from "@/lib/types";
export interface NavItem {
href: string;
label: string;
icon: LucideIcon;
/** 卡片网格中的简介。 */
desc?: string;
/** 所属功能分组(用于仪表盘分区展示)。 */
group?: "core" | "practice" | "advance" | "insight";
}
/** 学生端导航。 */
export const STUDENT_NAV: NavItem[] = [
{ href: "/student", label: "学习中心", icon: LayoutDashboard },
{
href: "/student/learning-space",
label: "成长档案",
icon: BookOpen,
desc: "记录课程学分、见习轮转、科研产出等医学学习成果,沉淀成长轨迹。",
group: "core",
},
{
href: "/student/profile",
label: "胜任力画像",
icon: Sparkles,
desc: "基于医学教育六维胜任力框架,量化能力水平并追溯数据来源。",
group: "insight",
},
{
href: "/student/career",
label: "执业发展",
icon: Target,
desc: "设定临床医师、科研等执业方向,生成个性化发展规划。",
group: "insight",
},
{
href: "/student/practice",
label: "医学题库",
icon: GraduationCap,
desc: "基于教学大纲生成练习题,在线作答并获得知识薄弱点分析。",
group: "practice",
},
{
href: "/student/clinical",
label: "临床模拟",
icon: Stethoscope,
desc: "模拟问诊、查体、医患沟通等临床情景,获得多维度评估反馈。",
group: "practice",
},
{
href: "/student/research",
label: "循证检索",
icon: Search,
desc: "按 PICO 框架生成检索式,检索 PubMed / 知网等数据库并生成引用。",
group: "core",
},
{
href: "/student/collaboration",
label: "医学 AI 协作",
icon: Bot,
desc: "在 AI 辅助下完成临床与科研任务,评估人机协作胜任力。",
group: "practice",
},
{
href: "/student/case-reasoning",
label: "病例推演",
icon: BriefcaseMedical,
desc: "渐进式披露病史、体征与检验结果,训练鉴别诊断与临床决策思维。",
group: "advance",
},
{
href: "/student/medication",
label: "用药安全",
icon: Pill,
desc: "练习药物剂量计算、相互作用识别与处方审核,强化安全用药能力。",
group: "advance",
},
{
href: "/student/knowledge",
label: "医学问答",
icon: MessageCircleQuestion,
desc: "向 AI 提问医学概念、机制与鉴别要点,获得带来源标注的专业解答。",
group: "advance",
},
{
href: "/student/rotation",
label: "轮转见习",
icon: CalendarDays,
desc: "对接医院轮转安排,记录科室出勤、带教评价与轮转心得。",
group: "core",
},
{
href: "/student/skill-video",
label: "技能视频",
icon: Video,
desc: "手术与操作技能视频学习,支持分段自评与关键点标记。",
group: "practice",
},
{
href: "/student/exam-prep",
label: "执业医师备考",
icon: Award,
desc: "针对执业医师考试的专项题库与进度追踪,强化薄弱科目。",
group: "practice",
},
{
href: "/student/academic",
label: "学术交流",
icon: Users,
desc: "文献汇报、病例讨论记录与导师反馈,沉淀学术交流轨迹。",
group: "core",
},
];
export interface NavGroup {
key: string;
label: string;
items: NavItem[];
}
export const STUDENT_NAV_GROUPS: NavGroup[] = [
{ key: "core", label: "学业沉淀", items: STUDENT_NAV.filter((n) => n.group === "core") },
{ key: "practice", label: "临床与训练", items: STUDENT_NAV.filter((n) => n.group === "practice") },
{ key: "advance", label: "专业精进", items: STUDENT_NAV.filter((n) => n.group === "advance") },
{ key: "insight", label: "能力与发展", items: STUDENT_NAV.filter((n) => n.group === "insight") },
];
/** 导师端导航。 */
export const MENTOR_NAV: NavItem[] = [
{ href: "/mentor", label: "带教工作台", icon: LayoutDashboard },
{
href: "/mentor/review",
label: "题库审核",
icon: ClipboardCheck,
desc: "审核学生出题,把控医学题库质量,通过或退回并说明原因。",
},
{
href: "/mentor/comments",
label: "教学点评",
icon: MessageSquareText,
desc: "对学生的学业成果进行教学性点评,指导临床与科研能力提升。",
},
{
href: "/mentor/students",
label: "学生胜任力",
icon: Users,
desc: "查看带教学生的胜任力画像(合规脱敏授权)。",
},
];
/** 管理端导航。 */
export const ADMIN_NAV: NavItem[] = [
{ href: "/admin", label: "管理工作台", icon: LayoutDashboard },
{
href: "/admin/skills",
label: "AI 能力治理",
icon: FlaskConical,
desc: "管理平台 AI 技能定义、知识源配置与输出质量规则。",
},
{
href: "/admin/compliance",
label: "权限与合规",
icon: ShieldCheck,
desc: "管理三端角色权限,审计操作日志与越权拒绝事件。",
},
];
export const NAV_BY_ROLE: Record<Role, NavItem[]> = {
[Role.Student]: STUDENT_NAV,
[Role.Mentor]: MENTOR_NAV,
[Role.Administrator]: ADMIN_NAV,
};
export const ROLE_PORTAL_TITLE: Record<Role, string> = {
[Role.Student]: "医学生学习平台",
[Role.Mentor]: "临床带教平台",
[Role.Administrator]: "教务管理平台",
};
+390
View File
@@ -0,0 +1,390 @@
/**
* 按后端模块组织的前端 API 服务函数。
*
* 仅封装 URL / 方法 / 参数,鉴权与错误处理在 `api.ts` 中统一完成。
* 返回类型尽量贴合后端,未严格建模的复杂结构用 `unknown` / 宽松对象表示,UI 按需取用。
*/
import { apiRequest } from "./api";
import type {
Achievement,
AchievementType,
CareerGoalAssociation,
Citation,
CollaborationAssessment,
CompetencyModel,
DevelopmentPlan,
DialogueReport,
Paginated,
PracticeReport,
SearchQuery,
SkillAuditLogEntry,
SkillDefinition,
StudentProfile,
StudentProfileView,
Summary,
} from "./types";
/* ----------------------------- 学习空间 ----------------------------- */
export interface AddAchievementInput {
type: AchievementType;
title: string;
occurredAt: string; // ISO-8601
academicYear?: string;
semester?: string;
rotationDept?: string;
}
export const learningSpaceApi = {
addAchievement: (input: AddAchievementInput) =>
apiRequest<{ achievement: Achievement; mappedTags?: unknown[] }>(
"/api/learning-space/achievements",
{ method: "POST", body: input },
),
listAchievements: (params: {
type?: AchievementType;
academicYear?: string;
semester?: string;
rotationDept?: string;
page?: number;
pageSize?: number;
}) =>
apiRequest<Paginated<Achievement>>("/api/learning-space/achievements", {
query: params,
}),
markMilestone: (input: {
name: string;
reachedAt?: string;
achievementIds?: string[];
}) =>
apiRequest<unknown>("/api/learning-space/milestones", {
method: "POST",
body: input,
}),
};
/* ----------------------------- 画像引擎 ----------------------------- */
export const profileApi = {
generate: () => apiRequest<StudentProfile>("/api/profile"),
traceability: (dimension: string) =>
apiRequest<unknown[]>(
`/api/profile/dimensions/${encodeURIComponent(dimension)}/traceability`,
),
};
/* ----------------------------- 职业规划 ----------------------------- */
export const careerApi = {
getGoal: () => apiRequest<CareerGoalAssociation | null>("/api/career/goal"),
setGoal: (input: { id?: string; title: string; description?: string }) =>
apiRequest<CompetencyModel>("/api/career/goal", {
method: "POST",
body: input,
}),
generatePlan: (goalId: string) =>
apiRequest<DevelopmentPlan>("/api/career/development-plan", {
method: "POST",
body: { goalId },
}),
};
/* --------------------------- 课程对练引擎 --------------------------- */
export const practiceApi = {
generateQuestions: (courseId: string, body: unknown) =>
apiRequest<unknown>(`/api/practice/courses/${courseId}/questions`, {
method: "POST",
body,
}),
listAvailableQuestions: (courseId: string) =>
apiRequest<unknown[]>(
`/api/practice/courses/${courseId}/available-questions`,
),
startSession: (courseId: string, body: unknown = {}) =>
apiRequest<unknown>(`/api/practice/courses/${courseId}/sessions`, {
method: "POST",
body,
}),
submitAnswer: (sessionId: string, body: unknown) =>
apiRequest<unknown>(`/api/practice/sessions/${sessionId}/answers`, {
method: "POST",
body,
}),
finishSession: (sessionId: string) =>
apiRequest<PracticeReport>(`/api/practice/sessions/${sessionId}/finish`, {
method: "POST",
body: {},
}),
};
/* ------------------------- 临床情景对话对练 ------------------------- */
export const clinicalApi = {
start: (scenarioId: string, body: unknown = {}) =>
apiRequest<unknown>(
`/api/clinical-dialogue/scenarios/${scenarioId}/sessions`,
{ method: "POST", body },
),
sendTurn: (sessionId: string, body: unknown) =>
apiRequest<unknown>(`/api/clinical-dialogue/sessions/${sessionId}/turns`, {
method: "POST",
body,
}),
finish: (sessionId: string) =>
apiRequest<DialogueReport>(
`/api/clinical-dialogue/sessions/${sessionId}/finish`,
{ method: "POST", body: {} },
),
};
/* --------------------------- 研究资料查询 --------------------------- */
export const researchApi = {
generateSearchQuery: (body: unknown) =>
apiRequest<{ query?: SearchQuery } & SearchQuery>(
"/api/research/search-queries",
{ method: "POST", body },
),
search: (body: unknown) =>
apiRequest<unknown>("/api/research/search", { method: "POST", body }),
summarize: (body: unknown) =>
apiRequest<Summary>("/api/research/summaries", { method: "POST", body }),
generateCitation: (body: unknown) =>
apiRequest<Citation[]>("/api/research/citations", {
method: "POST",
body,
}),
};
/* --------------------------- AI 协同训练 --------------------------- */
export const collaborationApi = {
listTasks: (params: Record<string, string | undefined> = {}) =>
apiRequest<unknown[]>("/api/collaboration/tasks", { query: params }),
evaluateTask: (taskId: string, body: unknown) =>
apiRequest<CollaborationAssessment>(
`/api/collaboration/tasks/${taskId}/evaluations`,
{ method: "POST", body },
),
listComments: () => apiRequest<unknown[]>("/api/collaboration/comments"),
};
/* ----------------------------- 病例推演 ----------------------------- */
export const caseReasoningApi = {
listCases: () =>
apiRequest<unknown[]>("/api/case-reasoning/cases"),
startSession: (caseId: string) =>
apiRequest<unknown>(`/api/case-reasoning/cases/${caseId}/sessions`, {
method: "POST",
body: {},
}),
submitReasoning: (sessionId: string, body: { step: string; answer: string }) =>
apiRequest<unknown>(`/api/case-reasoning/sessions/${sessionId}/steps`, {
method: "POST",
body,
}),
finishSession: (sessionId: string) =>
apiRequest<unknown>(`/api/case-reasoning/sessions/${sessionId}/finish`, {
method: "POST",
body: {},
}),
};
/* ----------------------------- 用药安全 ----------------------------- */
export const medicationApi = {
generateScenario: (body: { category?: string }) =>
apiRequest<unknown>("/api/medication-safety/scenarios", {
method: "POST",
body,
}),
submitAnswer: (scenarioId: string, body: { answer: string }) =>
apiRequest<unknown>(`/api/medication-safety/scenarios/${scenarioId}/answers`, {
method: "POST",
body,
}),
listHistory: () =>
apiRequest<unknown[]>("/api/medication-safety/history"),
};
/* ----------------------------- 医学问答 ----------------------------- */
export const knowledgeApi = {
ask: (body: { question: string; context?: string }) =>
apiRequest<unknown>("/api/medical-knowledge/ask", {
method: "POST",
body,
}),
listHistory: () =>
apiRequest<unknown[]>("/api/medical-knowledge/history"),
};
/* ------------------------------ 导师端 ------------------------------ */
export const mentorApi = {
reviewQuestion: (
questionId: string,
body: { decision: string; reason?: string },
) =>
apiRequest<unknown>(`/api/mentor/questions/${questionId}/review`, {
method: "POST",
body,
}),
addComment: (achievementId: string, comment: string) =>
apiRequest<unknown>(`/api/mentor/achievements/${achievementId}/comments`, {
method: "POST",
body: { comment },
}),
listComments: (achievementId: string) =>
apiRequest<unknown[]>(`/api/mentor/achievements/${achievementId}/comments`),
listStudents: () => apiRequest<string[]>("/api/mentor/students"),
viewStudentProfile: (studentId: string) =>
apiRequest<StudentProfileView>(`/api/mentor/students/${studentId}/profile`),
};
/* ------------------------------ 管理端 ------------------------------ */
export interface SkillDefinitionInput {
id?: string;
name: string;
inputSpec: unknown;
processingLogic: unknown;
knowledgeSources: unknown;
outputFormat: unknown;
credibilityRule: unknown;
enabled?: boolean;
}
export const adminSkillsApi = {
list: () => apiRequest<SkillDefinition[]>("/api/admin/skills"),
get: (skillId: string) =>
apiRequest<SkillDefinition | null>(`/api/admin/skills/${skillId}`),
auditLogs: () =>
apiRequest<SkillAuditLogEntry[]>("/api/admin/skills/audit-logs"),
upsert: (body: SkillDefinitionInput) =>
apiRequest<SkillDefinition>("/api/admin/skills", {
method: "POST",
body,
}),
enable: (skillId: string) =>
apiRequest<SkillDefinition>(`/api/admin/skills/${skillId}/enable`, {
method: "POST",
body: {},
}),
};
export const adminComplianceApi = {
permissionScopes: () =>
apiRequest<unknown[]>("/api/admin/compliance/permission-scopes"),
auditLogs: (actorId?: string) =>
apiRequest<unknown[]>("/api/admin/compliance/audit-logs", {
query: { actorId },
}),
denialEvents: (actorId?: string) =>
apiRequest<unknown[]>("/api/admin/compliance/denial-events", {
query: { actorId },
}),
};
/* ----------------------------- 轮转见习 ----------------------------- */
export const rotationApi = {
addRecord: (body: {
department: string;
startDate: string;
endDate: string;
supervisor?: string;
notes?: string;
}) =>
apiRequest<unknown>("/api/rotation/records", {
method: "POST",
body,
}),
listRecords: () => apiRequest<unknown[]>("/api/rotation/records"),
};
/* ----------------------------- 技能视频 ----------------------------- */
export const skillVideoApi = {
search: (params: { query?: string; category?: string }) =>
apiRequest<unknown[]>("/api/skill-videos", { query: params }),
};
/* --------------------------- 执业医师备考 --------------------------- */
export const examPrepApi = {
generateQuestion: (params: { subject?: string; mode?: string }) =>
apiRequest<unknown>("/api/exam-prep/questions/generate", {
method: "POST",
body: params,
}),
submitAnswer: (questionId: string, body: { answer: string }) =>
apiRequest<unknown>(`/api/exam-prep/questions/${questionId}/answer`, {
method: "POST",
body,
}),
getStats: () => apiRequest<unknown>("/api/exam-prep/stats"),
listHistory: () => apiRequest<unknown[]>("/api/exam-prep/history"),
deleteHistory: (recordId: string) =>
apiRequest<unknown>(`/api/exam-prep/history/${recordId}`, { method: "DELETE" }),
deleteAllHistory: () =>
apiRequest<unknown>("/api/exam-prep/history", { method: "DELETE" }),
};
/* ----------------------------- 学术交流 ----------------------------- */
export const academicApi = {
addRecord: (body: {
type?: string;
title: string;
content: string;
mentorFeedback?: string;
}) =>
apiRequest<unknown>("/api/academic/records", {
method: "POST",
body,
}),
listRecords: () => apiRequest<unknown[]>("/api/academic/records"),
};
+366
View File
@@ -0,0 +1,366 @@
/**
* 与后端领域类型对齐的前端类型定义。
*
* 仅声明前端实际消费的字段;后端返回的对象可能含更多字段,按需扩展即可。
* 枚举值与后端字符串字面量保持一致(如 Role 的 'student' / 'mentor' / 'administrator')。
*/
/** 全系统规范角色(与后端 compliance.Role 对齐)。 */
export enum Role {
Student = "student",
Mentor = "mentor",
Administrator = "administrator",
}
/** 角色中文展示名。 */
export const ROLE_LABELS: Record<Role, string> = {
[Role.Student]: "学生",
[Role.Mentor]: "导师",
[Role.Administrator]: "管理员",
};
/** 认证身份(与后端 AuthenticatedUser 对齐)。 */
export interface AuthenticatedUser {
id: string;
username: string;
role: Role;
}
/** 登录/注册结果(与后端 AuthResult 对齐)。 */
export interface AuthResult {
accessToken: string;
expiresIn: number;
user: AuthenticatedUser;
}
/** 后端统一错误响应体(AppError.toJSON())。 */
export interface ApiErrorBody {
kind?: string;
code?: string;
message?: string;
details?: unknown;
}
/** 学习成果类型(与后端 AchievementType 对齐,共 9 种)。 */
export enum AchievementType {
CourseRecord = "course_record",
Assignment = "assignment",
LabReport = "lab_report",
ClinicalClerkship = "clinical_clerkship",
OsceAssessment = "osce_assessment",
LiteratureReading = "literature_reading",
ResearchOutput = "research_output",
Certificate = "certificate",
LicensingExamPrep = "licensing_exam_prep",
}
export const ACHIEVEMENT_TYPE_LABELS: Record<string, string> = {
course_record: "课程记录",
assignment: "作业",
lab_report: "实验报告",
clinical_clerkship: "临床见习/实习",
osce_assessment: "OSCE 技能考核",
literature_reading: "文献阅读",
research_output: "科研成果",
certificate: "证书",
licensing_exam_prep: "执业资格备考",
};
/** 分页结果(与后端 Paginated<T> 对齐)。 */
export interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/** 学习成果。 */
export interface Achievement {
id: string;
studentId: string;
type: AchievementType;
title: string;
occurredAt: string;
academicYear?: string;
semester?: string;
rotationDept?: string;
attachments?: { id: string; name: string; url: string }[];
}
/** 画像维度分值(与后端画像引擎对齐)。score 为数字或字符串 'insufficient_data'。 */
export interface ProfileDimensionScore {
dimension: string;
dimensionName?: string;
score: number | string | null;
sensitive?: boolean;
sensitiveCategory?: string;
}
/** 学生六维画像。 */
export interface StudentProfile {
studentId: string;
dimensions: ProfileDimensionScore[];
generatedAt?: string;
}
/** 维度中文展示名(覆盖常见维度键,未覆盖的回退为原值)。 */
export const DIMENSION_LABELS: Record<string, string> = {
knowledge: "知识掌握",
clinical: "临床能力",
research: "科研能力",
collaboration: "协同能力",
practice: "实践能力",
professionalism: "职业素养",
};
/* ===================== 各模块结构化返回类型(与后端对齐) ===================== */
/** 可信度标注来源。 */
export interface SourceRef {
id: string;
title: string;
citation?: string;
}
/** 可信度标注(AI 输出统一封装)。 */
export interface CredibilityAnnotation {
sources: SourceRef[];
confidence: number;
verified: boolean;
}
/* ---------- 职业规划:发展规划 ---------- */
export interface SuggestedAction {
description: string;
targetLevel: number;
}
export interface RecommendedResource {
id: string;
title: string;
type: string;
tagId: string;
}
export interface CompetencyGap {
tagId: string;
tagName: string;
dimension: string;
dimensionName: string;
currentLevel: number | string;
requiredLevel: number;
missingData: boolean;
suggestedActions: SuggestedAction[];
recommendedResources: RecommendedResource[];
resourceNote?: string;
}
export interface DevelopmentPlan {
studentId: string;
goalId: string;
goalTitle: string;
modelId: string;
gaps: CompetencyGap[];
generatedAt: string;
}
/** 岗位胜任力模型(setCareerGoal 返回)。 */
export interface CompetencyModel {
id: string;
goalId: string;
goalTitle: string;
framework: string;
dimensions: {
dimension: string;
dimensionName: string;
competencyTags: {
tagId: string;
tagName: string;
dimension: string;
requiredLevel: number;
}[];
}[];
generatedAt: string;
}
export interface CareerGoalAssociation {
studentId: string;
goal: { id: string; title: string; description?: string };
model: CompetencyModel;
associatedAt: string;
}
/* ---------- 课程对练:报告 ---------- */
export interface WeakArea {
tagId: string;
tagName?: string;
totalQuestions: number;
correctCount: number;
correctnessRate: number;
}
export interface PracticeImprovementSuggestion {
content: string;
competencyTagIds: string[];
annotation: CredibilityAnnotation;
trustOutputId: string;
}
export interface CompetencyTagBreakdown {
tagId: string;
totalQuestions: number;
correctCount: number;
correctnessRate: number;
}
export interface PracticeReport {
id: string;
sessionId: string;
studentId: string;
courseId: string;
totalQuestions: number;
correctCount: number;
incorrectCount: number;
accuracy: number;
totalTimeSeconds: number;
tagBreakdown: CompetencyTagBreakdown[];
weakAreas: WeakArea[];
suggestions: PracticeImprovementSuggestion[];
competencyMappingIds: string[];
generatedAt: string;
}
export interface AnswerResult {
sessionId: string;
questionId: string;
correct: boolean;
timedOut: boolean;
elapsedMs: number;
next?: { questionId: string; position: number; total: number };
}
/* ---------- 临床对话:三维报告 ---------- */
export interface DialogueDimensionScore {
dimension: string;
dimensionName: string;
score: number;
competencyTagId?: string;
comment: string;
}
export interface DialogueReport {
id: string;
sessionId: string;
studentId: string;
scenarioId: string;
dimensions: DialogueDimensionScore[];
overallScore: number;
annotation: CredibilityAnnotation;
trustOutputId: string;
turnCount: number;
generatedAt: string;
}
/* ---------- 研究查询:检索式/总结/引用 ---------- */
export interface SearchQuery {
id: string;
question: string;
pico: {
population?: string;
intervention?: string;
comparison?: string;
outcome?: string;
};
meshTerms: string[];
keywords: string[];
expression: string;
rationale?: string;
}
export interface SummaryConclusion {
id: string;
statement: string;
verified: boolean;
citations: SourceRef[];
unverifiedLabel?: string;
}
export interface GradedReference {
itemId: string;
title: string;
url: string;
source: string;
evidenceLevel: string;
}
export interface Summary {
id: string;
conclusions: SummaryConclusion[];
citationOutput: SummaryConclusion[];
gradedItems: GradedReference[];
notice: string;
annotation: CredibilityAnnotation;
trustOutputId: string;
generatedAt: string;
}
export interface Citation {
itemId: string;
format: string;
text: string;
title: string;
authors: string[];
year: string;
url: string;
source: string;
}
/* ---------- 协同训练:评估结果 ---------- */
export interface CollaborationDimensionScore {
dimension: string;
dimensionName: string;
score: number | string;
competencyTagId?: string;
comment: string;
}
export interface CollaborationAssessment {
id: string;
studentId: string;
taskId: string;
dimensions: CollaborationDimensionScore[];
overallScore: number | string;
requiresSourceVerification: boolean;
verificationPrompt?: string;
annotation: CredibilityAnnotation;
trustOutputId: string;
competencyMappingIds: string[];
generatedAt: string;
}
/* ---------- 导师:学生画像视图(脱敏) ---------- */
export interface ProfileFieldView {
key: string;
value: unknown;
sensitive: boolean;
category?: string;
redacted: boolean;
}
export interface StudentProfileView {
studentId: string;
viewerId: string;
purpose: string;
fullAccess: boolean;
authorized: boolean;
authorizedScope: string[];
fields: ProfileFieldView[];
redactedFieldKeys: string[];
notice?: string;
resolvedAt: string;
}
/* ---------- 管理端:技能定义 ---------- */
export interface SkillDefinition {
id: string;
name: string;
inputSpec?: unknown;
processingLogic?: unknown;
knowledgeSources?: unknown;
outputFormat?: unknown;
credibilityRule?: unknown;
enabled: boolean;
}
export interface SkillAuditLogEntry {
id?: string;
skillId?: string;
action?: string;
actorId?: string;
actorRole?: string;
timestamp?: string;
[k: string]: unknown;
}
+23
View File
@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** 合并 className,配合 Tailwind 去重。 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** 友好地格式化日期(zh-CN)。 */
export function formatDate(value?: string | number | Date | null): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleDateString("zh-CN");
}
/** 友好地格式化日期时间(zh-CN)。 */
export function formatDateTime(value?: string | number | Date | null): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleString("zh-CN");
}
+92
View File
@@ -0,0 +1,92 @@
import { create } from "zustand";
import { apiRequest, clearToken, getToken, setToken } from "@/lib/api";
import { Role } from "@/lib/types";
import type { AuthResult, AuthenticatedUser } from "@/lib/types";
interface RegisterInput {
username: string;
password: string;
role: Role;
displayName: string;
}
interface AuthState {
user: AuthenticatedUser | null;
/** 初始会话还原是否进行中。 */
isLoading: boolean;
isAuthenticated: boolean;
_hasFetched: boolean;
/** 初次挂载:若本地有令牌则还原会话。 */
fetchUser: () => Promise<void>;
login: (username: string, password: string) => Promise<AuthenticatedUser>;
register: (input: RegisterInput) => Promise<AuthenticatedUser>;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isLoading: true,
isAuthenticated: false,
_hasFetched: false,
fetchUser: async () => {
if (get()._hasFetched) return;
set({ _hasFetched: true });
const token = getToken();
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
try {
const user = await apiRequest<AuthenticatedUser>("/api/auth/me");
set({ user, isAuthenticated: true, isLoading: false });
} catch {
clearToken();
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
login: async (username, password) => {
const result = await apiRequest<AuthResult>("/api/auth/login", {
method: "POST",
body: { username, password },
auth: false,
});
setToken(result.accessToken);
set({ user: result.user, isAuthenticated: true, isLoading: false });
return result.user;
},
register: async (input) => {
const result = await apiRequest<AuthResult>("/api/auth/register", {
method: "POST",
body: input,
auth: false,
});
setToken(result.accessToken);
set({ user: result.user, isAuthenticated: true, isLoading: false });
return result.user;
},
logout: () => {
clearToken();
set({ user: null, isAuthenticated: false });
},
}));
/** 按角色返回默认首页。 */
export function homeForRole(role: Role): string {
switch (role) {
case Role.Student:
return "/student";
case Role.Mentor:
return "/mentor";
case Role.Administrator:
return "/admin";
default:
return "/login";
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}