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:
@@ -0,0 +1,2 @@
|
||||
# NestJS 后端地址(开发环境)。/api/* 会被代理到这里,避免浏览器跨域。
|
||||
BACKEND_URL=http://localhost:3000
|
||||
@@ -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
|
||||
@@ -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`,可同时运行对比。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+6379
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="正在进入…" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
薄弱环节(正确率 < 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-100,null 表示数据不足 */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]: "教务管理平台",
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user