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

This commit is contained in:
freedakgmail
2026-06-04 11:50:25 +08:00
commit 995f4ecfbd
399 changed files with 73529 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
out
.env.local
*.tsbuildinfo
next-env.d.ts
+52
View File
@@ -0,0 +1,52 @@
# 医科高校 AI 学习中心 · 前端(三端)
基于 Next.js 14App Router+ TypeScript + Tailwind CSS 的三端前端,与 NestJS 后端真实联调。
## 三端
- 学生端 `/student`:学习空间、能力画像、职业规划、课程对练、临床对话对练、研究资料查询、AI 协同训练
- 导师端 `/mentor`:题目审核、成果点评、带教学生画像
- 管理端 `/admin`:技能治理、权限与合规
登录后按账号角色自动进入对应工作台;`AppShell` 做角色访问守卫,跨角色访问会被拦截。
## 与后端联调
- 所有请求走 `/api/*`,由 `next.config.mjs` 的 rewrites 代理到后端(默认 `http://localhost:3000`),避免浏览器跨域。
- 鉴权:登录/注册成功后将 JWT 存入 `localStorage`,后续请求自动附加 `Authorization: Bearer <token>`
- 错误:后端统一错误体(`AppError.toJSON()`)由 `ApiError` 承载,UI 展示 `message`
可用环境变量 `BACKEND_URL` 覆盖后端地址(如部署时)。
## 本地运行
```bash
# 1) 先启动后端(仓库根目录)
npm run start:dev # 监听 3000,需 .env 中 DB_ENABLED 与 PostgreSQL(可选)
# 2) 启动前端(frontend 目录)
cd frontend
npm install
npm run dev # 监听 4000
```
打开 http://localhost:4000 ,注册一个账号(可选角色:学生 / 导师 / 管理员)后即可进入对应工作台。
## 构建
```bash
npm run build && npm run start
```
## 目录
```
src/
app/ # App Router 页面(login / student / mentor / admin
components/ # AppShell(外壳+守卫)与基础 UI 组件
lib/
api.ts # fetch 封装:鉴权头、错误解析(ApiError)
auth-context.tsx # 登录态上下文(login/register/logout/me
services.ts # 按后端模块组织的 API 服务函数
types.ts # 与后端对齐的类型与枚举
```
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// 将 /api/* 代理到 NestJS 后端,避免浏览器跨域并统一前端调用路径。
async rewrites() {
const backend = process.env.BACKEND_URL ?? 'http://localhost:3000';
return [
{
source: '/api/:path*',
destination: `${backend}/api/:path*`,
},
];
},
};
export default nextConfig;
+1664
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "college-ai-center-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.14.10",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.19",
"postcss": "8.4.39",
"tailwindcss": "3.4.6",
"typescript": "5.5.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+145
View File
@@ -0,0 +1,145 @@
'use client';
/** 权限与合规:查看三类角色权限范围、审计日志与越权拒绝事件(可按操作者过滤)。 */
import { useCallback, useEffect, useState } from 'react';
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { adminComplianceApi } from '@/lib/services';
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">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="角色权限范围">
{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-slate-100 p-3"
>
<p className="mb-2 text-sm font-semibold text-brand-700">
{sc.role}
</p>
<ul className="space-y-1 text-xs text-slate-500">
{(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>
)}
</Card>
<Card
title="审计与越权事件"
actions={
<div className="flex items-center gap-2">
<input
className="input max-w-[200px]"
value={actorId}
onChange={(e) => setActorId(e.target.value)}
placeholder="按操作者 ID 过滤"
/>
<button className="btn-ghost" onClick={load} disabled={loading}>
</button>
</div>
}
>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
{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-slate-100 p-3 text-xs text-slate-600"
>
<pre className="overflow-auto">
{JSON.stringify(log, null, 2)}
</pre>
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
{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-red-100 bg-red-50 p-3 text-xs text-red-700"
>
<pre className="overflow-auto">
{JSON.stringify(d, null, 2)}
</pre>
</li>
))}
</ul>
)}
</div>
</div>
</Card>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
'use client';
import { AppShell, type NavGroup } from '@/components/app-shell';
import { Role } from '@/lib/types';
const NAV: NavGroup[] = [
{
items: [
{ href: '/admin', label: '工作台', icon: 'home' },
{ href: '/admin/skills', label: '技能治理', icon: 'sliders' },
{ href: '/admin/compliance', label: '权限与合规', icon: 'shield' },
],
},
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AppShell requiredRole={Role.Administrator} nav={NAV} title="管理端">
{children}
</AppShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import Link from 'next/link';
import { Card } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
const FEATURES = [
{
href: '/admin/skills',
title: '技能治理',
desc: '查看、新增/修改、启用技能定义,查看治理审计日志。',
},
{
href: '/admin/compliance',
title: '权限与合规',
desc: '查看角色权限范围、审计日志与越权拒绝事件。',
},
];
export default function AdminHome() {
const { user } = useAuth();
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-slate-800">
{user?.username}
</h1>
<p className="mt-1 text-sm text-slate-500"></p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FEATURES.map((f) => (
<Link key={f.href} href={f.href}>
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
<h2 className="text-base font-semibold text-brand-700">
{f.title}
</h2>
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
</Card>
</Link>
))}
</div>
</div>
);
}
+322
View File
@@ -0,0 +1,322 @@
'use client';
/**
* 技能治理:列出技能定义、查看详情、启用,并以「引导表单 + JSON 编辑器」新增/修改技能。
*
* 技能定义为五要素深层嵌套结构:id/name/enabled 用表单字段,五要素用 JSON 编辑(与后端
* 「最小校验 + 透传服务层规范化」约定一致),并提供模板降低输入成本。审计日志结构化展示。
*/
import { useCallback, useEffect, useState } from 'react';
import { InfoRow, RawDetails } from '@/components/display';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { adminSkillsApi } from '@/lib/services';
import type { SkillAuditLogEntry, SkillDefinition } from '@/lib/types';
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,
});
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);
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : '启用失败');
}
}
async 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">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card
title="技能定义"
actions={
<div className="flex gap-2">
<Badge tone="green">{enabledCount} </Badge>
<Badge tone="slate">{skills.length} </Badge>
</div>
}
>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : skills.length === 0 ? (
<EmptyState message="技能库为空,可在下方新增技能定义。" />
) : (
<ul className="divide-y divide-slate-100">
{skills.map((s) => (
<li key={s.id} className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-800">{s.name}</p>
<p className="mt-0.5 text-xs text-slate-400">{s.id}</p>
</div>
<div className="flex items-center gap-3">
{s.enabled ? (
<Badge tone="green"></Badge>
) : (
<Badge tone="slate"></Badge>
)}
<button
className="btn-ghost"
onClick={() => setDetail(s)}
>
</button>
<button className="btn-ghost" onClick={() => handleEdit(s)}>
</button>
{!s.enabled && (
<button
className="btn-ghost"
onClick={() => handleEnable(s.id)}
>
</button>
)}
</div>
</li>
))}
</ul>
)}
</Card>
{detail && (
<Card
title={`技能详情 · ${detail.name}`}
actions={
<button className="btn-ghost" onClick={() => setDetail(null)}>
</button>
}
>
<InfoRow label="标识">{detail.id}</InfoRow>
<InfoRow label="名称">{detail.name}</InfoRow>
<InfoRow label="状态">
{detail.enabled ? (
<Badge tone="green"></Badge>
) : (
<Badge tone="slate"></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>
</Card>
)}
<Card title="新增 / 修改技能定义">
<form onSubmit={handleUpsert} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="label"></label>
<input
className="input"
value={skillId}
onChange={(e) => setSkillId(e.target.value)}
required
/>
</div>
<div>
<label className="label"></label>
<input
className="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-slate-600">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</label>
</div>
</div>
<div>
<label className="label">
inputSpec / processingLogic / knowledgeSources /
outputFormat / credibilityRule
</label>
<textarea
className="input 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 className="btn-primary" disabled={submitting}>
{submitting ? '提交中…' : '提交技能定义'}
</button>
<button
type="button"
className="btn-ghost"
onClick={() => setElementsJson(FIVE_ELEMENTS_TEMPLATE)}
>
</button>
</div>
</form>
</Card>
<Card title="治理审计日志">
{auditLogs.length === 0 ? (
<EmptyState message="暂无审计日志。" />
) : (
<ul className="divide-y divide-slate-100">
{auditLogs.map((log, i) => (
<li key={(log.id as string) ?? i} className="py-3">
<div className="flex items-center gap-2">
{log.action && <Badge tone="blue">{String(log.action)}</Badge>}
{log.skillId && (
<span className="text-sm text-slate-700">{String(log.skillId)}</span>
)}
{log.timestamp && (
<span className="ml-auto text-xs text-slate-400">
{new Date(String(log.timestamp)).toLocaleString('zh-CN')}
</span>
)}
</div>
{(log.actorId || log.actorRole) && (
<p className="mt-1 text-xs text-slate-400">
{String(log.actorId ?? '')}
{log.actorRole ? `${String(log.actorRole)}` : ''}
</p>
)}
<RawDetails data={log} />
</li>
))}
</ul>
)}
</Card>
</div>
);
}
/** 技能五要素之一的小卡片。 */
function ElementCard({ title, data }: { title: string; data: unknown }) {
return (
<div className="rounded-lg border border-slate-100 p-3">
<p className="mb-1.5 text-xs font-semibold text-slate-500">{title}</p>
<pre className="max-h-40 overflow-auto rounded bg-slate-50 p-2 text-xs text-slate-600">
{JSON.stringify(data ?? {}, null, 2)}
</pre>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
@apply bg-slate-50 text-slate-800 antialiased;
}
@layer components {
.card {
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60;
}
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700;
}
.btn-ghost {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-100;
}
.input {
@apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-600;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import './globals.css';
import { AuthProvider } from '@/lib/auth-context';
export const metadata: Metadata = {
title: '医科高校 AI 学习中心',
description: '面向医科类高校的 AI 学习、对练、画像与科研支持平台',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
+233
View File
@@ -0,0 +1,233 @@
'use client';
/**
* 登录 / 注册页。
*
* 支持两种模式切换:登录与注册。注册时可选择角色(学生/导师/管理员),便于演示三端。
* 成功后依据角色跳转到对应工作台。错误信息取自后端统一错误体。
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { homeForRole } from '@/components/app-shell';
import { ErrorBanner } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { useAuth } from '@/lib/auth-context';
import { ROLE_LABELS, Role } from '@/lib/types';
/** 内置测试账户(与后端 SEED_TEST_ACCOUNTS 一致),用于一键登录。 */
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 },
];
export default function LoginPage() {
const { user, loading, login, register } = useAuth();
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 (!loading && user) {
router.replace(homeForRole(user.role));
}
}, [loading, 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 });
router.replace(homeForRole(u.role));
} catch (err) {
if (err instanceof ApiError) {
setError(err.body.message ?? '操作失败');
} else {
setError('网络错误,请确认后端服务已启动');
}
} 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);
router.replace(homeForRole(u.role));
} catch (err) {
if (err instanceof ApiError) {
setError(
`${ROLE_LABELS[acct.role]}测试账户登录失败:${err.body.message ?? ''}`,
);
} else {
setError('网络错误,请确认后端服务已启动');
}
setQuickRole(null);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4">
<div className="w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold text-brand-700">
AI
</h1>
<p className="mt-1 text-sm text-slate-500">
· · · ·
</p>
</div>
<div className="card">
<div className="mb-5 grid grid-cols-2 gap-2 rounded-lg bg-slate-100 p-1">
{(['login', 'register'] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setError(null);
}}
className={`rounded-md py-2 text-sm font-medium transition ${
mode === m
? 'bg-white text-brand-700 shadow-sm'
: 'text-slate-500'
}`}
>
{m === 'login' ? '登录' : '注册'}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label"></label>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="至少 3 个字符"
autoComplete="username"
required
/>
</div>
<div>
<label className="label"></label>
<input
className="input"
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 className="label"></label>
<input
className="input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="如:张三"
required
/>
</div>
<div>
<label className="label"></label>
<div className="grid grid-cols-3 gap-2">
{Object.values(Role).map((r) => (
<button
type="button"
key={r}
onClick={() => setRole(r)}
className={`rounded-lg border px-3 py-2 text-sm transition ${
role === r
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
</div>
</>
)}
{error && <ErrorBanner message={error} />}
<button
type="submit"
className="btn-primary w-full"
disabled={submitting}
>
{submitting
? '处理中…'
: mode === 'login'
? '登录'
: '注册并登录'}
</button>
</form>
<div className="mt-5 border-t border-slate-100 pt-4">
<p className="mb-2 text-center text-xs text-slate-400">
·
</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-slate-200 px-2 py-2 text-center transition hover:border-brand-400 hover:bg-brand-50 disabled:opacity-60"
>
<span className="text-sm font-medium text-slate-700">
{ROLE_LABELS[acct.role]}
</span>
<span className="mt-0.5 text-[11px] text-slate-400">
{quickRole === acct.role ? '登录中…' : acct.username}
</span>
</button>
))}
</div>
<p className="mt-2 text-center text-[11px] text-slate-300">
student123 / mentor123 / admin123
</p>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400">
http://localhost:3000
</p>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
'use client';
/** 成果点评:对指定学习成果添加点评,并查看该成果的全部点评。 */
import { useState } from 'react';
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { mentorApi } from '@/lib/services';
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('');
await loadComments();
} catch (err) {
setError(err instanceof ApiError ? err.message : '点评失败');
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="点评学习成果">
<form onSubmit={addComment} className="space-y-4">
<div>
<label className="label"></label>
<div className="flex gap-2">
<input
className="input"
value={achievementId}
onChange={(e) => setAchievementId(e.target.value)}
placeholder="输入学习成果的 ID"
required
/>
<button
type="button"
className="btn-ghost shrink-0"
onClick={loadComments}
disabled={!achievementId || busy}
>
</button>
</div>
</div>
<div>
<label className="label"></label>
<textarea
className="input"
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="填写对该成果的点评(对学生可见)"
required
/>
</div>
{error && <ErrorBanner message={error} />}
<button className="btn-primary" disabled={busy}>
{busy ? '提交中…' : '提交点评'}
</button>
</form>
</Card>
<Card title="该成果的点评">
{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-slate-100 p-3 text-sm text-slate-600"
>
{c.comment ?? c.content ?? JSON.stringify(c)}
</li>
))}
</ul>
)}
</Card>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { AppShell, type NavGroup } from '@/components/app-shell';
import { Role } from '@/lib/types';
const NAV: NavGroup[] = [
{
items: [
{ href: '/mentor', label: '工作台', icon: 'home' },
{ href: '/mentor/review', label: '题目审核', icon: 'clipboard-check' },
{ href: '/mentor/comments', label: '成果点评', icon: 'message-square' },
{ href: '/mentor/students', label: '带教学生画像', icon: 'user-check' },
],
},
];
export default function MentorLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AppShell requiredRole={Role.Mentor} nav={NAV} title="导师端">
{children}
</AppShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import Link from 'next/link';
import { Card } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
const FEATURES = [
{
href: '/mentor/review',
title: '题目审核',
desc: '审核学生练习题,通过或退回并填写原因。',
},
{
href: '/mentor/comments',
title: '成果点评',
desc: '对学生学习成果进行点评,点评对学生可见。',
},
{
href: '/mentor/students',
title: '带教学生画像',
desc: '查看所带学生的能力画像(脱敏授权)。',
},
];
export default function MentorHome() {
const { user } = useAuth();
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-slate-800">
{user?.username}
</h1>
<p className="mt-1 text-sm text-slate-500"></p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{FEATURES.map((f) => (
<Link key={f.href} href={f.href}>
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
<h2 className="text-base font-semibold text-brand-700">
{f.title}
</h2>
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
</Card>
</Link>
))}
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
/** 题目审核:输入题目标识,通过或退回(退回须填原因)。 */
import { useState } from 'react';
import { Card, ErrorBanner } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { mentorApi } from '@/lib/services';
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);
} catch (err) {
setError(err instanceof ApiError ? err.message : '审核失败');
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="审核题目">
<form onSubmit={submit} className="space-y-4">
<div>
<label className="label"></label>
<input
className="input"
value={questionId}
onChange={(e) => setQuestionId(e.target.value)}
placeholder="输入待审核题目的 ID"
required
/>
</div>
<div>
<label className="label"></label>
<div className="flex gap-2">
{(['approve', 'return'] as const).map((d) => (
<button
type="button"
key={d}
onClick={() => setDecision(d)}
className={`rounded-lg border px-4 py-2 text-sm transition ${
decision === d
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-300 text-slate-600'
}`}
>
{d === 'approve' ? '通过' : '退回'}
</button>
))}
</div>
</div>
{decision === 'return' && (
<div>
<label className="label">退</label>
<textarea
className="input"
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="说明退回原因"
required
/>
</div>
)}
{error && <ErrorBanner message={error} />}
<button className="btn-primary" disabled={busy}>
{busy ? '提交中…' : '提交审核'}
</button>
</form>
{result && (
<div className="mt-4">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
</h3>
<pre className="overflow-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</Card>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
/** 带教学生画像:列出所带学生 → 查看某学生的画像(合规授权与脱敏由后端负责)。 */
import { useCallback, useEffect, useState } from 'react';
import { InfoRow, RawDetails } from '@/components/display';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { mentorApi } from '@/lib/services';
import type { StudentProfileView } from '@/lib/types';
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">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card title="所带学生" className="lg:col-span-1">
{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={`w-full rounded-lg px-3 py-2 text-left text-sm transition ${
activeStudent === s
? 'bg-brand-50 font-medium text-brand-700'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{s}
</button>
</li>
))}
</ul>
)}
</Card>
<Card title="学生画像" className="lg:col-span-2">
{!activeStudent ? (
<EmptyState message="从左侧选择一名学生查看画像。" />
) : profileLoading ? (
<Loading />
) : profileError ? (
<ErrorBanner message={profileError} />
) : profile ? (
<ProfileViewCard profile={profile} />
) : null}
</Card>
</div>
</div>
);
}
/** 学生画像视图:授权状态 + 字段(敏感字段脱敏标记)。 */
function ProfileViewCard({ profile }: { profile: StudentProfileView }) {
return (
<div className="space-y-4">
{profile.notice && (
<p className="rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
{profile.notice}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
{profile.fullAccess ? (
<Badge tone="green">访</Badge>
) : profile.authorized ? (
<Badge tone="blue"></Badge>
) : (
<Badge tone="red"></Badge>
)}
{profile.redactedFieldKeys?.length > 0 && (
<Badge tone="amber">
{profile.redactedFieldKeys.length}
</Badge>
)}
<span className="text-xs text-slate-400">{profile.purpose}</span>
</div>
<div className="divide-y divide-slate-100">
{profile.fields?.map((f) => (
<div
key={f.key}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="text-slate-500">
{f.key}
{f.sensitive && (
<span className="ml-1.5 align-middle">
<Badge tone="amber"></Badge>
</span>
)}
</span>
<span
className={
f.redacted ? 'text-slate-300' : 'font-medium text-slate-700'
}
>
{f.redacted
? '已脱敏'
: typeof f.value === 'object'
? JSON.stringify(f.value)
: String(f.value)}
</span>
</div>
))}
</div>
<RawDetails data={profile} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { homeForRole } from '@/components/app-shell';
import { Loading } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
/** 入口页:依据登录态与角色重定向到对应工作台或登录页。 */
export default function HomePage() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
router.replace(user ? homeForRole(user.role) : '/login');
}, [user, loading, router]);
return (
<div className="flex min-h-screen items-center justify-center">
<Loading label="正在进入…" />
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
'use client';
/** 职业规划:设定职业目标 → 关联岗位胜任力模型 → 生成动态闭环发展规划。 */
import { useCallback, useEffect, useState } from 'react';
import {
InfoRow,
RawDetails,
ScoreBar,
} from '@/components/display';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
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,
});
await load();
} catch (err) {
if (err instanceof ApiError) {
const recs = (err.body.details as any)?.recommendedGoals;
setFormError(
recs?.length
? `${err.message}(建议目标:${recs
.map((r: any) => r.title ?? r.id ?? 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">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="设定职业目标">
<form
onSubmit={handleSetGoal}
className="grid grid-cols-1 gap-4 sm:grid-cols-3"
>
<div>
<label className="label"></label>
<input
className="input"
value={goalId}
onChange={(e) => setGoalId(e.target.value)}
placeholder="如:clinical-physician"
required
/>
</div>
<div>
<label className="label"></label>
<input
className="input"
value={goalTitle}
onChange={(e) => setGoalTitle(e.target.value)}
placeholder="如:临床医师"
required
/>
</div>
<div>
<label className="label"></label>
<input
className="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 className="btn-primary" disabled={submitting}>
{submitting ? '提交中…' : '设定目标'}
</button>
</div>
</form>
</Card>
<Card
title="当前目标与岗位胜任力模型"
actions={
<button
className="btn-ghost"
onClick={handleGeneratePlan}
disabled={planning}
>
{planning ? '生成中…' : '生成发展规划'}
</button>
}
>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : !goal ? (
<EmptyState message="尚未设定职业目标。" />
) : (
<div className="space-y-4">
<div className="rounded-lg bg-slate-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-slate-100 p-3"
>
<p className="mb-2 text-sm font-semibold text-brand-700">
{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="brand"
/>
))}
</div>
</div>
))}
</div>
</div>
)}
{planError && (
<div className="mt-4">
<ErrorBanner message={planError} />
</div>
)}
</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
title={`发展规划 · ${plan.goalTitle}`}
actions={
<div className="flex gap-2">
<Badge tone="amber">{gapCount} </Badge>
{missingCount > 0 && <Badge tone="slate">{missingCount} </Badge>}
</div>
}
>
{gapCount === 0 ? (
<EmptyState message="未发现能力差距,已达到目标岗位要求。" />
) : (
<ul className="space-y-4">
{plan.gaps.map((gap) => (
<GapItem key={gap.tagId} gap={gap} />
))}
</ul>
)}
<RawDetails data={plan} />
</Card>
);
}
function GapItem({ gap }: { gap: CompetencyGap }) {
return (
<li className="rounded-lg border border-slate-200 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-800">{gap.tagName}</p>
<p className="mt-0.5 text-xs text-slate-400">{gap.dimensionName}</p>
</div>
{gap.missingData ? (
<Badge tone="slate"></Badge>
) : (
<Badge tone="amber"></Badge>
)}
</div>
<ScoreBar
label="当前水平"
score={gap.missingData ? 'insufficient_data' : gap.currentLevel}
required={gap.requiredLevel}
tone="red"
/>
{gap.suggestedActions?.length > 0 && (
<div className="mt-3">
<p className="mb-1 text-xs font-semibold text-slate-500"></p>
<ul className="space-y-1">
{gap.suggestedActions.map((a, i) => (
<li key={i} className="text-sm text-slate-600">
· {a.description}
<span className="ml-1 text-xs text-slate-400">
{a.targetLevel}
</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-3">
<p className="mb-1 text-xs font-semibold text-slate-500">
/
</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-brand-200 bg-brand-50 px-2.5 py-1 text-xs text-brand-700"
>
{r.type === 'practice_task' ? '🎯 ' : '📘 '}
{r.title}
</span>
))}
</div>
) : (
<p className="text-xs text-slate-400">
{gap.resourceNote ?? '暂无可推荐资源'}
</p>
)}
</div>
</li>
);
}
+220
View File
@@ -0,0 +1,220 @@
'use client';
/**
* 临床情景对话对练:输入情景标识发起会话 → 逐轮对话 → 结束生成三维评估报告。
*
* 对话历史在本地累积展示;系统应答取自每轮返回。
*/
import { useState } from 'react';
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from '@/components/display';
import { Card, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { clinicalApi } from '@/lib/services';
import type { DialogueReport } from '@/lib/types';
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)
);
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="发起对练">
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[220px]">
<label className="label"></label>
<input
className="input"
value={scenarioId}
onChange={(e) => setScenarioId(e.target.value)}
placeholder="如:chest-pain-triage"
/>
</div>
<button
className="btn-primary"
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>
)}
</Card>
{session && !report && (
<Card title="对话">
<div className="mb-4 max-h-96 space-y-3 overflow-auto">
{turns.length === 0 && (
<p className="text-sm text-slate-400">
</p>
)}
{turns.map((t, i) => (
<div
key={i}
className={`flex ${t.role === 'student' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
t.role === 'student'
? 'bg-brand-600 text-white'
: 'bg-slate-100 text-slate-700'
}`}
>
{t.text}
</div>
</div>
))}
</div>
{busy && <Loading />}
<div className="flex gap-3">
<input
className="input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入对话内容…"
onKeyDown={(e) => {
if (e.key === 'Enter' && input && !busy) {
e.preventDefault();
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) },
]),
);
}
}}
/>
<button
className="btn-primary"
disabled={!input || busy}
onClick={() => {
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) },
]),
);
}}
>
</button>
<button
className="btn-ghost"
disabled={busy}
onClick={() =>
run(
() => clinicalApi.finish(session.id),
(v: any) => setReport(v),
)
}
>
</button>
</div>
</Card>
)}
{report && (
<Card title="三维评估报告">
<div className="mb-4 flex items-center gap-4">
<StatTile
label="综合评分"
value={report.overallScore?.toFixed?.(0) ?? report.overallScore}
tone="blue"
/>
<div className="text-sm text-slate-500">
{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 ? 'green' : 'amber'}
/>
{d.comment && (
<p className="mt-1 text-xs text-slate-500">{d.comment}</p>
)}
</div>
))}
</div>
<div className="mt-4">
<CredibilityBadge annotation={report.annotation} />
</div>
<RawDetails data={report} />
</Card>
)}
</div>
);
}
@@ -0,0 +1,219 @@
'use client';
/**
* AI 协同训练:列出协同训练任务 → 完成任务并评估四个协同能力维度 → 查看导师点评。
*/
import { useCallback, useEffect, useState } from 'react';
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from '@/components/display';
import { Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { collaborationApi } from '@/lib/services';
import type { CollaborationAssessment } from '@/lib/types';
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);
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : '评估失败');
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800">AI </h1>
<Card title="协同训练任务">
{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={`cursor-pointer rounded-lg border p-3 transition ${
activeTask?.id === t.id
? 'border-brand-500 bg-brand-50'
: 'border-slate-200 hover:border-brand-300'
}`}
onClick={() => {
setActiveTask(t);
setAssessment(null);
setContent('');
setAdopted(false);
}}
>
<p className="text-sm font-medium text-slate-700">
{t.title ?? t.name ?? t.id}
</p>
{t.dimension && (
<p className="mt-1 text-xs text-slate-400">
{t.dimension}
</p>
)}
{t.description && (
<p className="mt-1 text-xs text-slate-500">{t.description}</p>
)}
</li>
))}
</ul>
)}
</Card>
{activeTask && (
<Card title={`完成任务:${activeTask.title ?? activeTask.id}`}>
<div className="space-y-3">
<div>
<label className="label"> / </label>
<textarea
className="input"
rows={3}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="描述你的完成过程与产出"
/>
</div>
<label className="flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={adopted}
onChange={(e) => setAdopted(e.target.checked)}
/>
AI
</label>
{submitError && <ErrorBanner message={submitError} />}
<button
className="btn-primary"
disabled={submitting}
onClick={handleEvaluate}
>
{submitting ? '评估中…' : '提交并评估'}
</button>
</div>
{assessment && (
<div className="mt-4">
{assessment.requiresSourceVerification && (
<p className="mb-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-700">
{assessment.verificationPrompt ?? '请对 AI 输出进行来源核验'}
</p>
)}
<div className="mb-4">
<StatTile
label="综合评分"
value={
typeof assessment.overallScore === 'number'
? assessment.overallScore.toFixed(0)
: '数据不足'
}
tone="blue"
/>
</div>
<h3 className="mb-2 text-sm font-semibold text-slate-700">
</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
? 'green'
: 'amber'
}
/>
{d.comment && (
<p className="mt-1 text-xs text-slate-500">{d.comment}</p>
)}
</div>
))}
</div>
<div className="mt-4">
<CredibilityBadge annotation={assessment.annotation} />
</div>
<RawDetails data={assessment} />
</div>
)}
</Card>
)}
<Card title="导师点评">
{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-slate-100 p-3 text-sm text-slate-600"
>
{c.comment ?? c.content ?? JSON.stringify(c)}
</li>
))}
</ul>
)}
</Card>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { AppShell, type NavGroup } from '@/components/app-shell';
import { Role } from '@/lib/types';
const NAV: NavGroup[] = [
{ items: [{ href: '/student', label: '工作台', icon: 'home' }] },
{
label: '学习训练',
items: [
{ href: '/student/learning-space', label: '学习空间', icon: 'book-open' },
{ href: '/student/practice', label: '课程对练', icon: 'target' },
{ href: '/student/clinical', label: '临床对话对练', icon: 'stethoscope' },
{ href: '/student/collaboration', label: 'AI 协同训练', icon: 'users' },
],
},
{
label: '成长发展',
items: [
{ href: '/student/profile', label: '能力画像', icon: 'chart' },
{ href: '/student/career', label: '职业规划', icon: 'compass' },
],
},
{
label: '研究',
items: [
{ href: '/student/research', label: '研究资料查询', icon: 'search' },
],
},
];
export default function StudentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AppShell requiredRole={Role.Student} nav={NAV} title="学生端">
{children}
</AppShell>
);
}
@@ -0,0 +1,209 @@
'use client';
/** 学习空间:新增学习成果 + 按筛选条件分页检索。 */
import { useCallback, useEffect, useState } from 'react';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { learningSpaceApi } from '@/lib/services';
import {
ACHIEVEMENT_TYPE_LABELS,
AchievementType,
type Achievement,
type Paginated,
} from '@/lib/types';
export default function LearningSpacePage() {
const [page, setPage] = useState<Paginated<Achievement> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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 [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await learningSpaceApi.listAchievements({
type: filterType ? (filterType as AchievementType) : undefined,
page: pageNum,
pageSize: 10,
});
setPage(result);
} catch (err) {
setError(err instanceof ApiError ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, [filterType, pageNum]);
useEffect(() => {
load();
}, [load]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
setSubmitting(true);
try {
await learningSpaceApi.addAchievement({
type,
title,
occurredAt: new Date(occurredAt).toISOString(),
});
setTitle('');
setPageNum(1);
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : '新增失败');
} finally {
setSubmitting(false);
}
}
const totalPages = page ? Math.max(1, Math.ceil(page.total / page.pageSize)) : 1;
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="新增学习成果">
<form
onSubmit={handleAdd}
className="grid grid-cols-1 gap-4 sm:grid-cols-4"
>
<div>
<label className="label"></label>
<select
className="input"
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 className="label"></label>
<input
className="input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:完成内科学期末考试"
required
/>
</div>
<div>
<label className="label"></label>
<input
className="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 className="btn-primary" disabled={submitting}>
{submitting ? '提交中…' : '新增成果'}
</button>
</div>
</form>
</Card>
<Card
title="我的学习成果"
actions={
<select
className="input max-w-[140px]"
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>
}
>
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : !page || page.items.length === 0 ? (
<EmptyState message="暂无学习成果,先在上方新增一条吧。" />
) : (
<>
<ul className="divide-y divide-slate-100">
{page.items.map((a) => (
<li
key={a.id}
className="flex items-center justify-between py-3"
>
<div>
<p className="font-medium text-slate-800">{a.title}</p>
<p className="mt-0.5 text-xs text-slate-400">
{new Date(a.occurredAt).toLocaleDateString('zh-CN')}
</p>
</div>
<Badge tone="blue">
{ACHIEVEMENT_TYPE_LABELS[a.type] ?? a.type}
</Badge>
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
<span> {page.total} </span>
<div className="flex items-center gap-2">
<button
className="btn-ghost"
disabled={pageNum <= 1}
onClick={() => setPageNum((p) => p - 1)}
>
</button>
<span>
{page.page} / {totalPages}
</span>
<button
className="btn-ghost"
disabled={pageNum >= totalPages}
onClick={() => setPageNum((p) => p + 1)}
>
</button>
</div>
</div>
</>
)}
</Card>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import Link from 'next/link';
import { Card } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
const FEATURES = [
{
href: '/student/learning-space',
title: '学习空间',
desc: '记录课程、实践、科研等学习成果,标记成长里程碑。',
},
{
href: '/student/profile',
title: '能力画像',
desc: '查看六维度能力量化画像与数据来源追溯。',
},
{
href: '/student/career',
title: '职业规划',
desc: '设定职业目标,生成动态闭环发展规划。',
},
{
href: '/student/practice',
title: '课程对练',
desc: '基于课程内容生成练习题并进行对练。',
},
{
href: '/student/clinical',
title: '临床对话对练',
desc: '在临床情景中与 AI 角色对话,获得三维评估。',
},
{
href: '/student/research',
title: '研究资料查询',
desc: '自然语言转检索式,检索、总结并生成引用。',
},
{
href: '/student/collaboration',
title: 'AI 协同训练',
desc: '完成协同任务,评估四个协同能力维度。',
},
];
export default function StudentHome() {
const { user } = useAuth();
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-slate-800">
{user?.username}
</h1>
<p className="mt-1 text-sm text-slate-500">
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f) => (
<Link key={f.href} href={f.href}>
<Card className="h-full transition hover:border-brand-300 hover:shadow-md">
<h2 className="text-base font-semibold text-brand-700">
{f.title}
</h2>
<p className="mt-2 text-sm text-slate-500">{f.desc}</p>
</Card>
</Link>
))}
</div>
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
'use client';
/**
* 课程对练:输入课程标识 → 生成题目 → 查看可对练题目 → 发起对练逐题作答 → 结束生成报告。
*
* 题目结构由后端动态生成,前端以宽松对象消费并展示题干/选项;作答提交后端逐题判定。
*/
import { useState } from 'react';
import {
CredibilityBadge,
RawDetails,
ScoreBar,
StatTile,
} from '@/components/display';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { practiceApi } from '@/lib/services';
import type { AnswerResult, PracticeReport } from '@/lib/types';
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">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="选择课程">
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[220px]">
<label className="label"></label>
<input
className="input"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
placeholder="如:internal-medicine-101"
/>
</div>
<button
className="btn-primary"
disabled={!courseId || busy}
onClick={() =>
run(
() => practiceApi.generateQuestions(courseId, {}),
(v: any) => setQuestions(v?.questions ?? v?.items ?? []),
)
}
>
</button>
<button
className="btn-ghost"
disabled={!courseId || busy}
onClick={() =>
run(
() => practiceApi.listAvailableQuestions(courseId),
(v: any) => setQuestions(Array.isArray(v) ? v : []),
)
}
>
</button>
<button
className="btn-ghost"
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>
)}
</Card>
{busy && <Loading />}
{questions.length > 0 && (
<Card title={`题目(${questions.length}`}>
<ul className="space-y-3">
{questions.map((q: any, i: number) => (
<li key={q.id ?? i} className="rounded-lg border border-slate-100 p-3">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-slate-700">
{i + 1}. {q.stem ?? q.title ?? q.content ?? '(题干)'}
</p>
{q.reviewStatus && (
<Badge tone="amber">{q.reviewStatus}</Badge>
)}
</div>
{Array.isArray(q.options) && (
<ul className="mt-2 space-y-1 text-xs text-slate-500">
{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>
</Card>
)}
{session && !report && (
<Card title="进行对练">
{currentQuestion ? (
<div className="space-y-3">
<p className="text-sm font-medium text-slate-700">
{currentQuestion.stem ??
currentQuestion.title ??
currentQuestion.content ??
'当前题目'}
</p>
{Array.isArray(currentQuestion.options) && (
<ul className="space-y-1 text-xs text-slate-500">
{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
className="input"
rows={3}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="输入你的作答(选择题填选项 key,分析题填要点)"
/>
<div className="flex gap-3">
<button
className="btn-primary"
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
className="btn-ghost"
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-green-50 text-green-700'
: 'bg-red-50 text-red-700'
}`}
>
<span className="font-medium">
{lastResult.correct ? '✓ 回答正确' : '✗ 回答错误'}
</span>
{lastResult.timedOut && (
<Badge tone="amber"></Badge>
)}
<span className="ml-auto text-xs text-slate-400">
{(lastResult.elapsedMs / 1000).toFixed(1)}
</span>
</div>
{lastResult.next && (
<p className="mt-2 text-xs text-slate-400">
{lastResult.next.position} / {lastResult.next.total}
</p>
)}
</div>
)}
</Card>
)}
{report && <PracticeReportView report={report} />}
</div>
);
}
/** 对练报告:正确率、用时、能力标签统计、薄弱环节与改进建议。 */
function PracticeReportView({ report }: { report: PracticeReport }) {
return (
<Card title="对练报告">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
label="正确率"
value={`${report.accuracy.toFixed(0)}%`}
tone={report.accuracy >= 60 ? 'green' : 'red'}
/>
<StatTile label="题目总数" value={report.totalQuestions} />
<StatTile label="正确 / 错误" value={`${report.correctCount}/${report.incorrectCount}`} />
<StatTile
label="总用时"
value={`${report.totalTimeSeconds}s`}
tone="blue"
/>
</div>
{report.tagBreakdown?.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
</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 ? 'green' : 'red'}
/>
))}
</div>
</div>
)}
{report.weakAreas?.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
&lt; 60%
</h3>
<div className="flex flex-wrap gap-2">
{report.weakAreas.map((w) => (
<Badge key={w.tagId} tone="red">
{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-slate-700">
</h3>
<ul className="space-y-3">
{report.suggestions.map((s, i) => (
<li key={i} className="rounded-lg border border-slate-100 p-3">
<p className="text-sm text-slate-700">{s.content}</p>
<div className="mt-2">
<CredibilityBadge annotation={s.annotation} />
</div>
</li>
))}
</ul>
</div>
)}
<RawDetails data={report} />
</Card>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
/** 能力画像:生成/刷新六维度画像并以条形可视化展示。 */
import { useCallback, useEffect, useState } from 'react';
import { Badge, Card, EmptyState, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { profileApi } from '@/lib/services';
import { DIMENSION_LABELS, type StudentProfile } from '@/lib/types';
export default function ProfilePage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setProfile(await profileApi.generate());
} catch (err) {
setError(err instanceof ApiError ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<button className="btn-ghost" onClick={load} disabled={loading}>
</button>
</div>
<Card title="六维度能力概览">
{loading ? (
<Loading />
) : error ? (
<ErrorBanner message={error} />
) : !profile || profile.dimensions.length === 0 ? (
<EmptyState message="暂无足够数据生成画像,先在学习空间录入成果。" />
) : (
<div className="space-y-4">
{profile.dimensions.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="font-medium text-slate-700">
{label}
{d.sensitive && (
<span className="ml-2 align-middle">
<Badge tone="red"></Badge>
</span>
)}
</span>
{insufficient ? (
<Badge tone="amber"></Badge>
) : (
<span className="text-slate-500">{score.toFixed(0)}</span>
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${
insufficient ? 'bg-amber-300' : 'bg-brand-500'
}`}
style={{ width: `${insufficient ? 8 : score}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</Card>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
'use client';
/**
* 研究资料查询:自然语言问题 → 生成检索式 → 在选定来源检索 → 总结 / 生成引用。
*
* 检索式、检索结果等复杂对象由后端产出后在前端状态中原样回传给后续端点(与后端约定一致)。
*/
import { useState } from 'react';
import { CredibilityBadge, InfoRow, RawDetails } from '@/components/display';
import { Badge, Card, ErrorBanner, Loading } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { researchApi } from '@/lib/services';
import type { Citation, SearchQuery, Summary } from '@/lib/types';
const SOURCES = ['PUBMED', 'CNKI', 'WANFANG', 'UPTODATE', 'COCHRANE'];
/** 证据分级 → 配色(越强越绿)。 */
function evidenceTone(level: string): 'green' | 'blue' | 'amber' | 'slate' {
if (level.startsWith('1')) return 'green';
if (level.startsWith('2')) return 'blue';
if (level.startsWith('3') || level === '4') return 'amber';
return 'slate';
}
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(s: string) {
setSelectedSources((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
);
}
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-slate-800"></h1>
<Card title="① 生成检索式">
<div className="space-y-3">
<textarea
className="input"
rows={2}
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="用自然语言描述研究问题,如:他汀类药物对老年冠心病患者二级预防的疗效"
/>
<button
className="btn-primary"
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-slate-200 bg-slate-50 p-4">
<p className="mb-2 font-mono text-sm text-brand-700">
{searchQuery.expression}
</p>
{searchQuery.meshTerms?.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{searchQuery.meshTerms.map((m) => (
<Badge key={m} tone="blue">
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-slate-400">
{searchQuery.rationale}
</p>
)}
</div>
)}
</div>
</Card>
{searchQuery && (
<Card title="② 检索资料">
<div className="mb-3 flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button
key={s}
type="button"
onClick={() => toggleSource(s)}
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
selectedSources.includes(s)
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-300 text-slate-500'
}`}
>
{s}
</button>
))}
</div>
<button
className="btn-primary"
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-amber-50 px-4 py-3 text-sm text-amber-700">
{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-slate-100 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-brand-700 hover:underline"
>
{it.title}
</a>
{it.source && <Badge tone="blue">{it.source}</Badge>}
</div>
{it.authors && (
<p className="mt-1 text-xs text-slate-400">
{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-slate-500">
{it.abstract}
</p>
)}
</li>
))}
</ul>
)}
</Card>
)}
{items.length > 0 && (
<Card title="③ 总结与引用">
<div className="flex gap-3">
<button
className="btn-ghost"
disabled={busy}
onClick={() =>
run(
() => researchApi.summarize({ items }),
(v: Summary) => setSummary(v),
)
}
>
</button>
<button
className="btn-ghost"
disabled={busy}
onClick={() =>
run(
() =>
researchApi.generateCitation({
items,
format: 'VANCOUVER',
}),
(v: Citation[]) => setCitations(v),
)
}
>
Vancouver
</button>
</div>
{summary && <SummaryView summary={summary} />}
{citations && citations.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
{citations.length}
</h3>
<ol className="list-inside list-decimal space-y-1.5 text-sm text-slate-600">
{citations.map((c, i) => (
<li key={c.itemId ?? i}>{c.text}</li>
))}
</ol>
</div>
)}
</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-amber-50 px-3 py-2 text-xs text-amber-700">
{summary.notice}
</p>
<h3 className="mb-2 text-sm font-semibold text-slate-700"></h3>
<ul className="space-y-2">
{summary.conclusions?.map((c) => (
<li
key={c.id}
className="rounded-lg border border-slate-100 p-3 text-sm"
>
<p className="text-slate-700">{c.statement}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{c.verified ? (
<Badge tone="green"></Badge>
) : (
<Badge tone="red">{c.unverifiedLabel ?? '未验证'}</Badge>
)}
{c.citations?.map((s, i) => (
<span key={i} className="text-xs text-slate-400">
[{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-slate-700">
</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-slate-600 hover:text-brand-700 hover:underline"
>
{g.title}
</a>
<Badge tone={evidenceTone(g.evidenceLevel)}>
{g.evidenceLevel}
</Badge>
</li>
))}
</ul>
</div>
)}
<div className="mt-4">
<CredibilityBadge annotation={summary.annotation} />
</div>
<RawDetails data={summary} />
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
'use client';
/**
* 已认证页面的统一外壳:侧边导航 + 顶栏 + 角色访问守卫。
*
* - 未登录:重定向到 /login。
* - 角色不匹配:提示无权访问(避免学生进入导师/管理端等)。
* - 提供按角色定制的导航项。
*/
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState, type ReactNode } from 'react';
import { useAuth } from '@/lib/auth-context';
import { ROLE_LABELS, Role } from '@/lib/types';
import { NavIcon, type IconName } from './nav-icons';
import { Loading } from './ui';
export interface NavItem {
href: string;
label: string;
icon?: IconName;
}
/** 导航分组:label 为可选分组标题(不填则不渲染标题),items 为该组导航项。 */
export interface NavGroup {
label?: string;
items: NavItem[];
}
export function AppShell({
requiredRole,
nav,
title,
children,
}: {
requiredRole: Role;
nav: NavGroup[];
title: string;
children: ReactNode;
}) {
const { user, loading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
if (!loading && !user) {
router.replace('/login');
}
}, [loading, user, router]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loading label="正在校验登录态…" />
</div>
);
}
if (!user) return null;
if (user.role !== requiredRole) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-xl font-semibold text-slate-800">访</h1>
<p className="text-sm text-slate-500">
{ROLE_LABELS[user.role]}访{title}
</p>
<Link href={homeForRole(user.role)} className="btn-primary">
</Link>
</div>
);
}
return (
<div className="flex min-h-screen">
{/* 移动端抽屉遮罩层 */}
{drawerOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/40 md:hidden"
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-60 shrink-0 flex-col border-r border-slate-200 bg-white transition-transform duration-200 md:static md:translate-x-0 ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<div>
<p className="text-sm font-semibold text-brand-700">AI </p>
<p className="mt-0.5 text-xs text-slate-400">{title}</p>
</div>
<button
type="button"
onClick={() => setDrawerOpen(false)}
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 md:hidden"
aria-label="关闭导航"
>
<NavIcon name="close" className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-6 overflow-y-auto p-3">
{nav.map((group, groupIndex) => (
<div
key={group.label ?? `group-${groupIndex}`}
className="space-y-1"
>
{group.label && (
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{group.label}
</p>
)}
{group.items.map((item) => {
// 根路径(如 /student)仅精确匹配,避免「工作台」在所有子页面常驻高亮。
const isRoot =
item.href.split('/').filter(Boolean).length <= 1;
const active = isRoot
? pathname === item.href
: pathname === item.href ||
pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
onClick={() => setDrawerOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${
active
? 'bg-brand-50 font-medium text-brand-700'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{item.icon && (
<NavIcon
name={item.icon}
className={`h-[18px] w-[18px] shrink-0 ${
active ? 'text-brand-600' : 'text-slate-400'
}`}
/>
)}
<span className="truncate">{item.label}</span>
</Link>
);
})}
</div>
))}
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-3 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 md:hidden"
aria-label="打开导航"
>
<NavIcon name="menu" className="h-5 w-5" />
</button>
<div className="text-sm text-slate-500">
{ROLE_LABELS[user.role]}
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden text-right sm:block">
<p className="text-sm font-medium text-slate-700">
{user.username}
</p>
<p className="text-xs text-slate-400">ID: {user.id}</p>
</div>
<button onClick={logout} className="btn-ghost">
<NavIcon name="log-out" className="h-4 w-4" />
退
</button>
</div>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
/** 按角色返回默认首页。 */
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';
}
}
+181
View File
@@ -0,0 +1,181 @@
'use client';
/**
* 跨页面复用的「领域数据展示」组件。
*
* 将后端返回的结构化结果(评分、可信度标注、能力差距、证据分级等)渲染为统一样式的
* 可读 UI,替代早期的 JSON 原样展示。组件均为纯展示、对缺失字段做防御性处理。
*/
import type { ReactNode } from 'react';
import { Badge } from './ui';
/** "数据不足"标记(与后端 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)))
);
}
/** 0-100 分值条;支持"数据不足"。 */
export function ScoreBar({
label,
score,
required,
tone = 'brand',
}: {
label: ReactNode;
score: number | string | null | undefined;
/** 可选:目标/要求水平,渲染为参考刻度线。 */
required?: number;
tone?: 'brand' | 'green' | 'amber' | 'red';
}) {
const insufficient = isInsufficient(score);
const value = insufficient ? 0 : Math.max(0, Math.min(100, Number(score)));
const tones: Record<string, string> = {
brand: 'bg-brand-500',
green: 'bg-green-500',
amber: 'bg-amber-400',
red: 'bg-red-500',
};
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">{label}</span>
{insufficient ? (
<Badge tone="amber"></Badge>
) : (
<span className="tabular-nums text-slate-500">
{value.toFixed(0)}
{typeof required === 'number' && (
<span className="text-slate-400"> / {required}</span>
)}
</span>
)}
</div>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${insufficient ? 'bg-amber-300' : tones[tone]}`}
style={{ width: `${insufficient ? 8 : value}%` }}
/>
{typeof required === 'number' && !insufficient && (
<span
className="absolute top-0 h-full w-0.5 bg-slate-700/60"
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-slate-200 bg-slate-50 p-3">
<div className="mb-2 flex items-center gap-2">
{verified ? (
<Badge tone="green"></Badge>
) : (
<Badge tone="red"></Badge>
)}
{typeof confidence === 'number' && (
<span className="text-xs text-slate-500">
{confidence.toFixed(0)}%
</span>
)}
</div>
{sources.length > 0 ? (
<ul className="space-y-1 text-xs text-slate-500">
{sources.map((s, i) => (
<li key={s.id ?? i}>
· {s.title ?? s.id}
{s.citation && (
<span className="text-slate-400">{s.citation}</span>
)}
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-400"></p>
)}
</div>
);
}
/** 统计数字小卡。 */
export function StatTile({
label,
value,
tone = 'slate',
}: {
label: ReactNode;
value: ReactNode;
tone?: 'slate' | 'green' | 'amber' | 'red' | 'blue';
}) {
const tones: Record<string, string> = {
slate: 'text-slate-800',
green: 'text-green-600',
amber: 'text-amber-600',
red: 'text-red-600',
blue: 'text-brand-600',
};
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-center">
<p className={`text-2xl font-semibold tabular-nums ${tones[tone]}`}>
{value}
</p>
<p className="mt-1 text-xs text-slate-400">{label}</p>
</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-slate-400">{label}</span>
<span className="text-slate-700">{children}</span>
</div>
);
}
/** 可折叠的原始数据(保底查看,默认折叠)。 */
export function RawDetails({ data }: { data: unknown }) {
return (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-slate-400 hover:text-slate-600">
</summary>
<pre className="mt-2 max-h-80 overflow-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-500">
{JSON.stringify(data, null, 2)}
</pre>
</details>
);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* 导航图标集:lucide 风格的内联 SVG(统一 1.75 描边、currentColor)。
*
* 项目未引入图标库,故以内联 SVG 提供,避免新增依赖。
* 通过 <NavIcon name="..." /> 按名称渲染,颜色随父级文字色变化。
*/
import type { ReactNode } from 'react';
/** 受支持的图标名称(与各角色导航配置对齐)。 */
export type IconName =
| 'home'
| 'book-open'
| 'target'
| 'stethoscope'
| 'users'
| 'user-check'
| 'chart'
| 'compass'
| 'search'
| 'clipboard-check'
| 'message-square'
| 'sliders'
| 'shield'
| 'menu'
| 'close'
| 'log-out';
/** 各图标对应的 SVG 子元素(path / circle / line 等)。 */
const PATHS: Record<IconName, ReactNode> = {
home: (
<>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</>
),
'book-open': (
<>
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</>
),
target: (
<>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</>
),
stethoscope: (
<>
<path d="M4 2v6a6 6 0 0 0 12 0V2" />
<path d="M5 2H3" />
<path d="M17 2h-2" />
<path d="M10 14v3a5 5 0 0 0 10 0v-1" />
<circle cx="20" cy="10" r="2" />
</>
),
users: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
'user-check': (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<polyline points="16 11 18 13 22 9" />
</>
),
chart: (
<>
<path d="M3 3v18h18" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</>
),
compass: (
<>
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</>
),
'clipboard-check': (
<>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="m9 14 2 2 4-4" />
</>
),
'message-square': (
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
),
sliders: (
<>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</>
),
shield: (
<>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</>
),
menu: (
<>
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="18" y2="18" />
</>
),
close: (
<>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</>
),
'log-out': (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" />
</>
),
};
/** 按名称渲染的导航图标。颜色继承父级 text 色,尺寸由 className 控制。 */
export function NavIcon({
name,
className,
}: {
name: IconName;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
{PATHS[name]}
</svg>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
/** 跨端复用的基础 UI 组件。 */
import type { ReactNode } from 'react';
/** 区块卡片。 */
export function Card({
title,
actions,
children,
className = '',
}: {
title?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={`card ${className}`}>
{(title || actions) && (
<header className="mb-4 flex items-center justify-between gap-3">
{title && (
<h2 className="text-base font-semibold text-slate-800">{title}</h2>
)}
{actions}
</header>
)}
{children}
</section>
);
}
/** 加载提示。 */
export function Loading({ label = '加载中…' }: { label?: string }) {
return (
<div className="flex items-center gap-2 py-6 text-sm text-slate-500">
<span className="h-3 w-3 animate-pulse rounded-full bg-brand-400" />
{label}
</div>
);
}
/** 错误提示条。 */
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</div>
);
}
/** 空态提示。 */
export function EmptyState({ message }: { message: string }) {
return (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-400">
{message}
</div>
);
}
/** 标签徽章。 */
export function Badge({
children,
tone = 'slate',
}: {
children: ReactNode;
tone?: 'slate' | 'green' | 'amber' | 'blue' | 'red';
}) {
const tones: Record<string, string> = {
slate: 'bg-slate-100 text-slate-600',
green: 'bg-green-100 text-green-700',
amber: 'bg-amber-100 text-amber-700',
blue: 'bg-brand-100 text-brand-700',
red: 'bg-red-100 text-red-700',
};
return <span className={`badge ${tones[tone]}`}>{children}</span>;
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 后端 API 调用客户端。
*
* 统一处理:
* - 基础路径:所有请求走 `/api/*`,由 Next.js rewrites 代理到 NestJS(避免跨域)。
* - 鉴权:自动附加 `Authorization: Bearer <token>`token 由 auth 上下文写入 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;
}
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 } = 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,
});
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;
}
}
+108
View File
@@ -0,0 +1,108 @@
'use client';
/**
* 认证上下文:在客户端管理登录态(当前用户 + 令牌)。
*
* - 登录/注册成功后写入 localStorage 并更新内存态。
* - 初次挂载时若本地有令牌,调用 `/api/auth/me` 还原会话;失败则清除。
* - 提供 `login` / `register` / `logout` 与当前用户 / 加载态。
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { apiRequest, clearToken, getToken, setToken } from './api';
import type { AuthResult, AuthenticatedUser, Role } from './types';
interface AuthContextValue {
user: AuthenticatedUser | null;
/** 初始会话还原是否进行中。 */
loading: boolean;
login: (username: string, password: string) => Promise<AuthenticatedUser>;
register: (input: {
username: string;
password: string;
role: Role;
displayName: string;
}) => Promise<AuthenticatedUser>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthenticatedUser | null>(null);
const [loading, setLoading] = useState(true);
// 初次挂载:若本地有令牌则还原会话。
useEffect(() => {
const token = getToken();
if (!token) {
setLoading(false);
return;
}
apiRequest<AuthenticatedUser>('/api/auth/me')
.then((u) => setUser(u))
.catch(() => {
clearToken();
setUser(null);
})
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
const result = await apiRequest<AuthResult>('/api/auth/login', {
method: 'POST',
body: { username, password },
auth: false,
});
setToken(result.accessToken);
setUser(result.user);
return result.user;
}, []);
const register = useCallback(
async (input: {
username: string;
password: string;
role: Role;
displayName: string;
}) => {
const result = await apiRequest<AuthResult>('/api/auth/register', {
method: 'POST',
body: input,
auth: false,
});
setToken(result.accessToken);
setUser(result.user);
return result.user;
},
[],
);
const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, loading, login, register, logout }),
[user, loading, login, register, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/** 读取认证上下文。 */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用');
return ctx;
}
+267
View File
@@ -0,0 +1,267 @@
/**
* 按后端模块组织的前端 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 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 },
}),
};
+366
View File
@@ -0,0 +1,366 @@
/**
* 与后端领域类型对齐的前端类型定义。
*
* 仅声明前端实际消费的字段;后端返回的对象可能含更多字段,按需扩展即可。
* 枚举值与后端字符串字面量保持一致(如 Role 的 'student' / 'mentor' / 'administrator')。
*/
/** 全系统规范角色(与后端 compliance.Role 对齐)。 */
export enum Role {
Student = 'student',
Mentor = 'mentor',
Administrator = 'administrator',
}
/** 角色中文展示名。 */
export const ROLE_LABELS: Record<Role, string> = {
[Role.Student]: '学生',
[Role.Mentor]: '导师',
[Role.Administrator]: '管理员',
};
/** 认证身份(与后端 AuthenticatedUser 对齐)。 */
export interface AuthenticatedUser {
id: string;
username: string;
role: Role;
}
/** 登录/注册结果(与后端 AuthResult 对齐)。 */
export interface AuthResult {
accessToken: string;
expiresIn: number;
user: AuthenticatedUser;
}
/** 后端统一错误响应体(AppError.toJSON())。 */
export interface ApiErrorBody {
kind?: string;
code?: string;
message?: string;
details?: unknown;
}
/** 学习成果类型(与后端 AchievementType 对齐,共 9 种)。 */
export enum AchievementType {
CourseRecord = 'course_record',
Assignment = 'assignment',
LabReport = 'lab_report',
ClinicalClerkship = 'clinical_clerkship',
OsceAssessment = 'osce_assessment',
LiteratureReading = 'literature_reading',
ResearchOutput = 'research_output',
Certificate = 'certificate',
LicensingExamPrep = 'licensing_exam_prep',
}
export const ACHIEVEMENT_TYPE_LABELS: Record<string, string> = {
course_record: '课程记录',
assignment: '作业',
lab_report: '实验报告',
clinical_clerkship: '临床见习/实习',
osce_assessment: 'OSCE 技能考核',
literature_reading: '文献阅读',
research_output: '科研成果',
certificate: '证书',
licensing_exam_prep: '执业资格备考',
};
/** 分页结果(与后端 Paginated<T> 对齐)。 */
export interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/** 学习成果。 */
export interface Achievement {
id: string;
studentId: string;
type: AchievementType;
title: string;
occurredAt: string;
academicYear?: string;
semester?: string;
rotationDept?: string;
attachments?: { id: string; name: string; url: string }[];
}
/** 画像维度分值(与后端画像引擎对齐)。score 为数字或字符串 'insufficient_data'。 */
export interface ProfileDimensionScore {
dimension: string;
dimensionName?: string;
score: number | string | null;
sensitive?: boolean;
sensitiveCategory?: string;
}
/** 学生六维画像。 */
export interface StudentProfile {
studentId: string;
dimensions: ProfileDimensionScore[];
generatedAt?: string;
}
/** 维度中文展示名(覆盖常见维度键,未覆盖的回退为原值)。 */
export const DIMENSION_LABELS: Record<string, string> = {
knowledge: '知识掌握',
clinical: '临床能力',
research: '科研能力',
collaboration: '协同能力',
practice: '实践能力',
professionalism: '职业素养',
};
/* ===================== 各模块结构化返回类型(与后端对齐) ===================== */
/** 可信度标注来源。 */
export interface SourceRef {
id: string;
title: string;
citation?: string;
}
/** 可信度标注(AI 输出统一封装)。 */
export interface CredibilityAnnotation {
sources: SourceRef[];
confidence: number;
verified: boolean;
}
/* ---------- 职业规划:发展规划 ---------- */
export interface SuggestedAction {
description: string;
targetLevel: number;
}
export interface RecommendedResource {
id: string;
title: string;
type: string;
tagId: string;
}
export interface CompetencyGap {
tagId: string;
tagName: string;
dimension: string;
dimensionName: string;
currentLevel: number | string;
requiredLevel: number;
missingData: boolean;
suggestedActions: SuggestedAction[];
recommendedResources: RecommendedResource[];
resourceNote?: string;
}
export interface DevelopmentPlan {
studentId: string;
goalId: string;
goalTitle: string;
modelId: string;
gaps: CompetencyGap[];
generatedAt: string;
}
/** 岗位胜任力模型(setCareerGoal 返回)。 */
export interface CompetencyModel {
id: string;
goalId: string;
goalTitle: string;
framework: string;
dimensions: {
dimension: string;
dimensionName: string;
competencyTags: {
tagId: string;
tagName: string;
dimension: string;
requiredLevel: number;
}[];
}[];
generatedAt: string;
}
export interface CareerGoalAssociation {
studentId: string;
goal: { id: string; title: string; description?: string };
model: CompetencyModel;
associatedAt: string;
}
/* ---------- 课程对练:报告 ---------- */
export interface WeakArea {
tagId: string;
tagName?: string;
totalQuestions: number;
correctCount: number;
correctnessRate: number;
}
export interface PracticeImprovementSuggestion {
content: string;
competencyTagIds: string[];
annotation: CredibilityAnnotation;
trustOutputId: string;
}
export interface CompetencyTagBreakdown {
tagId: string;
totalQuestions: number;
correctCount: number;
correctnessRate: number;
}
export interface PracticeReport {
id: string;
sessionId: string;
studentId: string;
courseId: string;
totalQuestions: number;
correctCount: number;
incorrectCount: number;
accuracy: number;
totalTimeSeconds: number;
tagBreakdown: CompetencyTagBreakdown[];
weakAreas: WeakArea[];
suggestions: PracticeImprovementSuggestion[];
competencyMappingIds: string[];
generatedAt: string;
}
export interface AnswerResult {
sessionId: string;
questionId: string;
correct: boolean;
timedOut: boolean;
elapsedMs: number;
next?: { questionId: string; position: number; total: number };
}
/* ---------- 临床对话:三维报告 ---------- */
export interface DialogueDimensionScore {
dimension: string;
dimensionName: string;
score: number;
competencyTagId?: string;
comment: string;
}
export interface DialogueReport {
id: string;
sessionId: string;
studentId: string;
scenarioId: string;
dimensions: DialogueDimensionScore[];
overallScore: number;
annotation: CredibilityAnnotation;
trustOutputId: string;
turnCount: number;
generatedAt: string;
}
/* ---------- 研究查询:检索式/总结/引用 ---------- */
export interface SearchQuery {
id: string;
question: string;
pico: {
population?: string;
intervention?: string;
comparison?: string;
outcome?: string;
};
meshTerms: string[];
keywords: string[];
expression: string;
rationale?: string;
}
export interface SummaryConclusion {
id: string;
statement: string;
verified: boolean;
citations: SourceRef[];
unverifiedLabel?: string;
}
export interface GradedReference {
itemId: string;
title: string;
url: string;
source: string;
evidenceLevel: string;
}
export interface Summary {
id: string;
conclusions: SummaryConclusion[];
citationOutput: SummaryConclusion[];
gradedItems: GradedReference[];
notice: string;
annotation: CredibilityAnnotation;
trustOutputId: string;
generatedAt: string;
}
export interface Citation {
itemId: string;
format: string;
text: string;
title: string;
authors: string[];
year: string;
url: string;
source: string;
}
/* ---------- 协同训练:评估结果 ---------- */
export interface CollaborationDimensionScore {
dimension: string;
dimensionName: string;
score: number | string;
competencyTagId?: string;
comment: string;
}
export interface CollaborationAssessment {
id: string;
studentId: string;
taskId: string;
dimensions: CollaborationDimensionScore[];
overallScore: number | string;
requiresSourceVerification: boolean;
verificationPrompt?: string;
annotation: CredibilityAnnotation;
trustOutputId: string;
competencyMappingIds: string[];
generatedAt: string;
}
/* ---------- 导师:学生画像视图(脱敏) ---------- */
export interface ProfileFieldView {
key: string;
value: unknown;
sensitive: boolean;
category?: string;
redacted: boolean;
}
export interface StudentProfileView {
studentId: string;
viewerId: string;
purpose: string;
fullAccess: boolean;
authorized: boolean;
authorizedScope: string[];
fields: ProfileFieldView[];
redactedFieldKeys: string[];
notice?: string;
resolvedAt: string;
}
/* ---------- 管理端:技能定义 ---------- */
export interface SkillDefinition {
id: string;
name: string;
inputSpec?: unknown;
processingLogic?: unknown;
knowledgeSources?: unknown;
outputFormat?: unknown;
credibilityRule?: unknown;
enabled: boolean;
}
export interface SkillAuditLogEntry {
id?: string;
skillId?: string;
action?: string;
actorId?: string;
actorRole?: string;
timestamp?: string;
[k: string]: unknown;
}
+26
View File
@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#eef6ff',
100: '#d9eaff',
200: '#bcd9ff',
300: '#8ec1ff',
400: '#599dff',
500: '#3377f5',
600: '#1f59db',
700: '#1a47b0',
800: '#1b3e8c',
900: '#1c386f',
},
},
},
},
plugins: [],
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"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"]
}