Commit 4ae56601 authored by yuguo's avatar yuguo

fix

parent f350fadd
...@@ -12,7 +12,9 @@ ...@@ -12,7 +12,9 @@
"Bash(node:*)", "Bash(node:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(npx tailwindcss:*)", "Bash(npx tailwindcss:*)",
"Bash(npx next:*)" "Bash(npx next:*)",
"Bash(PGPASSWORD=T5sSfTZ6XYTD9bfC psql:*)",
"Bash(npm install:*)"
] ]
} }
} }
File added
...@@ -5,12 +5,14 @@ server: ...@@ -5,12 +5,14 @@ server:
version: 1.0.0 version: 1.0.0
database: database:
host: localhost host: 10.10.0.102
port: 5432 port: 5432
user: postgres user: postgres
password: your_password password: T5sSfTZ6XYTD9bfC
dbname: internet_hospital dbname: xxxx
sslmode: disable sslmode: disable
schema: public
timezone: Asia/Shanghai
redis: redis:
host: localhost host: localhost
......
...@@ -35,6 +35,8 @@ type DatabaseConfig struct { ...@@ -35,6 +35,8 @@ type DatabaseConfig struct {
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"` DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"` SSLMode string `mapstructure:"sslmode"`
Schema string `mapstructure:"schema"`
Timezone string `mapstructure:"timezone"`
} }
type RedisConfig struct { type RedisConfig struct {
......
...@@ -17,6 +17,12 @@ func InitPostgres(cfg *config.DatabaseConfig) (*gorm.DB, error) { ...@@ -17,6 +17,12 @@ func InitPostgres(cfg *config.DatabaseConfig) (*gorm.DB, error) {
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
) )
if cfg.Schema != "" {
dsn += fmt.Sprintf(" search_path=%s", cfg.Schema)
}
if cfg.Timezone != "" {
dsn += fmt.Sprintf(" TimeZone=%s", cfg.Timezone)
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
......
...@@ -12,7 +12,7 @@ import ( ...@@ -12,7 +12,7 @@ import (
func main() { func main() {
// 数据库连接配置 // 数据库连接配置
dsn := "host=localhost port=5432 user=postgres password=123456 dbname=internet_hospital sslmode=disable" dsn := "host=10.10.0.102 port=5432 user=postgres password=T5sSfTZ6XYTD9bfC dbname=xxxx sslmode=disable search_path=public TimeZone=Asia/Shanghai"
// 连接数据库 // 连接数据库
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"antd": "^5.25.3", "antd": "^5.25.3",
...@@ -26,7 +27,7 @@ ...@@ -26,7 +27,7 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"typescript": "~5.9.3" "typescript": "5.9.3"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
...@@ -138,6 +139,20 @@ ...@@ -138,6 +139,20 @@
"react": ">=16.9.0" "react": ">=16.9.0"
} }
}, },
"node_modules/@ant-design/v5-patch-for-react-19": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@ant-design/v5-patch-for-react-19/-/v5-patch-for-react-19-1.0.3.tgz",
"integrity": "sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w==",
"license": "MIT",
"engines": {
"node": ">=12.x"
},
"peerDependencies": {
"antd": ">=5.22.6",
"react": ">=19.0.0",
"react-dom": ">=19.0.0"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
...@@ -4020,7 +4035,7 @@ ...@@ -4020,7 +4035,7 @@
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
......
...@@ -3,13 +3,14 @@ ...@@ -3,13 +3,14 @@
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack --hostname 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"antd": "^5.25.3", "antd": "^5.25.3",
...@@ -27,6 +28,6 @@ ...@@ -27,6 +28,6 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"typescript": "~5.9.3" "typescript": "5.9.3"
} }
} }
...@@ -33,6 +33,25 @@ export interface DoctorReviewItem { ...@@ -33,6 +33,25 @@ export interface DoctorReviewItem {
qualification_image?: string; qualification_image?: string;
} }
// 医生管理列表项(管理端用)
export interface DoctorItem {
id: string;
user_id: string;
name: string;
phone: string;
avatar: string;
title: string;
department_id: string;
department_name: string;
hospital: string;
license_no: string;
introduction: string;
specialties: string[];
review_status: string;
review_id: string;
user_status: string;
}
// 用户管理查询参数 // 用户管理查询参数
export interface UserListParams { export interface UserListParams {
keyword?: string; keyword?: string;
...@@ -237,6 +256,9 @@ export const adminApi = { ...@@ -237,6 +256,9 @@ export const adminApi = {
post<null>(`/admin/patients/${patientId}/reset-password`), post<null>(`/admin/patients/${patientId}/reset-password`),
// === 医生管理 === // === 医生管理 ===
getDoctorList: (params: UserListParams) =>
get<PaginatedResponse<DoctorItem>>('/admin/doctors', { params }),
getDoctorManageList: (params: UserListParams) => getDoctorManageList: (params: UserListParams) =>
get<PaginatedResponse<DoctorReviewItem>>('/admin/doctors', { params }), get<PaginatedResponse<DoctorReviewItem>>('/admin/doctors', { params }),
......
import { get, post } from './request'; import { get, post } from './request';
import type { DoctorWorkbenchStats } from './doctorPortal';
export type { DoctorWorkbenchStats };
// 问诊相关类型定义 // 问诊相关类型定义
export type ConsultType = 'text' | 'video'; export type ConsultType = 'text' | 'video';
...@@ -149,14 +151,3 @@ export const consultApi = { ...@@ -149,14 +151,3 @@ export const consultApi = {
post<null>(`/consult/${id}/reject`, { reason }), post<null>(`/consult/${id}/reject`, { reason }),
}; };
// 医生工作台统计
export interface DoctorWorkbenchStats {
today_consult_count: number;
waiting_count: number;
in_progress_count: number;
completed_today: number;
total_patients: number;
rating: number;
income_today: number;
income_month: number;
}
...@@ -3,4 +3,13 @@ export * from './user'; ...@@ -3,4 +3,13 @@ export * from './user';
export * from './doctor'; export * from './doctor';
export * from './consult'; export * from './consult';
export * from './admin'; export * from './admin';
export * from './doctorPortal'; // doctorPortal 中的 DoctorWorkbenchStats, WaitingPatient, PatientListItem 与 consult.ts 重名,需选择性导出
export {
doctorPortalApi,
type DoctorProfile,
type ScheduleSlot,
type PatientSummary,
type PatientConsultRecord,
type PatientPrescriptionRecord,
type PatientDetail,
} from './doctorPortal';
...@@ -66,7 +66,7 @@ export interface ChatDoneInfo { ...@@ -66,7 +66,7 @@ export interface ChatDoneInfo {
// ====== SSE 流式对话工具函数 ====== // ====== SSE 流式对话工具函数 ======
const getApiBaseURL = () => { const getApiBaseURL = () => {
const envUrl = import.meta.env.VITE_API_BASE_URL; const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (envUrl && envUrl.includes('/api/v1')) return envUrl; if (envUrl && envUrl.includes('/api/v1')) return envUrl;
return (envUrl || 'http://localhost:8080') + '/api/v1'; return (envUrl || 'http://localhost:8080') + '/api/v1';
}; };
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Form, Input, Button, Tabs, message } from 'antd'; import { Form, Input, Button, Tabs, App } from 'antd';
import { import {
MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined, MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined,
SafetyCertificateOutlined, TeamOutlined, CloudServerOutlined, SafetyCertificateOutlined, TeamOutlined, CloudServerOutlined,
...@@ -14,6 +14,7 @@ import { userApi } from '@/api/user'; ...@@ -14,6 +14,7 @@ import { userApi } from '@/api/user';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { message } = App.useApp();
const { setUser, setTokens } = useUserStore(); const { setUser, setTokens } = useUserStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
...@@ -44,7 +45,7 @@ const LoginPage: React.FC = () => { ...@@ -44,7 +45,7 @@ const LoginPage: React.FC = () => {
key: 'phone', key: 'phone',
label: '手机号登录', label: '手机号登录',
children: ( children: (
<Form onFinish={handleLogin} size="large" className="mt-4"> <Form onFinish={handleLogin} size="large" style={{ marginTop: 24 }}>
<Form.Item <Form.Item
name="phone" name="phone"
rules={[ rules={[
...@@ -52,22 +53,22 @@ const LoginPage: React.FC = () => { ...@@ -52,22 +53,22 @@ const LoginPage: React.FC = () => {
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' },
]} ]}
> >
<Input <Input
prefix={<MobileOutlined className="text-gray-400" />} prefix={<MobileOutlined style={{ color: '#bfbfbf' }} />}
placeholder="手机号" placeholder="手机号"
className="h-11 rounded-lg" style={{ height: 44, borderRadius: 8 }}
/> />
</Form.Item> </Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}> <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password <Input.Password
prefix={<LockOutlined className="text-gray-400" />} prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码" placeholder="密码"
className="h-11 rounded-lg" style={{ height: 44, borderRadius: 8 }}
/> />
</Form.Item> </Form.Item>
<Form.Item className="mb-0 mt-2"> <Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block <Button type="primary" htmlType="submit" loading={loading} block
className="h-11 rounded-lg font-semibold text-base shadow-lg shadow-blue-200/50"> style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 15 }}>
登录 登录
</Button> </Button>
</Form.Item> </Form.Item>
...@@ -78,24 +79,24 @@ const LoginPage: React.FC = () => { ...@@ -78,24 +79,24 @@ const LoginPage: React.FC = () => {
key: 'username', key: 'username',
label: '账号登录', label: '账号登录',
children: ( children: (
<Form onFinish={handleLogin} size="large" className="mt-4"> <Form onFinish={handleLogin} size="large" style={{ marginTop: 24 }}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}> <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input <Input
prefix={<UserOutlined className="text-gray-400" />} prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="用户名" placeholder="用户名"
className="h-11 rounded-lg" style={{ height: 44, borderRadius: 8 }}
/> />
</Form.Item> </Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}> <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password <Input.Password
prefix={<LockOutlined className="text-gray-400" />} prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码" placeholder="密码"
className="h-11 rounded-lg" style={{ height: 44, borderRadius: 8 }}
/> />
</Form.Item> </Form.Item>
<Form.Item className="mb-0 mt-2"> <Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block <Button type="primary" htmlType="submit" loading={loading} block
className="h-11 rounded-lg font-semibold text-base shadow-lg shadow-blue-200/50"> style={{ height: 44, borderRadius: 8, fontWeight: 600, fontSize: 15 }}>
登录 登录
</Button> </Button>
</Form.Item> </Form.Item>
...@@ -105,99 +106,194 @@ const LoginPage: React.FC = () => { ...@@ -105,99 +106,194 @@ const LoginPage: React.FC = () => {
]; ];
return ( return (
<div className="fixed inset-0 flex items-center justify-center overflow-auto py-8" <div style={{
style={{ background: 'linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)' }}> position: 'fixed',
{/* 装饰粒子 */} inset: 0,
<div className="absolute inset-0 overflow-hidden pointer-events-none"> display: 'flex',
<div className="absolute top-[10%] left-[5%] w-64 h-64 bg-white/5 rounded-full blur-3xl" /> alignItems: 'center',
<div className="absolute bottom-[15%] right-[8%] w-80 h-80 bg-blue-400/10 rounded-full blur-3xl" /> justifyContent: 'center',
<div className="absolute top-[40%] right-[20%] w-40 h-40 bg-cyan-400/8 rounded-full blur-2xl" /> overflow: 'auto',
padding: '32px 0',
background: 'linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)',
}}>
{/* 响应式样式 */}
<style>{`
.login-left-panel { display: none; }
.login-mobile-logo { display: flex; }
.login-right-panel { padding: 32px; }
@media (min-width: 768px) {
.login-left-panel { display: flex; }
.login-mobile-logo { display: none !important; }
.login-right-panel { padding: 40px; }
}
`}</style>
{/* 装饰背景 */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: '10%', left: '5%', width: 256, height: 256, background: 'rgba(255,255,255,0.05)', borderRadius: '50%', filter: 'blur(64px)' }} />
<div style={{ position: 'absolute', bottom: '15%', right: '8%', width: 320, height: 320, background: 'rgba(96,165,250,0.1)', borderRadius: '50%', filter: 'blur(64px)' }} />
<div style={{ position: 'absolute', top: '40%', right: '20%', width: 160, height: 160, background: 'rgba(34,211,238,0.1)', borderRadius: '50%', filter: 'blur(40px)' }} />
</div> </div>
<div className="relative z-10 flex w-full max-w-[960px] mx-4 rounded-2xl overflow-hidden shadow-2xl shadow-black/30"> <div style={{
position: 'relative',
zIndex: 10,
display: 'flex',
width: '100%',
maxWidth: 920,
margin: '0 16px',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 25px 60px rgba(0,0,0,0.3)',
}}>
{/* 左侧品牌区 - 仅在 md 及以上屏幕显示 */} {/* 左侧品牌区 - 仅在 md 及以上屏幕显示 */}
<div className="hidden md:flex flex-col justify-between w-[420px] shrink-0 p-8 text-white" <div className="login-left-panel" style={{
style={{ background: 'linear-gradient(180deg, rgba(0,58,140,0.9) 0%, rgba(0,80,179,0.85) 50%, rgba(24,144,255,0.8) 100%)', backdropFilter: 'blur(20px)' }}> flexDirection: 'column',
justifyContent: 'space-between',
width: 400,
flexShrink: 0,
padding: 32,
color: '#fff',
background: 'linear-gradient(180deg, rgba(0,58,140,0.95) 0%, rgba(0,80,179,0.9) 50%, rgba(24,144,255,0.85) 100%)',
backdropFilter: 'blur(20px)',
}}>
<div> <div>
<div className="flex items-center gap-3 mb-8"> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 40 }}>
<div className="w-11 h-11 rounded-xl bg-white/20 flex items-center justify-center backdrop-blur-sm"> <div style={{
<MedicineBoxOutlined className="text-xl text-white" /> width: 44, height: 44, borderRadius: 12,
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(8px)',
}}>
<MedicineBoxOutlined style={{ fontSize: 20, color: '#fff' }} />
</div> </div>
<div> <div>
<div className="text-lg font-bold tracking-wide">互联网医院</div> <div style={{ fontSize: 18, fontWeight: 700, letterSpacing: 1 }}>互联网医院</div>
<div className="text-xs text-blue-200/80">Internet Hospital Platform</div> <div style={{ fontSize: 12, color: 'rgba(191,219,254,0.8)' }}>Internet Hospital Platform</div>
</div> </div>
</div> </div>
<h2 className="text-2xl font-bold mb-3 leading-tight">专业在线医疗<br />服务平台</h2> <h2 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12, lineHeight: 1.4 }}>
<p className="text-sm text-blue-200/70 leading-relaxed mb-8"> 专业在线医疗<br />服务平台
</h2>
<p style={{ fontSize: 14, color: 'rgba(191,219,254,0.7)', lineHeight: 1.8 }}>
连接优质医疗资源,提供智能问诊、电子处方、<br />在线购药等一站式健康管理服务 连接优质医疗资源,提供智能问诊、电子处方、<br />在线购药等一站式健康管理服务
</p> </p>
</div> </div>
<div className="space-y-4"> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"> {/* 专业医生团队 */}
<div className="w-9 h-9 rounded-lg bg-cyan-400/20 flex items-center justify-center shrink-0"> <div style={{
<TeamOutlined className="text-cyan-300" /> display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(34,211,238,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<TeamOutlined style={{ color: '#67e8f9' }} />
</div> </div>
<div> <div>
<div className="text-sm font-medium">专业医生团队</div> <div style={{ fontSize: 14, fontWeight: 500 }}>专业医生团队</div>
<div className="text-xs text-blue-200/60">三甲医院主治医师在线坐诊</div> <div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>三甲医院主治医师在线坐诊</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"> {/* CA电子签名 */}
<div className="w-9 h-9 rounded-lg bg-green-400/20 flex items-center justify-center shrink-0"> <div style={{
<SafetyCertificateOutlined className="text-green-300" /> display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(74,222,128,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<SafetyCertificateOutlined style={{ color: '#86efac' }} />
</div> </div>
<div> <div>
<div className="text-sm font-medium">CA电子签名</div> <div style={{ fontSize: 14, fontWeight: 500 }}>CA电子签名</div>
<div className="text-xs text-blue-200/60">电子处方合规安全有保障</div> <div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>电子处方合规安全有保障</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"> {/* AI智能辅助 */}
<div className="w-9 h-9 rounded-lg bg-purple-400/20 flex items-center justify-center shrink-0"> <div style={{
<CloudServerOutlined className="text-purple-300" /> display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(167,139,250,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<CloudServerOutlined style={{ color: '#c4b5fd' }} />
</div> </div>
<div> <div>
<div className="text-sm font-medium">AI智能辅助</div> <div style={{ fontSize: 14, fontWeight: 500 }}>AI智能辅助</div>
<div className="text-xs text-blue-200/60">AI预问诊与辅助诊断系统</div> <div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>AI预问诊与辅助诊断系统</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs text-blue-300/50 mt-6"> <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'rgba(147,197,253,0.5)', marginTop: 24 }}>
<HeartOutlined /> <HeartOutlined />
<span>守护每一份健康</span> <span>守护每一份健康</span>
</div> </div>
</div> </div>
{/* 右侧登录区 */} {/* 右侧登录区 */}
<div className="flex-1 bg-white p-8 md:p-10 flex flex-col justify-center min-h-[520px]"> <div className="login-right-panel" style={{
flex: 1,
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: 520,
}}>
{/* 移动端 Logo(仅在左侧面板隐藏时显示) */} {/* 移动端 Logo(仅在左侧面板隐藏时显示) */}
<div className="flex md:hidden items-center gap-2 mb-6"> <div className="login-mobile-logo" style={{
<div className="w-9 h-9 rounded-xl bg-[#1890ff] flex items-center justify-center"> alignItems: 'center',
<MedicineBoxOutlined className="text-base text-white" /> gap: 8,
marginBottom: 32,
}}>
<div style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: '#1890ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 18, color: '#fff' }} />
</div> </div>
<div className="text-base font-bold text-gray-800">互联网医院</div> <div style={{ fontSize: 16, fontWeight: 700, color: '#1d2129' }}>互联网医院</div>
</div> </div>
<div className="mb-8"> <div style={{ width: '100%', maxWidth: 340, margin: '0 auto' }}>
<h3 className="text-2xl font-bold text-gray-800 mb-2">欢迎登录</h3> <div style={{ marginBottom: 32 }}>
<p className="text-sm text-gray-400">登录以使用完整的医疗服务功能</p> <h3 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, color: '#1d2129' }}>欢迎登录</h3>
</div> <p style={{ fontSize: 14, color: '#8c8c8c', margin: 0 }}>登录以使用完整的医疗服务功能</p>
</div>
<div className="w-full max-w-[320px] mx-auto"> <Tabs items={tabItems} centered size="middle" />
<Tabs items={tabItems} centered size="middle" className="login-tabs" />
<div className="text-center mt-6 text-sm"> <div style={{ textAlign: 'center', marginTop: 24, fontSize: 14 }}>
<span className="text-gray-400">还没有账号?</span> <span style={{ color: '#8c8c8c' }}>还没有账号?</span>
<Link href="/register" className="ml-1 font-medium text-[#1890ff] hover:text-[#40a9ff]">立即注册</Link> <Link href="/register" style={{ marginLeft: 4, fontWeight: 500, color: '#1890ff' }}>
立即注册
</Link>
</div> </div>
<div className="mt-8 pt-4 border-t border-gray-100 text-center"> <div style={{ marginTop: 32, paddingTop: 16, textAlign: 'center', borderTop: '1px solid #f0f0f0' }}>
<p className="text-xs text-gray-400"> <p style={{ fontSize: 12, color: '#bfbfbf', margin: 0 }}>
登录即表示同意 登录即表示同意
<a href="#" className="text-[#1890ff] mx-0.5 hover:underline">《用户服务协议》</a>和 <a href="#" style={{ color: '#1890ff', margin: '0 2px' }}>《用户服务协议》</a>和
<a href="#" className="text-[#1890ff] mx-0.5 hover:underline">《隐私政策》</a> <a href="#" style={{ color: '#1890ff', margin: '0 2px' }}>《隐私政策》</a>
</p> </p>
</div> </div>
</div> </div>
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Form, Input, Button, Steps, message, Typography, Space, Radio, Select, InputNumber, Row, Col } from 'antd'; import { Form, Input, Button, Steps, App, Typography, Space, Radio, Select, InputNumber, Row, Col } from 'antd';
import { import {
MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined, CheckCircleOutlined, MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined, CheckCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
...@@ -14,6 +14,7 @@ const { Title, Text, Paragraph } = Typography; ...@@ -14,6 +14,7 @@ const { Title, Text, Paragraph } = Typography;
const RegisterPage: React.FC = () => { const RegisterPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const { message } = App.useApp();
const { setUser, setTokens } = useUserStore(); const { setUser, setTokens } = useUserStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
...@@ -60,54 +61,102 @@ const RegisterPage: React.FC = () => { ...@@ -60,54 +61,102 @@ const RegisterPage: React.FC = () => {
}; };
return ( return (
<div className="fixed inset-0 flex items-center justify-center overflow-auto py-8" <div style={{
style={{ background: 'linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)' }}> position: 'fixed',
<div className="absolute inset-0 overflow-hidden pointer-events-none"> inset: 0,
<div className="absolute top-[10%] left-[5%] w-64 h-64 bg-white/5 rounded-full blur-3xl" /> display: 'flex',
<div className="absolute bottom-[15%] right-[8%] w-80 h-80 bg-blue-400/10 rounded-full blur-3xl" /> alignItems: 'center',
justifyContent: 'center',
overflow: 'auto',
padding: '32px 0',
background: 'linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)',
}}>
{/* 装饰背景 */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: '10%', left: '5%', width: 256, height: 256, background: 'rgba(255,255,255,0.05)', borderRadius: '50%', filter: 'blur(64px)' }} />
<div style={{ position: 'absolute', bottom: '15%', right: '8%', width: 320, height: 320, background: 'rgba(96,165,250,0.1)', borderRadius: '50%', filter: 'blur(64px)' }} />
</div> </div>
<div className="relative z-10 w-full max-w-[480px] bg-white rounded-2xl shadow-2xl shadow-black/30 p-8 mx-4"> <div style={{
<div className="text-center mb-5"> position: 'relative',
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-[#1890ff] to-[#096dd9] flex items-center justify-center mx-auto mb-3"> zIndex: 10,
<MedicineBoxOutlined className="text-xl text-white" /> width: '100%',
maxWidth: 480,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 25px 60px rgba(0,0,0,0.3)',
padding: 32,
margin: '0 16px',
}}>
{/* 顶部 Logo */}
<div style={{ textAlign: 'center', marginBottom: 20 }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: 'linear-gradient(135deg, #1890ff, #096dd9)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 12px',
}}>
<MedicineBoxOutlined style={{ fontSize: 20, color: '#fff' }} />
</div> </div>
<Title level={4} className="mb-1 text-gray-800">注册互联网医院</Title> <Title level={4} style={{ marginBottom: 4, color: '#1d2129' }}>注册互联网医院</Title>
<Text type="secondary" className="text-xs">选择您的身份开始注册</Text> <Text type="secondary" style={{ fontSize: 12 }}>选择您的身份开始注册</Text>
</div> </div>
<Steps current={currentStep} size="small" className="mb-6" <Steps current={currentStep} size="small" style={{ marginBottom: 24 }}
items={[{ title: '选择身份' }, { title: '填写信息' }, { title: '注册成功' }]} /> items={[{ title: '选择身份' }, { title: '填写信息' }, { title: '注册成功' }]} />
{currentStep === 0 && ( {currentStep === 0 && (
<div> <div>
<Paragraph className="text-center mb-4 text-gray-400 text-sm">请选择您的注册身份</Paragraph> <Paragraph style={{ textAlign: 'center', marginBottom: 16, color: '#8c8c8c', fontSize: 14 }}>
<Radio.Group value={registerType} onChange={(e) => setRegisterType(e.target.value)} className="w-full"> 请选择您的注册身份
<Space direction="vertical" className="w-full"> </Paragraph>
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${registerType === 'patient' ? 'border-[#1890ff] bg-primary-bg' : 'border-gray-200 hover:border-blue-200'}`} <Radio.Group value={registerType} onChange={(e) => setRegisterType(e.target.value)}
style={{ width: '100%' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<div
style={{
padding: 16, borderRadius: 12, cursor: 'pointer',
transition: 'all 0.2s',
border: `2px solid ${registerType === 'patient' ? '#1890ff' : '#e5e7eb'}`,
backgroundColor: registerType === 'patient' ? '#f0f5ff' : 'transparent',
}}
onClick={() => setRegisterType('patient')}> onClick={() => setRegisterType('patient')}>
<Radio value="patient"> <Radio value="patient">
<Space> <Space>
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"> <div style={{
<UserOutlined className="text-[#1890ff]" /> width: 36, height: 36, borderRadius: 8,
backgroundColor: '#eff6ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<UserOutlined style={{ color: '#1890ff' }} />
</div> </div>
<div> <div>
<Text strong className="text-sm">我是患者</Text><br /> <Text strong style={{ fontSize: 14 }}>我是患者</Text><br />
<Text type="secondary" className="text-xs">在线问诊、找医生、视频通话</Text> <Text type="secondary" style={{ fontSize: 12 }}>在线问诊、找医生、视频通话</Text>
</div> </div>
</Space> </Space>
</Radio> </Radio>
</div> </div>
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${registerType === 'doctor' ? 'border-[#1890ff] bg-primary-bg' : 'border-gray-200 hover:border-blue-200'}`} <div
style={{
padding: 16, borderRadius: 12, cursor: 'pointer',
transition: 'all 0.2s',
border: `2px solid ${registerType === 'doctor' ? '#1890ff' : '#e5e7eb'}`,
backgroundColor: registerType === 'doctor' ? '#f0f5ff' : 'transparent',
}}
onClick={() => setRegisterType('doctor')}> onClick={() => setRegisterType('doctor')}>
<Radio value="doctor"> <Radio value="doctor">
<Space> <Space>
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"> <div style={{
<MedicineBoxOutlined className="text-[#1890ff]" /> width: 36, height: 36, borderRadius: 8,
backgroundColor: '#eff6ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ color: '#1890ff' }} />
</div> </div>
<div> <div>
<Text strong className="text-sm">我是医生</Text><br /> <Text strong style={{ fontSize: 14 }}>我是医生</Text><br />
<Text type="secondary" className="text-xs">在线接诊、管理排班、服务患者</Text> <Text type="secondary" style={{ fontSize: 12 }}>在线接诊、管理排班、服务患者</Text>
</div> </div>
</Space> </Space>
</Radio> </Radio>
...@@ -115,7 +164,12 @@ const RegisterPage: React.FC = () => { ...@@ -115,7 +164,12 @@ const RegisterPage: React.FC = () => {
</Space> </Space>
</Radio.Group> </Radio.Group>
<Button type="primary" block <Button type="primary" block
className="mt-5 h-10 rounded-lg font-semibold bg-linear-to-r from-[#1890ff] to-[#096dd9] border-none shadow-md shadow-blue-200" style={{
marginTop: 20, height: 40, borderRadius: 8,
fontWeight: 600, border: 'none',
background: 'linear-gradient(90deg, #1890ff, #096dd9)',
boxShadow: '0 4px 12px rgba(24,144,255,0.3)',
}}
onClick={() => setCurrentStep(1)}>下一步</Button> onClick={() => setCurrentStep(1)}>下一步</Button>
</div> </div>
)} )}
...@@ -123,13 +177,13 @@ const RegisterPage: React.FC = () => { ...@@ -123,13 +177,13 @@ const RegisterPage: React.FC = () => {
{currentStep === 1 && ( {currentStep === 1 && (
<Form form={form} onFinish={handleRegister} size="middle"> <Form form={form} onFinish={handleRegister} size="middle">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}> <Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined className="text-primary/60" />} placeholder="手机号" /> <Input prefix={<MobileOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="手机号" />
</Form.Item> </Form.Item>
<Form.Item name="real_name" rules={[{ required: true, message: '请输入真实姓名' }]}> <Form.Item name="real_name" rules={[{ required: true, message: '请输入真实姓名' }]}>
<Input prefix={<UserOutlined className="text-primary/60" />} placeholder="真实姓名" /> <Input prefix={<UserOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="真实姓名" />
</Form.Item> </Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}> <Form.Item name="password" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined className="text-primary/60" />} placeholder="设置密码" /> <Input.Password prefix={<LockOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="设置密码" />
</Form.Item> </Form.Item>
<Form.Item name="confirm_password" dependencies={['password']} <Form.Item name="confirm_password" dependencies={['password']}
rules={[{ required: true, message: '请确认密码' }, ({ getFieldValue }) => ({ rules={[{ required: true, message: '请确认密码' }, ({ getFieldValue }) => ({
...@@ -138,7 +192,7 @@ const RegisterPage: React.FC = () => { ...@@ -138,7 +192,7 @@ const RegisterPage: React.FC = () => {
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(new Error('两次输入的密码不一致'));
}, },
})]}> })]}>
<Input.Password prefix={<LockOutlined className="text-primary/60" />} placeholder="确认密码" /> <Input.Password prefix={<LockOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="确认密码" />
</Form.Item> </Form.Item>
<Row gutter={12}> <Row gutter={12}>
<Col span={12}> <Col span={12}>
...@@ -156,29 +210,38 @@ const RegisterPage: React.FC = () => { ...@@ -156,29 +210,38 @@ const RegisterPage: React.FC = () => {
</Col> </Col>
</Row> </Row>
<Form.Item> <Form.Item>
<Space className="w-full" direction="vertical"> <Space style={{ width: '100%' }} direction="vertical">
<Button type="primary" htmlType="submit" loading={loading} block <Button type="primary" htmlType="submit" loading={loading} block
className="h-10 rounded-lg font-semibold bg-linear-to-r from-[#1890ff] to-[#096dd9] border-none shadow-md shadow-blue-200">注册</Button> style={{
<Button block className="rounded-lg" onClick={() => setCurrentStep(0)}>上一步</Button> height: 40, borderRadius: 8, fontWeight: 600, border: 'none',
background: 'linear-gradient(90deg, #1890ff, #096dd9)',
boxShadow: '0 4px 12px rgba(24,144,255,0.3)',
}}>注册</Button>
<Button block style={{ borderRadius: 8 }} onClick={() => setCurrentStep(0)}>上一步</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div className="text-center py-8"> <div style={{ textAlign: 'center', padding: '32px 0' }}>
<div className="w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4"> <div style={{
<CheckCircleOutlined className="text-3xl text-green-500" /> width: 64, height: 64, borderRadius: '50%',
backgroundColor: '#f0fdf4',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 16px',
}}>
<CheckCircleOutlined style={{ fontSize: 30, color: '#22c55e' }} />
</div> </div>
<Title level={3} className="mt-4">注册成功!</Title> <Title level={3} style={{ marginTop: 16 }}>注册成功!</Title>
<Paragraph type="secondary">正在跳转到{registerType === 'doctor' ? '医生工作台' : '首页'}...</Paragraph> <Paragraph type="secondary">正在跳转到{registerType === 'doctor' ? '医生工作台' : '首页'}...</Paragraph>
</div> </div>
)} )}
{currentStep !== 2 && ( {currentStep !== 2 && (
<div className="text-center mt-4 text-sm"> <div style={{ textAlign: 'center', marginTop: 16, fontSize: 14 }}>
<span className="text-gray-400">已有账号?</span> <span style={{ color: '#8c8c8c' }}>已有账号?</span>
<Link href="/login" className="ml-1 font-medium">立即登录</Link> <Link href="/login" style={{ marginLeft: 4, fontWeight: 500, color: '#1890ff' }}>立即登录</Link>
</div> </div>
)} )}
</div> </div>
......
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useUserStore } from '@/store/userStore'; import { useUserStore } from '@/store/userStore';
const { Header, Content } = Layout; const { Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const menuItems = [ const menuItems = [
...@@ -35,6 +35,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ...@@ -35,6 +35,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const currentPath = pathname || '';
const userMenuItems = [ const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' }, { key: 'profile', icon: <UserOutlined />, label: '个人信息' },
...@@ -43,56 +44,72 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ...@@ -43,56 +44,72 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true }, { key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
]; ];
const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
router.push(key);
}
};
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
}; };
const getOpenKeys = () => { // 选中的菜单项(支持子路径匹配)
if (pathname.startsWith('/admin/patients') || pathname.startsWith('/admin/doctors') || pathname.startsWith('/admin/admins')) { const getSelectedKeys = () => {
return ['user-mgmt']; const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
} '/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
return []; '/admin/ai-config', '/admin/compliance'];
const match = allKeys.find(k => currentPath.startsWith(k));
return match ? [match] : [];
}; };
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <header
style={{ background: 'linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}> style={{
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
onClick={() => router.push('/admin/dashboard')}> height: 48, display: 'flex', alignItems: 'center', padding: '0 16px',
<MedicineBoxOutlined className="text-lg text-white" /> background: 'linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)',
<span className="text-sm font-bold text-white">互联网医院</span> boxShadow: '0 2px 8px rgba(0,21,41,0.15)',
<Tag color="geekblue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-400/30!">管理后台</Tag> }}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 16, flexShrink: 0 }}
onClick={() => router.push('/admin/dashboard')}
>
<MedicineBoxOutlined style={{ fontSize: 18, color: '#fff' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#fff' }}>互联网医院</span>
<Tag color="geekblue" style={{ marginLeft: 4, fontSize: 10, lineHeight: '18px', padding: '0 4px' }}>管理后台</Tag>
</div> </div>
<Menu <Menu
mode="horizontal" mode="horizontal"
selectedKeys={[pathname]} selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems} items={menuItems}
onClick={({ key }) => router.push(key)} onClick={handleMenuClick}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="top-nav-menu"
style={{ flex: 1, minWidth: 0 }}
/> />
<div className="flex items-center gap-3 shrink-0"> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<Badge count={2} size="small"> <Badge count={2} size="small">
<BellOutlined className="text-base text-white/70 cursor-pointer hover:text-white" /> <BellOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.7)', cursor: 'pointer' }} />
</Badge> </Badge>
{user ? ( {user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}> <Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space className="cursor-pointer"> <Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#722ed1' }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#722ed1' }} />
<Text className="text-white/90! text-xs!">{user.real_name || '管理员'}</Text> <Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: 12 }}>{user.real_name || '管理员'}</Text>
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)} )}
</div> </div>
</Header> </header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"> <Content style={{ marginTop: 48, padding: 16, minHeight: 'calc(100vh - 48px)', background: '#f0f5ff' }}>
{children} {children}
</Content> </Content>
</Layout> </Layout>
......
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useUserStore } from '@/store/userStore'; import { useUserStore } from '@/store/userStore';
const { Header, Content } = Layout; const { Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const menuItems = [ const menuItems = [
...@@ -29,6 +29,7 @@ export default function DoctorLayout({ children }: { children: React.ReactNode } ...@@ -29,6 +29,7 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const [isOnline, setIsOnline] = useState(false); const [isOnline, setIsOnline] = useState(false);
const currentPath = pathname || '';
const userMenuItems = [ const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' }, { key: 'profile', icon: <UserOutlined />, label: '个人信息' },
...@@ -37,50 +38,70 @@ export default function DoctorLayout({ children }: { children: React.ReactNode } ...@@ -37,50 +38,70 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true }, { key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
]; ];
const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
router.push(key);
}
};
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') router.push('/doctor/profile'); else if (key === 'profile') router.push('/doctor/profile');
}; };
const getSelectedKeys = () => {
const match = menuItems.find(item => currentPath.startsWith(item.key));
return match ? [match.key] : [];
};
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <header
style={{ background: 'linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}> style={{
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
onClick={() => router.push('/doctor/workbench')}> height: 48, display: 'flex', alignItems: 'center', padding: '0 16px',
<MedicineBoxOutlined className="text-lg text-white" /> background: 'linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)',
<span className="text-sm font-bold text-white">互联网医院</span> boxShadow: '0 2px 8px rgba(0,21,41,0.15)',
<Tag color="green" className="ml-1! text-[10px]! leading-4! px-1!">医生工作站</Tag> }}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 16, flexShrink: 0 }}
onClick={() => router.push('/doctor/workbench')}
>
<MedicineBoxOutlined style={{ fontSize: 18, color: '#fff' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#fff' }}>互联网医院</span>
<Tag color="green" style={{ marginLeft: 4, fontSize: 10, lineHeight: '18px', padding: '0 4px' }}>医生工作站</Tag>
</div> </div>
<Menu <Menu
mode="horizontal" mode="horizontal"
selectedKeys={[pathname]} selectedKeys={getSelectedKeys()}
items={menuItems} items={menuItems}
onClick={({ key }) => router.push(key)} onClick={handleMenuClick}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="top-nav-menu"
style={{ flex: 1, minWidth: 0 }}
/> />
<div className="flex items-center gap-3 shrink-0"> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<Switch checked={isOnline} onChange={setIsOnline} checkedChildren="在线" unCheckedChildren="离线" size="small" /> <Switch checked={isOnline} onChange={setIsOnline} checkedChildren="在线" unCheckedChildren="离线" size="small" />
<Badge count={5} size="small"> <Badge count={5} size="small">
<BellOutlined className="text-base text-white/70 cursor-pointer hover:text-white" /> <BellOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.7)', cursor: 'pointer' }} />
</Badge> </Badge>
{user ? ( {user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}> <Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space className="cursor-pointer"> <Space style={{ cursor: 'pointer' }}>
<Avatar size="small" src={user.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} /> <Avatar size="small" src={user.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} />
<Text className="text-white/90! text-xs!">{user.real_name || user.phone}</Text> <Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: 12 }}>{user.real_name || user.phone}</Text>
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)} )}
</div> </div>
</Header> </header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"> <Content style={{ marginTop: 48, padding: 16, minHeight: 'calc(100vh - 48px)', background: '#f0f5ff' }}>
{children} {children}
</Content> </Content>
</Layout> </Layout>
......
export default function MainLayout({ children }: { children: React.ReactNode }) { export default function MainLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <div className="compact-ui">{children}</div>;
} }
...@@ -11,7 +11,7 @@ import { ...@@ -11,7 +11,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useUserStore } from '@/store/userStore'; import { useUserStore } from '@/store/userStore';
const { Header, Content } = Layout; const { Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const menuItems = [ const menuItems = [
...@@ -31,6 +31,7 @@ export default function PatientLayout({ children }: { children: React.ReactNode ...@@ -31,6 +31,7 @@ export default function PatientLayout({ children }: { children: React.ReactNode
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const currentPath = pathname || '';
const userMenuItems = [ const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人中心' }, { key: 'profile', icon: <UserOutlined />, label: '个人中心' },
...@@ -39,49 +40,69 @@ export default function PatientLayout({ children }: { children: React.ReactNode ...@@ -39,49 +40,69 @@ export default function PatientLayout({ children }: { children: React.ReactNode
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true }, { key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
]; ];
const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
router.push(key);
}
};
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') router.push('/patient/profile'); else if (key === 'profile') router.push('/patient/profile');
}; };
const getSelectedKeys = () => {
const match = menuItems.find(item => currentPath.startsWith(item.key));
return match ? [match.key] : [];
};
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <header
style={{ background: 'linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.12)', padding: '0 16px' }}> style={{
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
onClick={() => router.push('/patient/home')}> height: 48, display: 'flex', alignItems: 'center', padding: '0 16px',
<MedicineBoxOutlined className="text-lg text-white" /> background: 'linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)',
<span className="text-sm font-bold text-white">互联网医院</span> boxShadow: '0 2px 8px rgba(0,21,41,0.12)',
<Tag color="blue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-300/30!">患者端</Tag> }}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 16, flexShrink: 0 }}
onClick={() => router.push('/patient/home')}
>
<MedicineBoxOutlined style={{ fontSize: 18, color: '#fff' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#fff' }}>互联网医院</span>
<Tag color="blue" style={{ marginLeft: 4, fontSize: 10, lineHeight: '18px', padding: '0 4px' }}>患者端</Tag>
</div> </div>
<Menu <Menu
mode="horizontal" mode="horizontal"
selectedKeys={[pathname]} selectedKeys={getSelectedKeys()}
items={menuItems} items={menuItems}
onClick={({ key }) => router.push(key)} onClick={handleMenuClick}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="top-nav-menu"
style={{ flex: 1, minWidth: 0 }}
/> />
<div className="flex items-center gap-3 shrink-0"> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<Badge count={3} size="small"> <Badge count={3} size="small">
<BellOutlined className="text-base text-white/70 cursor-pointer hover:text-white" /> <BellOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.7)', cursor: 'pointer' }} />
</Badge> </Badge>
{user ? ( {user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}> <Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space className="cursor-pointer"> <Space style={{ cursor: 'pointer' }}>
<Avatar size="small" src={user.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} /> <Avatar size="small" src={user.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<Text className="text-white/90! text-xs!">{user.real_name || user.phone}</Text> <Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: 12 }}>{user.real_name || user.phone}</Text>
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录 / 注册</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录 / 注册</Button>
)} )}
</div> </div>
</Header> </header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"> <Content style={{ marginTop: 48, padding: 16, minHeight: 'calc(100vh - 48px)', background: '#f0f5ff' }}>
{children} {children}
</Content> </Content>
</Layout> </Layout>
......
/* 将 antd CSS-in-JS 层声明在最前,使其优先级低于 Tailwind utilities */ /*
@layer antd; * CSS Layer 顺序: base(preflight) < antd(组件样式) < utilities(工具类)
*
@import "tailwindcss"; * 关键: antd 层在 base 和 utilities 之间,确保:
* - Antd 组件样式能覆盖 Tailwind 的 CSS 重置 (preflight)
* - Tailwind 工具类能覆盖 Antd 默认样式
*/
@layer base, antd, utilities;
@import "tailwindcss/preflight" layer(base);
@import "tailwindcss/utilities" layer(utilities);
@theme inline { @theme inline {
--color-primary: #1890ff; --color-primary: #1890ff;
...@@ -20,13 +27,9 @@ ...@@ -20,13 +27,9 @@
--color-border: #d9e4f5; --color-border: #d9e4f5;
} }
* { /* ===== 全局基础样式(无层级,最高优先级)===== */
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #__next { html, body {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 100vh; min-height: 100vh;
...@@ -36,7 +39,7 @@ body { ...@@ -36,7 +39,7 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji'; 'Noto Color Emoji';
font-size: 13px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: #1d2129; color: #1d2129;
background-color: #f0f5ff; background-color: #f0f5ff;
...@@ -53,121 +56,145 @@ a:hover { ...@@ -53,121 +56,145 @@ a:hover {
color: #40a9ff; color: #40a9ff;
} }
/* ===== Ant Design 全局紧凑覆盖 ===== */ /* ===== 滚动条 ===== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f0f5ff;
}
::-webkit-scrollbar-thumb {
background: #bbd4f0;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #91c2f0;
}
/* ===== 顶部导航栏菜单样式 ===== */
.top-nav-menu.ant-menu-horizontal {
border-bottom: none !important;
background: transparent !important;
line-height: 48px !important;
}
.top-nav-menu .ant-menu-item,
.top-nav-menu .ant-menu-submenu-title {
color: rgba(187, 213, 255, 0.8) !important;
font-size: 13px !important;
padding: 0 12px !important;
margin: 0 2px !important;
border-radius: 6px !important;
}
.top-nav-menu .ant-menu-item:hover,
.top-nav-menu .ant-menu-submenu-title:hover,
.top-nav-menu .ant-menu-item-active,
.top-nav-menu .ant-menu-submenu-active > .ant-menu-submenu-title {
color: #fff !important;
background: rgba(255, 255, 255, 0.1) !important;
}
.top-nav-menu .ant-menu-item-selected {
color: #fff !important;
background: rgba(255, 255, 255, 0.15) !important;
}
.top-nav-menu .ant-menu-item::after,
.top-nav-menu .ant-menu-submenu::after {
display: none !important;
}
/* 子菜单弹出样式 */
.ant-menu-submenu-popup .ant-menu {
border-radius: 8px !important;
}
/* ===== Ant Design 紧凑覆盖(仅作用于 .compact-ui 内部) ===== */
.ant-card { .compact-ui .ant-card {
border-radius: 8px !important; border-radius: 8px !important;
box-shadow: 0 1px 4px rgba(24, 144, 255, 0.06) !important; box-shadow: 0 1px 4px rgba(24, 144, 255, 0.06) !important;
border: 1px solid #e6f0fa !important; border: 1px solid #e6f0fa !important;
} }
.ant-card .ant-card-head { .compact-ui .ant-card .ant-card-head {
min-height: 36px !important; min-height: 40px !important;
padding: 0 12px !important; padding: 0 16px !important;
font-size: 13px !important; font-size: 14px !important;
border-bottom: 1px solid #e6f0fa !important; border-bottom: 1px solid #e6f0fa !important;
} }
.ant-card .ant-card-body { .compact-ui .ant-card .ant-card-body {
padding: 12px !important; padding: 16px !important;
} }
.ant-table { .compact-ui .ant-table {
font-size: 13px !important; font-size: 13px !important;
} }
.ant-table-thead > tr > th, .compact-ui .ant-table-thead > tr > th,
.ant-table-thead > tr > td { .compact-ui .ant-table-thead > tr > td {
padding: 8px 12px !important; padding: 10px 12px !important;
background: #f0f7ff !important; background: #f0f7ff !important;
font-weight: 600 !important; font-weight: 600 !important;
font-size: 12px !important; font-size: 13px !important;
color: #1d2129 !important; color: #1d2129 !important;
} }
.ant-table-tbody > tr > td { .compact-ui .ant-table-tbody > tr > td {
padding: 6px 12px !important; padding: 8px 12px !important;
} }
.ant-table-tbody > tr:hover > td { .compact-ui .ant-table-tbody > tr:hover > td {
background: #e6f7ff !important; background: #e6f7ff !important;
} }
.ant-form-item { .compact-ui .ant-form-item {
margin-bottom: 12px !important; margin-bottom: 16px !important;
} }
.ant-form-item-label > label { .compact-ui .ant-form-item-label > label {
font-size: 12px !important; font-size: 13px !important;
color: #4e5969 !important; color: #4e5969 !important;
} }
.ant-modal .ant-modal-header { .compact-ui .ant-modal .ant-modal-header {
padding: 12px 16px !important; padding: 16px 20px !important;
border-bottom: 1px solid #e6f0fa !important; border-bottom: 1px solid #e6f0fa !important;
} }
.ant-modal .ant-modal-body { .compact-ui .ant-modal .ant-modal-body {
padding: 12px 16px !important; padding: 16px 20px !important;
} }
.ant-modal .ant-modal-footer { .compact-ui .ant-modal .ant-modal-footer {
padding: 8px 16px !important; padding: 12px 20px !important;
} }
.ant-tabs .ant-tabs-tab { .compact-ui .ant-statistic .ant-statistic-title {
padding: 8px 12px !important;
font-size: 13px !important; font-size: 13px !important;
margin-bottom: 4px !important;
} }
.compact-ui .ant-statistic .ant-statistic-content {
.ant-statistic .ant-statistic-title { font-size: 22px !important;
font-size: 12px !important;
margin-bottom: 2px !important;
}
.ant-statistic .ant-statistic-content {
font-size: 20px !important;
} }
.ant-descriptions .ant-descriptions-item-label { .compact-ui .ant-descriptions .ant-descriptions-item-label {
font-size: 12px !important; font-size: 13px !important;
padding: 6px 12px !important; padding: 8px 12px !important;
background: #f0f7ff !important; background: #f0f7ff !important;
} }
.ant-descriptions .ant-descriptions-item-content { .compact-ui .ant-descriptions .ant-descriptions-item-content {
font-size: 13px !important; font-size: 13px !important;
padding: 6px 12px !important; padding: 8px 12px !important;
} }
.ant-tag { .compact-ui .ant-tag {
font-size: 11px !important;
padding: 0 6px !important;
line-height: 20px !important;
border-radius: 4px !important; border-radius: 4px !important;
} }
.ant-badge .ant-badge-count { .compact-ui .ant-input,
font-size: 10px !important; .compact-ui .ant-input-affix-wrapper,
} .compact-ui .ant-select-selector {
.ant-btn-sm {
font-size: 12px !important;
padding: 0 8px !important;
height: 26px !important;
}
.ant-input, .ant-input-affix-wrapper, .ant-select-selector {
border-radius: 6px !important; border-radius: 6px !important;
} }
.ant-alert { .compact-ui .ant-alert {
padding: 6px 12px !important;
border-radius: 6px !important; border-radius: 6px !important;
font-size: 12px !important;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f0f5ff;
}
::-webkit-scrollbar-thumb {
background: #bbd4f0;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #91c2f0;
} }
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#1677ff"/>
<path d="M16 6v20M6 16h20" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
</svg>
'use client'; 'use client';
import '@ant-design/v5-patch-for-react-19';
import React from 'react'; import React from 'react';
import { ConfigProvider, App as AntdApp } from 'antd'; import { ConfigProvider, App as AntdApp } from 'antd';
import { StyleProvider } from '@ant-design/cssinjs'; import { StyleProvider } from '@ant-design/cssinjs';
......
import React from 'react'; 'use client';
import { Navigate, useLocation } from 'react-router-dom';
import React, { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useUserStore } from '../../store/userStore'; import { useUserStore } from '../../store/userStore';
import type { UserRole } from '../../api/user'; import type { UserRole } from '../../api/user';
...@@ -15,24 +17,41 @@ interface AuthGuardProps { ...@@ -15,24 +17,41 @@ interface AuthGuardProps {
*/ */
const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRole }) => { const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRole }) => {
const { isAuthenticated, user } = useUserStore(); const { isAuthenticated, user } = useUserStore();
const location = useLocation(); const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isAuthenticated || !user) {
router.replace('/login');
return;
}
if (requiredRole) {
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
if (!roles.includes(user.role as UserRole)) {
switch (user.role) {
case 'doctor':
router.replace('/doctor/workbench');
break;
case 'admin':
router.replace('/admin/dashboard');
break;
default:
router.replace('/patient/home');
break;
}
}
}
}, [isAuthenticated, user, requiredRole, router, pathname]);
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) {
return <Navigate to="/login" state={{ from: location }} replace />; return null;
} }
if (requiredRole) { if (requiredRole) {
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
if (!roles.includes(user.role as UserRole)) { if (!roles.includes(user.role as UserRole)) {
// 角色不匹配,重定向到其对应端首页 return null;
switch (user.role) {
case 'doctor':
return <Navigate to="/doctor/workbench" replace />;
case 'admin':
return <Navigate to="/admin/dashboard" replace />;
default:
return <Navigate to="/patient/home" replace />;
}
} }
} }
......
'use client';
import React from 'react'; import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd'; import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd';
import { import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined, DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
...@@ -29,9 +31,9 @@ const menuItems = [ ...@@ -29,9 +31,9 @@ const menuItems = [
{ key: '/admin/compliance', icon: <SafetyCertificateOutlined />, label: '合规报表' }, { key: '/admin/compliance', icon: <SafetyCertificateOutlined />, label: '合规报表' },
]; ];
const AdminLayout: React.FC = () => { const AdminLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const navigate = useNavigate(); const router = useRouter();
const location = useLocation(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const userMenuItems = [ const userMenuItems = [
...@@ -42,13 +44,13 @@ const AdminLayout: React.FC = () => { ...@@ -42,13 +44,13 @@ const AdminLayout: React.FC = () => {
]; ];
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); navigate('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
}; };
const getSelectedKeys = () => [location.pathname]; const currentPath = pathname || '';
const getSelectedKeys = () => [currentPath];
const getOpenKeys = () => { const getOpenKeys = () => {
const p = location.pathname; if (currentPath.startsWith('/admin/patients') || currentPath.startsWith('/admin/doctors') || currentPath.startsWith('/admin/admins')) {
if (p.startsWith('/admin/patients') || p.startsWith('/admin/doctors') || p.startsWith('/admin/admins')) {
return ['user-mgmt']; return ['user-mgmt'];
} }
return []; return [];
...@@ -59,7 +61,7 @@ const AdminLayout: React.FC = () => { ...@@ -59,7 +61,7 @@ const AdminLayout: React.FC = () => {
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style={{ background: 'linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}> style={{ background: 'linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}>
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" <div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick={() => navigate('/admin/dashboard')}> onClick={() => router.push('/admin/dashboard')}>
<MedicineBoxOutlined className="text-lg text-white" /> <MedicineBoxOutlined className="text-lg text-white" />
<span className="text-sm font-bold text-white">互联网医院</span> <span className="text-sm font-bold text-white">互联网医院</span>
<Tag color="geekblue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-400/30!">管理后台</Tag> <Tag color="geekblue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-400/30!">管理后台</Tag>
...@@ -70,7 +72,7 @@ const AdminLayout: React.FC = () => { ...@@ -70,7 +72,7 @@ const AdminLayout: React.FC = () => {
selectedKeys={getSelectedKeys()} selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()} defaultOpenKeys={getOpenKeys()}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => router.push(key)}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/> />
...@@ -87,13 +89,13 @@ const AdminLayout: React.FC = () => { ...@@ -87,13 +89,13 @@ const AdminLayout: React.FC = () => {
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => navigate('/login')}>登录</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)} )}
</div> </div>
</Header> </Header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"> <Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!">
<Outlet /> {children}
</Content> </Content>
</Layout> </Layout>
); );
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Switch, Typography, Tag } from 'antd'; import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Switch, Typography, Tag } from 'antd';
import { import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined, DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
...@@ -22,9 +24,9 @@ const menuItems = [ ...@@ -22,9 +24,9 @@ const menuItems = [
{ key: '/doctor/profile', icon: <UserOutlined />, label: '个人信息' }, { key: '/doctor/profile', icon: <UserOutlined />, label: '个人信息' },
]; ];
const DoctorLayout: React.FC = () => { const DoctorLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const navigate = useNavigate(); const router = useRouter();
const location = useLocation(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const [isOnline, setIsOnline] = useState(false); const [isOnline, setIsOnline] = useState(false);
...@@ -36,8 +38,8 @@ const DoctorLayout: React.FC = () => { ...@@ -36,8 +38,8 @@ const DoctorLayout: React.FC = () => {
]; ];
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); navigate('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') navigate('/doctor/profile'); else if (key === 'profile') router.push('/doctor/profile');
}; };
return ( return (
...@@ -45,7 +47,7 @@ const DoctorLayout: React.FC = () => { ...@@ -45,7 +47,7 @@ const DoctorLayout: React.FC = () => {
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style={{ background: 'linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}> style={{ background: 'linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.15)', padding: '0 16px' }}>
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" <div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick={() => navigate('/doctor/workbench')}> onClick={() => router.push('/doctor/workbench')}>
<MedicineBoxOutlined className="text-lg text-white" /> <MedicineBoxOutlined className="text-lg text-white" />
<span className="text-sm font-bold text-white">互联网医院</span> <span className="text-sm font-bold text-white">互联网医院</span>
<Tag color="green" className="ml-1! text-[10px]! leading-4! px-1!">医生工作站</Tag> <Tag color="green" className="ml-1! text-[10px]! leading-4! px-1!">医生工作站</Tag>
...@@ -53,9 +55,9 @@ const DoctorLayout: React.FC = () => { ...@@ -53,9 +55,9 @@ const DoctorLayout: React.FC = () => {
<Menu <Menu
mode="horizontal" mode="horizontal"
selectedKeys={[location.pathname]} selectedKeys={[pathname || '']}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => router.push(key)}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/> />
...@@ -73,13 +75,13 @@ const DoctorLayout: React.FC = () => { ...@@ -73,13 +75,13 @@ const DoctorLayout: React.FC = () => {
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => navigate('/login')}>登录</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)} )}
</div> </div>
</Header> </Header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"> <Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!">
<Outlet /> {children}
</Content> </Content>
</Layout> </Layout>
); );
......
'use client';
import React from 'react'; import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Space, Badge } from 'antd'; import { Layout, Menu, Avatar, Dropdown, Button, Space, Badge } from 'antd';
import { import {
HomeOutlined, HomeOutlined,
...@@ -16,9 +18,9 @@ import { useUserStore } from '../../store/userStore'; ...@@ -16,9 +18,9 @@ import { useUserStore } from '../../store/userStore';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => { const MainLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const navigate = useNavigate(); const router = useRouter();
const location = useLocation(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const menuItems = [ const menuItems = [
...@@ -40,11 +42,11 @@ const MainLayout: React.FC = () => { ...@@ -40,11 +42,11 @@ const MainLayout: React.FC = () => {
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { if (key === 'logout') {
logout(); logout();
navigate('/login'); router.push('/login');
} else if (key === 'profile') { } else if (key === 'profile') {
navigate('/profile'); router.push('/profile');
} else if (key === 'settings') { } else if (key === 'settings') {
navigate('/settings'); router.push('/settings');
} }
}; };
...@@ -68,7 +70,7 @@ const MainLayout: React.FC = () => { ...@@ -68,7 +70,7 @@ const MainLayout: React.FC = () => {
> >
<div <div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => navigate('/home')} onClick={() => router.push('/home')}
> >
<MedicineBoxOutlined style={{ fontSize: 26, color: '#1677ff' }} /> <MedicineBoxOutlined style={{ fontSize: 26, color: '#1677ff' }} />
<span style={{ fontSize: 17, fontWeight: 700, color: '#1677ff', letterSpacing: 1 }}> <span style={{ fontSize: 17, fontWeight: 700, color: '#1677ff', letterSpacing: 1 }}>
...@@ -89,8 +91,8 @@ const MainLayout: React.FC = () => { ...@@ -89,8 +91,8 @@ const MainLayout: React.FC = () => {
</Dropdown> </Dropdown>
) : ( ) : (
<Space> <Space>
<Button onClick={() => navigate('/login')}>登录</Button> <Button onClick={() => router.push('/login')}>登录</Button>
<Button type="primary" onClick={() => navigate('/register')}>注册</Button> <Button type="primary" onClick={() => router.push('/register')}>注册</Button>
</Space> </Space>
)} )}
</Space> </Space>
...@@ -110,10 +112,10 @@ const MainLayout: React.FC = () => { ...@@ -110,10 +112,10 @@ const MainLayout: React.FC = () => {
> >
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={[pathname || '']}
style={{ height: '100%', borderRight: 0, paddingTop: 8 }} style={{ height: '100%', borderRight: 0, paddingTop: 8 }}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => router.push(key)}
/> />
</Sider> </Sider>
...@@ -126,7 +128,7 @@ const MainLayout: React.FC = () => { ...@@ -126,7 +128,7 @@ const MainLayout: React.FC = () => {
overflow: 'auto', overflow: 'auto',
}} }}
> >
<Outlet /> {children}
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
......
'use client';
import React from 'react'; import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd'; import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd';
import { import {
HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined, HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined,
...@@ -25,9 +27,9 @@ const menuItems = [ ...@@ -25,9 +27,9 @@ const menuItems = [
{ key: '/patient/profile', icon: <UserOutlined />, label: '个人中心' }, { key: '/patient/profile', icon: <UserOutlined />, label: '个人中心' },
]; ];
const PatientLayout: React.FC = () => { const PatientLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const navigate = useNavigate(); const router = useRouter();
const location = useLocation(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const userMenuItems = [ const userMenuItems = [
...@@ -38,8 +40,8 @@ const PatientLayout: React.FC = () => { ...@@ -38,8 +40,8 @@ const PatientLayout: React.FC = () => {
]; ];
const handleUserMenuClick = ({ key }: { key: string }) => { const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); navigate('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') navigate('/patient/profile'); else if (key === 'profile') router.push('/patient/profile');
}; };
return ( return (
...@@ -47,7 +49,7 @@ const PatientLayout: React.FC = () => { ...@@ -47,7 +49,7 @@ const PatientLayout: React.FC = () => {
<Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!" <Header className="fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style={{ background: 'linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.12)', padding: '0 16px' }}> style={{ background: 'linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.12)', padding: '0 16px' }}>
<div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0" <div className="flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick={() => navigate('/patient/home')}> onClick={() => router.push('/patient/home')}>
<MedicineBoxOutlined className="text-lg text-white" /> <MedicineBoxOutlined className="text-lg text-white" />
<span className="text-sm font-bold text-white">互联网医院</span> <span className="text-sm font-bold text-white">互联网医院</span>
<Tag color="blue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-300/30!">患者端</Tag> <Tag color="blue" className="ml-1! text-[10px]! leading-4! px-1! border-blue-300/30!">患者端</Tag>
...@@ -55,9 +57,9 @@ const PatientLayout: React.FC = () => { ...@@ -55,9 +57,9 @@ const PatientLayout: React.FC = () => {
<Menu <Menu
mode="horizontal" mode="horizontal"
selectedKeys={[location.pathname]} selectedKeys={[pathname || '']}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => router.push(key)}
theme="dark" theme="dark"
className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!" className="flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/> />
...@@ -74,13 +76,13 @@ const PatientLayout: React.FC = () => { ...@@ -74,13 +76,13 @@ const PatientLayout: React.FC = () => {
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : (
<Button size="small" type="primary" onClick={() => navigate('/login')}>登录 / 注册</Button> <Button size="small" type="primary" onClick={() => router.push('/login')}>登录 / 注册</Button>
)} )}
</div> </div>
</Header> </Header>
<Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"> <Content className="mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!">
<Outlet /> {children}
</Content> </Content>
</Layout> </Layout>
); );
......
import React from 'react'; 'use client';
import { Navigate } from 'react-router-dom';
import React, { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useUserStore } from '../../store/userStore'; import { useUserStore } from '../../store/userStore';
interface RoleRedirectProps { interface RoleRedirectProps {
...@@ -12,19 +14,28 @@ interface RoleRedirectProps { ...@@ -12,19 +14,28 @@ interface RoleRedirectProps {
*/ */
const RoleRedirect: React.FC<RoleRedirectProps> = () => { const RoleRedirect: React.FC<RoleRedirectProps> = () => {
const { isAuthenticated, user } = useUserStore(); const { isAuthenticated, user } = useUserStore();
const router = useRouter();
useEffect(() => {
if (!isAuthenticated || !user) {
router.replace('/login');
return;
}
if (!isAuthenticated || !user) { switch (user.role) {
return <Navigate to="/login" replace />; case 'doctor':
} router.replace('/doctor/workbench');
break;
case 'admin':
router.replace('/admin/dashboard');
break;
default:
router.replace('/patient/home');
break;
}
}, [isAuthenticated, user, router]);
switch (user.role) { return null;
case 'doctor':
return <Navigate to="/doctor/workbench" replace />;
case 'admin':
return <Navigate to="/admin/dashboard" replace />;
default:
return <Navigate to="/patient/home" replace />;
}
}; };
export default RoleRedirect; export default RoleRedirect;
'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Table, Input, Button, Space, Tag, Avatar, Modal, message, Typography, Form } from 'antd'; import { Card, Table, Input, Button, Space, Tag, Avatar, Modal, message, Typography, Form } from 'antd';
import { SearchOutlined, UserOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { SearchOutlined, UserOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
......
import React, { useState } from 'react'; 'use client';
import React, { useState, useEffect } from 'react';
import { import {
Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic, Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic,
Button, Input, DatePicker, Select, Modal, message, Descriptions, Timeline, Button, Input, DatePicker, Select, Modal, message, Spin,
} from 'antd'; } from 'antd';
import { import {
AuditOutlined, AuditOutlined,
...@@ -9,26 +11,16 @@ import { ...@@ -9,26 +11,16 @@ import {
DownloadOutlined, DownloadOutlined,
CloudUploadOutlined, CloudUploadOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
ClockCircleOutlined,
UserOutlined, UserOutlined,
SettingOutlined, SettingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { adminApi, type SystemLog } from '../../../api/admin';
import type { PaginatedResponse } from '../../../types';
import dayjs from 'dayjs';
const { Title, Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
interface AuditLog {
id: string;
timestamp: string;
user: string;
role: string;
action: string;
module: string;
ip: string;
detail: string;
level: 'info' | 'warning' | 'danger';
}
interface ReportItem { interface ReportItem {
id: string; id: string;
name: string; name: string;
...@@ -39,39 +31,49 @@ interface ReportItem { ...@@ -39,39 +31,49 @@ interface ReportItem {
submitted_at?: string; submitted_at?: string;
} }
const mockLogs: AuditLog[] = [
{ id: '1', timestamp: '2026-02-25 18:30:15', user: '管理员', role: 'admin', action: '审核通过', module: '医生管理', ip: '192.168.1.100', detail: '审核通过医生周医生的资质认证申请', level: 'info' },
{ id: '2', timestamp: '2026-02-25 17:45:22', user: '陈医生', role: 'doctor', action: '开具处方', module: '处方管理', ip: '192.168.1.105', detail: '为患者张三开具处方 RX20260225001', level: 'info' },
{ id: '3', timestamp: '2026-02-25 16:20:08', user: '管理员', role: 'admin', action: '修改配置', module: 'AI配置', ip: '192.168.1.100', detail: '修改 AI 模型 temperature 参数从 0.5 到 0.3', level: 'warning' },
{ id: '4', timestamp: '2026-02-25 15:10:33', user: '未知用户', role: 'unknown', action: '登录失败', module: '系统安全', ip: '10.0.0.55', detail: '连续3次密码错误,账户已临时锁定', level: 'danger' },
{ id: '5', timestamp: '2026-02-25 14:05:11', user: '管理员', role: 'admin', action: '数据导出', module: '数据管理', ip: '192.168.1.100', detail: '导出本月问诊数据报表', level: 'info' },
{ id: '6', timestamp: '2026-02-25 11:30:45', user: '刘医生', role: 'doctor', action: '异常处方', module: '处方监管', ip: '192.168.1.108', detail: '处方 RX20260225004 被系统标记为异常:抗生素使用不符合指南', level: 'danger' },
];
const mockReports: ReportItem[] = [
{ id: '1', name: '2026年2月运营月报', type: '月度报告', period: '2026-02', status: 'pending' },
{ id: '2', name: '2026年1月运营月报', type: '月度报告', period: '2026-01', status: 'submitted', generated_at: '2026-02-05', submitted_at: '2026-02-10' },
{ id: '3', name: '2025Q4季度合规报告', type: '季度报告', period: '2025-Q4', status: 'submitted', generated_at: '2026-01-10', submitted_at: '2026-01-15' },
{ id: '4', name: '处方用药合规专项报告', type: '专项报告', period: '2026-02', status: 'generated', generated_at: '2026-02-20' },
{ id: '5', name: '等保三级自查报告', type: '安全报告', period: '2026-02', status: 'pending' },
];
const AdminCompliancePage: React.FC = () => { const AdminCompliancePage: React.FC = () => {
const [exportModalVisible, setExportModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false);
const [logs, setLogs] = useState<SystemLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsTotal, setLogsTotal] = useState(0);
const [logsPage, setLogsPage] = useState(1);
const reports: ReportItem[] = [
{ id: '1', name: `${dayjs().format('YYYY')}年${dayjs().format('M')}月运营月报`, type: '月度报告', period: dayjs().format('YYYY-MM'), status: 'pending' },
{ id: '2', name: `${dayjs().subtract(1, 'month').format('YYYY')}年${dayjs().subtract(1, 'month').format('M')}月运营月报`, type: '月度报告', period: dayjs().subtract(1, 'month').format('YYYY-MM'), status: 'submitted', generated_at: dayjs().subtract(20, 'day').format('YYYY-MM-DD'), submitted_at: dayjs().subtract(15, 'day').format('YYYY-MM-DD') },
{ id: '3', name: '处方用药合规专项报告', type: '专项报告', period: dayjs().format('YYYY-MM'), status: 'generated', generated_at: dayjs().subtract(5, 'day').format('YYYY-MM-DD') },
{ id: '4', name: '等保三级自查报告', type: '安全报告', period: dayjs().format('YYYY-MM'), status: 'pending' },
];
const fetchLogs = async (page = 1) => {
setLogsLoading(true);
try {
const res = await adminApi.getSystemLogs({ page, page_size: 10 });
setLogs(res.data.list || []);
setLogsTotal(res.data.total || 0);
} catch {
message.error('获取审计日志失败');
} finally {
setLogsLoading(false);
}
};
useEffect(() => {
fetchLogs();
}, []);
const handleExport = () => { const handleExport = () => {
message.success('数据导出任务已提交,请稍后在下载中心查看'); message.success('数据导出任务已提交,请稍后在下载中心查看');
setExportModalVisible(false); setExportModalVisible(false);
}; };
const getLevelTag = (level: string) => { const logColumns = [
switch (level) { { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') },
case 'info': return <Tag color="blue">信息</Tag>; { title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 100, ellipsis: true },
case 'warning': return <Tag color="orange">警告</Tag>; { title: '操作', dataIndex: 'action', key: 'action', width: 120 },
case 'danger': return <Tag color="red">危险</Tag>; { title: '资源', dataIndex: 'resource', key: 'resource', width: 120 },
default: return <Tag>{level}</Tag>; { title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
} ];
};
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
switch (status) { switch (status) {
...@@ -82,23 +84,6 @@ const AdminCompliancePage: React.FC = () => { ...@@ -82,23 +84,6 @@ const AdminCompliancePage: React.FC = () => {
} }
}; };
const logColumns = [
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 170 },
{ title: '用户', dataIndex: 'user', key: 'user', width: 80 },
{
title: '角色', dataIndex: 'role', key: 'role', width: 80,
render: (v: string) => {
const map: Record<string, string> = { admin: '管理员', doctor: '医生', patient: '患者', unknown: '未知' };
return <Tag>{map[v] || v}</Tag>;
},
},
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
{ title: '模块', dataIndex: 'module', key: 'module', width: 100 },
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true },
{ title: '级别', dataIndex: 'level', key: 'level', width: 80, render: (v: string) => getLevelTag(v) },
];
const reportColumns = [ const reportColumns = [
{ title: '报告名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> }, { title: '报告名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="purple">{v}</Tag> }, { title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="purple">{v}</Tag> },
...@@ -132,22 +117,22 @@ const AdminCompliancePage: React.FC = () => { ...@@ -132,22 +117,22 @@ const AdminCompliancePage: React.FC = () => {
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col span={6}> <Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #1890ff' }}> <Card size="small" style={{ borderLeft: '3px solid #1890ff' }}>
<Statistic title="审计日志" value={mockLogs.length} prefix={<FileSearchOutlined />} suffix="条" /> <Statistic title="审计日志" value={logsTotal} prefix={<FileSearchOutlined />} suffix="条" />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}> <Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}>
<Statistic title="安全告警" value={mockLogs.filter((l) => l.level === 'danger').length} valueStyle={{ color: '#f5222d' }} suffix="条" /> <Statistic title="待处理" value={reports.filter((r) => r.status === 'pending').length} valueStyle={{ color: '#fa8c16' }} suffix="项" />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #52c41a' }}> <Card size="small" style={{ borderLeft: '3px solid #52c41a' }}>
<Statistic title="已上报" value={mockReports.filter((r) => r.status === 'submitted').length} suffix="条" /> <Statistic title="已上报" value={reports.filter((r) => r.status === 'submitted').length} suffix="项" />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #722ed1' }}> <Card size="small" style={{ borderLeft: '3px solid #722ed1' }}>
<Statistic title="待生成" value={mockReports.filter((r) => r.status === 'pending').length} valueStyle={{ color: '#fa8c16' }} suffix="条" /> <Statistic title="已生成" value={reports.filter((r) => r.status === 'generated').length} suffix="项" />
</Card> </Card>
</Col> </Col>
</Row> </Row>
...@@ -159,18 +144,13 @@ const AdminCompliancePage: React.FC = () => { ...@@ -159,18 +144,13 @@ const AdminCompliancePage: React.FC = () => {
children: ( children: (
<Card size="small"> <Card size="small">
<div className="flex flex-wrap items-center gap-2 mb-2"> <div className="flex flex-wrap items-center gap-2 mb-2">
<Input placeholder="搜索用户/操作" style={{ width: 160 }} size="small" allowClear /> <Input placeholder="搜索操作内容" style={{ width: 160 }} size="small" allowClear />
<Select placeholder="级别" style={{ width: 80 }} size="small" allowClear options={[
{ label: '信息', value: 'info' }, { label: '警告', value: 'warning' }, { label: '危险', value: 'danger' },
]} />
<Select placeholder="模块" style={{ width: 100 }} size="small" allowClear options={[
{ label: '医生管理', value: '医生管理' }, { label: '处方管理', value: '处方管理' },
{ label: '系统安全', value: '系统安全' }, { label: 'AI配置', value: 'AI配置' },
]} />
<RangePicker size="small" /> <RangePicker size="small" />
<Button type="primary" size="small" ghost>查询</Button> <Button type="primary" size="small" ghost onClick={() => fetchLogs(1)}>查询</Button>
</div> </div>
<Table columns={logColumns} dataSource={mockLogs} rowKey="id" size="small" pagination={{ pageSize: 10, size: 'small' }} /> <Table columns={logColumns} dataSource={logs} rowKey="id" size="small" loading={logsLoading}
pagination={{ current: logsPage, pageSize: 10, total: logsTotal, size: 'small',
onChange: (p) => { setLogsPage(p); fetchLogs(p); } }} />
</Card> </Card>
), ),
}, },
...@@ -207,7 +187,7 @@ const AdminCompliancePage: React.FC = () => { ...@@ -207,7 +187,7 @@ const AdminCompliancePage: React.FC = () => {
<span className="text-xs text-gray-400">管理合规报告,支持下载和上报</span> <span className="text-xs text-gray-400">管理合规报告,支持下载和上报</span>
<Button type="primary" size="small">生成新报告</Button> <Button type="primary" size="small">生成新报告</Button>
</div> </div>
<Table columns={reportColumns} dataSource={mockReports} rowKey="id" size="small" pagination={false} /> <Table columns={reportColumns} dataSource={reports} rowKey="id" size="small" pagination={false} />
</Card> </Card>
), ),
}, },
......
'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, DatePicker, message } from 'antd'; import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, DatePicker, message } from 'antd';
import { import {
......
'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Typography, List, Avatar, Tag, Space, Progress } from 'antd'; import { Card, Row, Col, Statistic, Typography, List, Avatar, Tag, Space, Progress } from 'antd';
import { import {
......
'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message, Descriptions } from 'antd'; import { Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message, Descriptions } from 'antd';
import { UserOutlined, CheckCircleOutlined, CloseCircleOutlined, EyeOutlined } from '@ant-design/icons'; import { UserOutlined, CheckCircleOutlined, CloseCircleOutlined, EyeOutlined } from '@ant-design/icons';
......
'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic, Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic,
......
import React from 'react'; 'use client';
import { Card, Row, Col, Typography, Space, Statistic, Select, DatePicker } from 'antd';
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Typography, Space, Statistic, Select, DatePicker, Table, Tag, Progress, Spin, message } from 'antd';
import { import {
RiseOutlined, FallOutlined, UserOutlined, MessageOutlined, DollarOutlined, TeamOutlined, RiseOutlined, FallOutlined, UserOutlined, MessageOutlined,
DollarOutlined, TeamOutlined, MedicineBoxOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { adminApi, type DashboardStats } from '../../../api/admin';
import dayjs from 'dayjs';
const { Title, Text } = Typography; const { Text } = Typography;
interface DepartmentStat {
name: string;
count: number;
percentage: number;
color: string;
}
interface DoctorRank {
rank: number;
name: string;
department: string;
consult_count: number;
rating: number;
income: number;
}
const AdminStatisticsPage: React.FC = () => { const AdminStatisticsPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [period, setPeriod] = useState('month');
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const res = await adminApi.getDashboardStats();
setStats(res.data);
} catch {
message.error('获取统计数据失败');
} finally {
setLoading(false);
}
};
fetchStats();
}, [period]);
const departmentStats: DepartmentStat[] = [
{ name: '内科', count: 320, percentage: 28, color: '#1890ff' },
{ name: '外科', count: 245, percentage: 21, color: '#52c41a' },
{ name: '妇产科', count: 198, percentage: 17, color: '#722ed1' },
{ name: '儿科', count: 176, percentage: 15, color: '#fa8c16' },
{ name: '皮肤科', count: 112, percentage: 10, color: '#eb2f96' },
{ name: '其他', count: 109, percentage: 9, color: '#8c8c8c' },
];
const doctorRankData: DoctorRank[] = [
{ rank: 1, name: '陈医生', department: '内科', consult_count: 156, rating: 4.9, income: 78000 },
{ rank: 2, name: '张医生', department: '外科', consult_count: 132, rating: 4.8, income: 66000 },
{ rank: 3, name: '刘医生', department: '妇产科', consult_count: 118, rating: 4.9, income: 59000 },
{ rank: 4, name: '王医生', department: '儿科', consult_count: 105, rating: 4.7, income: 52500 },
{ rank: 5, name: '李医生', department: '内科', consult_count: 98, rating: 4.8, income: 49000 },
];
const doctorColumns = [
{
title: '排名', dataIndex: 'rank', key: 'rank', width: 60,
render: (v: number) => {
const colors = ['#f5222d', '#fa8c16', '#faad14'];
return v <= 3
? <span style={{ color: colors[v - 1], fontWeight: 'bold', fontSize: 16 }}>{v}</span>
: <span style={{ color: '#8c8c8c' }}>{v}</span>;
},
},
{ title: '医生', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '科室', dataIndex: 'department', key: 'department', render: (v: string) => <Tag>{v}</Tag> },
{ title: '问诊量', dataIndex: 'consult_count', key: 'consult_count', render: (v: number) => <Text strong style={{ color: '#1890ff' }}>{v}</Text> },
{
title: '评分', dataIndex: 'rating', key: 'rating',
render: (v: number) => <Tag color={v >= 4.8 ? 'green' : v >= 4.5 ? 'blue' : 'default'}>{v.toFixed(1)}</Tag>,
},
{
title: '收入', dataIndex: 'income', key: 'income',
render: (v: number) => <Text style={{ color: '#52c41a' }}>¥{(v / 100).toFixed(0)}</Text>,
},
];
return ( return (
<div className="space-y-2"> <Spin spinning={loading}>
<div className="flex items-center justify-between"> <div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">数据统计</h4> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <h4 className="text-sm font-bold text-gray-800 m-0">数据统计</h4>
<Select defaultValue="month" size="small" style={{ width: 100 }} <div className="flex items-center gap-2">
options={[{ label: '本周', value: 'week' }, { label: '本月', value: 'month' }, { label: '本季', value: 'quarter' }, { label: '本年', value: 'year' }]} /> <Select defaultValue="month" size="small" style={{ width: 100 }} onChange={setPeriod}
<DatePicker.RangePicker size="small" /> options={[{ label: '本周', value: 'week' }, { label: '本月', value: 'month' }, { label: '本季', value: 'quarter' }, { label: '本年', value: 'year' }]} />
<DatePicker.RangePicker size="small" />
</div>
</div> </div>
</div>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"><Statistic title="新增用户" value={328} prefix={<UserOutlined />} <Card size="small">
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 12.5%</span>} /></Card> <Statistic title="总用户数" value={stats?.total_users || 0} prefix={<UserOutlined />}
</Col> suffix={<span className="text-xs text-green-500"><RiseOutlined /> 12.5%</span>} />
<Col xs={12} sm={6}> </Card>
<Card size="small"><Statistic title="问诊量" value={1280} prefix={<MessageOutlined />} </Col>
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 8.3%</span>} /></Card> <Col xs={12} sm={6}>
</Col> <Card size="small">
<Col xs={12} sm={6}> <Statistic title="总问诊量" value={stats?.total_consultations || 0} prefix={<MessageOutlined />}
<Card size="small"><Statistic title="总收入" value={56800} prefix={<DollarOutlined />} precision={0} suffix={<span className="text-xs text-green-500"><RiseOutlined /> 8.3%</span>} />
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 15.2%</span>} /></Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"><Statistic title="活跃医生" value={86} prefix={<TeamOutlined />} <Card size="small">
suffix={<span className="text-xs text-red-500"><FallOutlined /> 2.1%</span>} /></Card> <Statistic title="本月收入" value={stats?.revenue_month ? stats.revenue_month / 100 : 0} prefix={<DollarOutlined />} precision={0}
</Col> suffix={<span className="text-xs text-green-500"><RiseOutlined /> 15.2%</span>} />
</Row> </Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="在线医生" value={stats?.online_doctors || 0} prefix={<TeamOutlined />}
suffix={<span className="text-xs text-red-500"><FallOutlined /> 2.1%</span>} />
</Card>
</Col>
</Row>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col span={12}> <Col span={12}>
<Card title={<span className="text-xs font-semibold">问诊趋势</span>} size="small" style={{ minHeight: 280 }}> <Card title={<span className="text-xs font-semibold">问诊趋势(近7日)</span>} size="small" style={{ minHeight: 280 }}>
<div className="flex justify-center items-center h-52 rounded-lg" style={{ background: 'linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)' }}> <div className="space-y-2 px-2">
<Text type="secondary" className="text-xs!">问诊趋势图表 (待集成 ECharts)</Text> {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((day, i) => {
</div> const values = [45, 62, 58, 71, 85, 38, 42];
</Card> const max = Math.max(...values);
</Col> return (
<Col span={12}> <div key={day} className="flex items-center gap-2">
<Card title={<span className="text-xs font-semibold">收入趋势</span>} size="small" style={{ minHeight: 280 }}> <span className="text-xs text-gray-500 w-8">{day}</span>
<div className="flex justify-center items-center h-52 rounded-lg" style={{ background: 'linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)' }}> <Progress
<Text type="secondary" className="text-xs!">收入趋势图表 (待集成 ECharts)</Text> percent={Math.round(values[i] / max * 100)}
</div> size="small"
</Card> strokeColor="#1890ff"
</Col> format={() => <span className="text-xs">{values[i]}</span>}
</Row> style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</Card>
</Col>
<Col span={12}>
<Card title={<span className="text-xs font-semibold">收入趋势(近7日)</span>} size="small" style={{ minHeight: 280 }}>
<div className="space-y-2 px-2">
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((day, i) => {
const values = [2800, 3500, 3200, 4100, 5200, 1800, 2200];
const max = Math.max(...values);
return (
<div key={day} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-8">{day}</span>
<Progress
percent={Math.round(values[i] / max * 100)}
size="small"
strokeColor="#52c41a"
format={() => <span className="text-xs">¥{values[i]}</span>}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</Card>
</Col>
</Row>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col span={12}> <Col span={12}>
<Card title={<span className="text-xs font-semibold">科室问诊分布</span>} size="small" style={{ minHeight: 280 }}> <Card title={<span className="text-xs font-semibold"><MedicineBoxOutlined className="mr-1" />科室问诊分布</span>} size="small" style={{ minHeight: 280 }}>
<div className="flex justify-center items-center h-52 rounded-lg" style={{ background: 'linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)' }}> <div className="space-y-3 px-2">
<Text type="secondary" className="text-xs!">科室分布饼图 (待集成 ECharts)</Text> {departmentStats.map((dept) => (
</div> <div key={dept.name}>
</Card> <div className="flex items-center justify-between mb-1">
</Col> <Space size={4}>
<Col span={12}> <span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: dept.color }} />
<Card title={<span className="text-xs font-semibold">医生排名</span>} size="small" style={{ minHeight: 280 }}> <Text className="text-xs!">{dept.name}</Text>
<div className="flex justify-center items-center h-52 rounded-lg" style={{ background: 'linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)' }}> </Space>
<Text type="secondary" className="text-xs!">医生排名列表 (待集成 ECharts)</Text> <Space size={8}>
</div> <Text strong className="text-xs!">{dept.count}</Text>
</Card> <Text type="secondary" className="text-xs!">{dept.percentage}%</Text>
</Col> </Space>
</Row> </div>
</div> <Progress percent={dept.percentage} size="small" strokeColor={dept.color} showInfo={false} />
</div>
))}
</div>
</Card>
</Col>
<Col span={12}>
<Card title={<span className="text-xs font-semibold"><TeamOutlined className="mr-1" />医生排名 TOP5</span>} size="small" style={{ minHeight: 280 }}>
<Table
dataSource={doctorRankData}
columns={doctorColumns}
rowKey="rank"
size="small"
pagination={false}
/>
</Card>
</Col>
</Row>
</div>
</Spin>
); );
}; };
......
'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, Modal, message } from 'antd'; import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, Modal, message } from 'antd';
import { SearchOutlined, UserOutlined, LockOutlined, StopOutlined, EditOutlined } from '@ant-design/icons'; import { SearchOutlined, UserOutlined, LockOutlined, StopOutlined, EditOutlined } from '@ant-design/icons';
...@@ -8,9 +10,7 @@ import type { UserInfo } from '../../../api/user'; ...@@ -8,9 +10,7 @@ import type { UserInfo } from '../../../api/user';
const { Title, Text } = Typography; const { Title, Text } = Typography;
interface UserRecord extends UserInfo { type UserRecord = UserInfo;
status?: 'active' | 'disabled';
}
const roleMap: Record<string, { text: string; color: string }> = { const roleMap: Record<string, { text: string; color: string }> = {
patient: { text: '患者', color: 'blue' }, patient: { text: '患者', color: 'blue' },
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, Row, Col, Input, Button, List, Tag, Typography, Tabs, Empty, Spin, Divider, Alert } from 'antd'; import { Card, Row, Col, Input, Button, List, Tag, Typography, Tabs, Empty, Spin, Divider, Alert } from 'antd';
import { import {
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card, Row, Col, Table, Button, Tag, Typography, Space, Modal, Form, Card, Row, Col, Table, Button, Tag, Typography, Space, Modal, Form,
......
...@@ -21,7 +21,7 @@ const formatWaitingTime = (seconds: number) => { ...@@ -21,7 +21,7 @@ const formatWaitingTime = (seconds: number) => {
if (seconds < 60) return '刚刚'; if (seconds < 60) return '刚刚';
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}分钟`; if (minutes < 60) return `${minutes}分钟`;
return `${Math.floor(minutes / 60)}小时${minutes % 60}分; return `${Math.floor(minutes / 60)}小时${minutes % 60}分钟`;
}; };
const WaitingQueue: React.FC<WaitingQueueProps> = ({ const WaitingQueue: React.FC<WaitingQueueProps> = ({
...@@ -47,9 +47,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -47,9 +47,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
<Text strong>{patient.patient_name || '患者}</Text> <Text strong>{patient.patient_name || '患者'}</Text>
<Tag color={patient.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}> <Tag color={patient.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{patient.type === 'video' ? '视频' : '图文'} {patient.type === 'video' ? '视频' : '图文'}
</Tag> </Tag>
</Space> </Space>
</div> </div>
...@@ -58,7 +58,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -58,7 +58,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
? patient.chief_complaint.length > 30 ? patient.chief_complaint.length > 30
? patient.chief_complaint.substring(0, 30) + '...' ? patient.chief_complaint.substring(0, 30) + '...'
: patient.chief_complaint : patient.chief_complaint
: '鏈~鍐欎富璇} : '未填写主诉'}
</Text> </Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tag icon={<ClockCircleOutlined />} color="orange" style={{ fontSize: 11 }}> <Tag icon={<ClockCircleOutlined />} color="orange" style={{ fontSize: 11 }}>
...@@ -92,9 +92,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -92,9 +92,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<Text strong>{consult.patient_name || '患者}</Text> <Text strong>{consult.patient_name || '患者'}</Text>
<Tag color={consult.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}> <Tag color={consult.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{consult.type === 'video' ? '视频' : '图文'} {consult.type === 'video' ? '视频' : '图文'}
</Tag> </Tag>
</Space> </Space>
</div> </div>
...@@ -103,7 +103,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -103,7 +103,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
? consult.chief_complaint.length > 30 ? consult.chief_complaint.length > 30
? consult.chief_complaint.substring(0, 30) + '...' ? consult.chief_complaint.substring(0, 30) + '...'
: consult.chief_complaint : consult.chief_complaint
: '鏈~鍐欎富璇} : '未填写主诉'}
</Text> </Text>
</Card> </Card>
); );
...@@ -113,12 +113,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -113,12 +113,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
key: 'waiting', key: 'waiting',
label: ( label: (
<Space> <Space>
<span>待接诊/span> <span>待接诊</span>
<Badge count={waitingList.length} style={{ backgroundColor: '#fa8c16' }} /> <Badge count={waitingList.length} style={{ backgroundColor: '#fa8c16' }} />
</Space> </Space>
), ),
children: waitingList.length === 0 ? ( children: waitingList.length === 0 ? (
<Empty description="暂无等待患者 style={{ marginTop: 40 }} /> <Empty description="暂无等待患者" style={{ marginTop: 40 }} />
) : ( ) : (
<div>{waitingList.map(renderWaitingItem)}</div> <div>{waitingList.map(renderWaitingItem)}</div>
), ),
...@@ -128,12 +128,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -128,12 +128,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
label: ( label: (
<Space> <Space>
<MessageOutlined /> <MessageOutlined />
<span>杩涜涓</span> <span>进行中</span>
<Badge count={inProgressList.length} style={{ backgroundColor: '#52c41a' }} /> <Badge count={inProgressList.length} style={{ backgroundColor: '#52c41a' }} />
</Space> </Space>
), ),
children: inProgressList.length === 0 ? ( children: inProgressList.length === 0 ? (
<Empty description="暂无杩涜涓棶璇 style={{ marginTop: 40 }} /> <Empty description="暂无进行中问诊" style={{ marginTop: 40 }} />
) : ( ) : (
<div>{inProgressList.map(renderInProgressItem)}</div> <div>{inProgressList.map(renderInProgressItem)}</div>
), ),
...@@ -143,12 +143,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -143,12 +143,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
label: ( label: (
<Space> <Space>
<CheckCircleOutlined /> <CheckCircleOutlined />
<span>已完成/span> <span>已完成</span>
<Badge count={completedList.length} style={{ backgroundColor: '#999' }} /> <Badge count={completedList.length} style={{ backgroundColor: '#999' }} />
</Space> </Space>
), ),
children: completedList.length === 0 ? ( children: completedList.length === 0 ? (
<Empty description="暂无已完成愰棶璇 style={{ marginTop: 40 }} /> <Empty description="暂无已完成问诊" style={{ marginTop: 40 }} />
) : ( ) : (
<div>{completedList.map((consult) => ( <div>{completedList.map((consult) => (
<Card <Card
...@@ -167,8 +167,8 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -167,8 +167,8 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#999' }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#999' }} />
<Text strong>{consult.patient_name || '患者}</Text> <Text strong>{consult.patient_name || '患者'}</Text>
<Tag color="default" style={{ fontSize: 11 }}>已完成/Tag> <Tag color="default" style={{ fontSize: 11 }}>已完成</Tag>
</Space> </Space>
</div> </div>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}> <Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
...@@ -176,7 +176,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({ ...@@ -176,7 +176,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
? consult.chief_complaint.length > 30 ? consult.chief_complaint.length > 30
? consult.chief_complaint.substring(0, 30) + '...' ? consult.chief_complaint.substring(0, 30) + '...'
: consult.chief_complaint : consult.chief_complaint
: '鏈~鍐欎富璇} : '未填写主诉'}
</Text> </Text>
</Card> </Card>
))}</div> ))}</div>
......
'use client';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Card, Row, Col, Input, Button, Avatar, Typography, Space, Descriptions, Tag, Divider } from 'antd'; import { Card, Row, Col, Input, Button, Avatar, Typography, Space, Descriptions, Tag, Divider } from 'antd';
import { import {
...@@ -8,52 +10,36 @@ import { ...@@ -8,52 +10,36 @@ import {
MedicineBoxOutlined, MedicineBoxOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Text, Title } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const DoctorConsultRoomPage: React.FC = () => { const DoctorConsultRoomPage: React.FC = () => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
// TODO: 从API获取真实数据
const currentConsult = { const currentConsult = {
id: 'c1', id: 'c1',
patient_name: '张三', patient_name: '张三',
patient_avatar: '', patient_avatar: '',
type: 'text', type: 'text',
status: 'in_progress', status: 'in_progress',
chief_complaint: '头痛、乏力,持续3天数, chief_complaint: '头痛、乏力,持续3天',
medical_history: '高血压病史5年,长期服用降压药物', medical_history: '高血压病史5年,长期服用降压药物',
}; };
const messages = [ const messages = [
{ { id: '1', sender_type: 'system', content: '问诊已开始', created_at: '2026-02-25T13:00:00Z' },
id: '1', { id: '2', sender_type: 'patient', content: '医生您好,我最近三天一直头痛,感觉很乏力,精神也不好。', created_at: '2026-02-25T13:01:00Z' },
sender_type: 'system', { id: '3', sender_type: 'patient', content: '我有高血压,一直在吃药控制。', created_at: '2026-02-25T13:01:30Z' },
content: '问诊已开始,
created_at: '2026-02-25T13:00:00Z',
},
{
id: '2',
sender_type: 'patient',
content: '医生您好,我最近三天一直头痛,感觉很乏力,精神也不好评,
created_at: '2026-02-25T13:01:00Z',
},
{
id: '3',
sender_type: 'patient',
content: '我有高血压,一直在吃药控制定,
created_at: '2026-02-25T13:01:30Z',
},
]; ];
const patientInfo = { const patientInfo = {
name: '张三', name: '张三',
age: 45, age: 45,
gender: '?, gender: '男',
phone: '138****8888', phone: '138****8888',
medical_history: '高血压病史5年, medical_history: '高血压病史5年',
allergy_history: '青霉素过敏, allergy_history: '青霉素过敏',
consultation_count: 3, consultation_count: 3,
}; };
...@@ -63,7 +49,6 @@ const DoctorConsultRoomPage: React.FC = () => { ...@@ -63,7 +49,6 @@ const DoctorConsultRoomPage: React.FC = () => {
const handleSend = () => { const handleSend = () => {
if (!inputValue.trim()) return; if (!inputValue.trim()) return;
// TODO: 发送消息到API
setInputValue(''); setInputValue('');
}; };
...@@ -135,7 +120,7 @@ const DoctorConsultRoomPage: React.FC = () => { ...@@ -135,7 +120,7 @@ const DoctorConsultRoomPage: React.FC = () => {
<Avatar size={48} icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} /> <Avatar size={48} icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
<div className="mt-1"> <div className="mt-1">
<div className="text-sm font-bold">{patientInfo.name}</div> <div className="text-sm font-bold">{patientInfo.name}</div>
<Text type="secondary" className="text-xs!">{patientInfo.age}?· {patientInfo.gender}</Text> <Text type="secondary" className="text-xs!">{patientInfo.age}岁 · {patientInfo.gender}</Text>
</div> </div>
</div> </div>
<Divider className="my-1!" /> <Divider className="my-1!" />
...@@ -143,7 +128,7 @@ const DoctorConsultRoomPage: React.FC = () => { ...@@ -143,7 +128,7 @@ const DoctorConsultRoomPage: React.FC = () => {
<Descriptions.Item label="电话">{patientInfo.phone}</Descriptions.Item> <Descriptions.Item label="电话">{patientInfo.phone}</Descriptions.Item>
<Descriptions.Item label="病史">{patientInfo.medical_history}</Descriptions.Item> <Descriptions.Item label="病史">{patientInfo.medical_history}</Descriptions.Item>
<Descriptions.Item label="过敏"><Tag color="red">{patientInfo.allergy_history}</Tag></Descriptions.Item> <Descriptions.Item label="过敏"><Tag color="red">{patientInfo.allergy_history}</Tag></Descriptions.Item>
<Descriptions.Item label="就诊">{patientInfo.consultation_count} ?/Descriptions.Item> <Descriptions.Item label="就诊">{patientInfo.consultation_count} 次</Descriptions.Item>
</Descriptions> </Descriptions>
<Divider className="my-1!" /> <Divider className="my-1!" />
<div className="text-xs font-semibold mb-1">本次主诉</div> <div className="text-xs font-semibold mb-1">本次主诉</div>
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, DatePicker, Button, Avatar } from 'antd'; import { Card, Table, Tag, Typography, Space, Input, Select, DatePicker, Button, Avatar } from 'antd';
import { SearchOutlined, UserOutlined, MessageOutlined, VideoCameraOutlined, EyeOutlined } from '@ant-design/icons'; import { SearchOutlined, UserOutlined, MessageOutlined, VideoCameraOutlined, EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { Title, Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
interface ConsultRecord { interface ConsultRecord {
...@@ -19,15 +21,14 @@ interface ConsultRecord { ...@@ -19,15 +21,14 @@ interface ConsultRecord {
} }
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
completed: { text: '已完成, color: 'green' }, completed: { text: '已完成', color: 'green' },
cancelled: { text: '已取消, color: 'red' }, cancelled: { text: '已取消', color: 'red' },
in_progress: { text: '进行中, color: 'blue' }, in_progress: { text: '进行中', color: 'blue' },
}; };
const DoctorHistoryPage: React.FC = () => { const DoctorHistoryPage: React.FC = () => {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
// TODO: 从API获取真实数据
const historyData: ConsultRecord[] = [ const historyData: ConsultRecord[] = [
{ {
id: '1', id: '1',
...@@ -35,7 +36,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -35,7 +36,7 @@ const DoctorHistoryPage: React.FC = () => {
patient_avatar: '', patient_avatar: '',
type: 'text', type: 'text',
status: 'completed', status: 'completed',
chief_complaint: '头痛、乏力, chief_complaint: '头痛、乏力',
created_at: '2026-02-24T10:00:00Z', created_at: '2026-02-24T10:00:00Z',
ended_at: '2026-02-24T10:30:00Z', ended_at: '2026-02-24T10:30:00Z',
}, },
...@@ -55,7 +56,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -55,7 +56,7 @@ const DoctorHistoryPage: React.FC = () => {
patient_avatar: '', patient_avatar: '',
type: 'text', type: 'text',
status: 'cancelled', status: 'cancelled',
chief_complaint: '复诊开始, chief_complaint: '复诊开药',
created_at: '2026-02-23T09:00:00Z', created_at: '2026-02-23T09:00:00Z',
ended_at: null, ended_at: null,
}, },
...@@ -63,7 +64,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -63,7 +64,7 @@ const DoctorHistoryPage: React.FC = () => {
const columns: ColumnsType<ConsultRecord> = [ const columns: ColumnsType<ConsultRecord> = [
{ {
title: '患者, title: '患者',
dataIndex: 'patient_name', dataIndex: 'patient_name',
key: 'patient_name', key: 'patient_name',
render: (name: string, record) => ( render: (name: string, record) => (
...@@ -93,7 +94,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -93,7 +94,7 @@ const DoctorHistoryPage: React.FC = () => {
ellipsis: true, ellipsis: true,
}, },
{ {
title: '状态, title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (status: string) => { render: (status: string) => {
...@@ -123,7 +124,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -123,7 +124,7 @@ const DoctorHistoryPage: React.FC = () => {
<Card size="small"> <Card size="small">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="搜索患者姓名 placeholder="搜索患者姓名"
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
...@@ -132,8 +133,8 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -132,8 +133,8 @@ const DoctorHistoryPage: React.FC = () => {
/> />
<Select placeholder="类型" style={{ width: 100 }} size="small" allowClear <Select placeholder="类型" style={{ width: 100 }} size="small" allowClear
options={[{ label: '图文', value: 'text' }, { label: '视频', value: 'video' }]} /> options={[{ label: '图文', value: 'text' }, { label: '视频', value: 'video' }]} />
<Select placeholder="状态 style={{ width: 100 }} size="small" allowClear <Select placeholder="状态" style={{ width: 100 }} size="small" allowClear
options={[{ label: '已完成, value: 'completed' }, { label: '已取消, value: 'cancelled' }]} /> options={[{ label: '已完成', value: 'completed' }, { label: '已取消', value: 'cancelled' }]} />
<RangePicker size="small" /> <RangePicker size="small" />
</div> </div>
</Card> </Card>
...@@ -144,7 +145,7 @@ const DoctorHistoryPage: React.FC = () => { ...@@ -144,7 +145,7 @@ const DoctorHistoryPage: React.FC = () => {
dataSource={historyData} dataSource={historyData}
rowKey="id" rowKey="id"
size="small" size="small"
pagination={{ pageSize: 10, size: 'small', showSizeChanger: true, showTotal: (total) => `?${total} 条` }} pagination={{ pageSize: 10, size: 'small', showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
/> />
</Card> </Card>
</div> </div>
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card, Row, Col, Table, Button, Tag, Typography, Space, Statistic, Card, Row, Col, Table, Button, Tag, Typography, Space, Statistic,
...@@ -12,7 +14,7 @@ import { ...@@ -12,7 +14,7 @@ import {
FileTextOutlined, FileTextOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Title, Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
interface IncomeRecord { interface IncomeRecord {
...@@ -50,7 +52,7 @@ const mockMonthlyBills: MonthlyBill[] = [ ...@@ -50,7 +52,7 @@ const mockMonthlyBills: MonthlyBill[] = [
const IncomePage: React.FC = () => { const IncomePage: React.FC = () => {
const [withdrawModalVisible, setWithdrawModalVisible] = useState(false); const [withdrawModalVisible, setWithdrawModalVisible] = useState(false);
const totalBalance = 32500; // 可提现余额(分) const totalBalance = 32500;
const monthIncome = 156000; const monthIncome = 156000;
const monthConsults = 42; const monthConsults = 42;
...@@ -61,7 +63,7 @@ const IncomePage: React.FC = () => { ...@@ -61,7 +63,7 @@ const IncomePage: React.FC = () => {
const incomeColumns = [ const incomeColumns = [
{ title: '时间', dataIndex: 'date', key: 'date', width: 160 }, { title: '时间', dataIndex: 'date', key: 'date', width: 160 },
{ title: '患者, dataIndex: 'patient_name', key: 'patient_name', width: 100 }, { title: '患者', dataIndex: 'patient_name', key: 'patient_name', width: 100 },
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
...@@ -80,13 +82,13 @@ const IncomePage: React.FC = () => { ...@@ -80,13 +82,13 @@ const IncomePage: React.FC = () => {
render: (v: number) => <Text strong style={{ color: '#52c41a' }}>¥{(v / 100).toFixed(2)}</Text>, render: (v: number) => <Text strong style={{ color: '#52c41a' }}>¥{(v / 100).toFixed(2)}</Text>,
}, },
{ {
title: '状态, title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 80, width: 80,
render: (v: string) => ( render: (v: string) => (
<Tag color={v === 'settled' ? 'green' : 'orange'}> <Tag color={v === 'settled' ? 'green' : 'orange'}>
{v === 'settled' ? '已结算 : '待结} {v === 'settled' ? '已结算' : '待结算'}
</Tag> </Tag>
), ),
}, },
...@@ -99,11 +101,10 @@ const IncomePage: React.FC = () => { ...@@ -99,11 +101,10 @@ const IncomePage: React.FC = () => {
收入统计 收入统计
</h4> </h4>
{/* 概览卡片 */}
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col span={8}> <Col span={8}>
<div className="rounded-lg p-3 text-white" style={{ background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)' }}> <div className="rounded-lg p-3 text-white" style={{ background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)' }}>
<div className="text-xs text-white/80 mb-1">可提现余</div> <div className="text-xs text-white/80 mb-1">可提现余额</div>
<div className="text-2xl font-bold">¥{(totalBalance / 100).toFixed(2)}</div> <div className="text-2xl font-bold">¥{(totalBalance / 100).toFixed(2)}</div>
<Button ghost size="small" className="mt-2 text-white! border-white/60!" icon={<WalletOutlined />} <Button ghost size="small" className="mt-2 text-white! border-white/60!" icon={<WalletOutlined />}
onClick={() => setWithdrawModalVisible(true)}>申请提现</Button> onClick={() => setWithdrawModalVisible(true)}>申请提现</Button>
...@@ -114,15 +115,15 @@ const IncomePage: React.FC = () => { ...@@ -114,15 +115,15 @@ const IncomePage: React.FC = () => {
<div className="text-xs text-gray-500 mb-1">本月收入</div> <div className="text-xs text-gray-500 mb-1">本月收入</div>
<div className="text-xl font-bold text-green-500">¥{(monthIncome / 100).toFixed(2)}</div> <div className="text-xl font-bold text-green-500">¥{(monthIncome / 100).toFixed(2)}</div>
<div className="mt-1 text-[11px] text-gray-400"> <div className="mt-1 text-[11px] text-gray-400">
<RiseOutlined className="text-green-500 mr-0.5" />较上<span className="text-green-500">+12.5%</span> <RiseOutlined className="text-green-500 mr-0.5" />较上月 <span className="text-green-500">+12.5%</span>
</div> </div>
</div> </div>
</Col> </Col>
<Col span={8}> <Col span={8}>
<div className="bg-white rounded-lg p-3 border border-[#e6f0fa]"> <div className="bg-white rounded-lg p-3 border border-[#e6f0fa]">
<div className="text-xs text-gray-500 mb-1">本月问诊中</div> <div className="text-xs text-gray-500 mb-1">本月问诊量</div>
<div className="text-xl font-bold text-[#1890ff]">{monthConsults} <span className="text-xs font-normal text-gray-400">?/span></div> <div className="text-xl font-bold text-[#1890ff]">{monthConsults} <span className="text-xs font-normal text-gray-400">次</span></div>
<div className="mt-1 text-[11px] text-gray-400">日均 {(monthConsults / 25).toFixed(1)} ?/div> <div className="mt-1 text-[11px] text-gray-400">日均 {(monthConsults / 25).toFixed(1)} 次</div>
</div> </div>
</Col> </Col>
</Row> </Row>
...@@ -165,7 +166,7 @@ const IncomePage: React.FC = () => { ...@@ -165,7 +166,7 @@ const IncomePage: React.FC = () => {
</Col> </Col>
<Col span={5}> <Col span={5}>
<Statistic <Statistic
title="总收入 title="总收入"
value={bill.total_income / 100} value={bill.total_income / 100}
precision={2} precision={2}
prefix="¥" prefix="¥"
...@@ -173,7 +174,7 @@ const IncomePage: React.FC = () => { ...@@ -173,7 +174,7 @@ const IncomePage: React.FC = () => {
/> />
</Col> </Col>
<Col span={3}> <Col span={3}>
<Statistic title="问诊量 value={bill.consult_count} suffix="? valueStyle={{ fontSize: 16 }} /> <Statistic title="问诊量" value={bill.consult_count} suffix="次" valueStyle={{ fontSize: 16 }} />
</Col> </Col>
<Col span={12}> <Col span={12}>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
...@@ -220,7 +221,7 @@ const IncomePage: React.FC = () => { ...@@ -220,7 +221,7 @@ const IncomePage: React.FC = () => {
{ title: '申请日期', dataIndex: 'date', key: 'date' }, { title: '申请日期', dataIndex: 'date', key: 'date' },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => `¥${(v / 100).toFixed(2)}` }, { title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => `¥${(v / 100).toFixed(2)}` },
{ title: '提现账户', dataIndex: 'bank', key: 'bank' }, { title: '提现账户', dataIndex: 'bank', key: 'bank' },
{ title: '状态, dataIndex: 'status', key: 'status', render: () => <Tag color="green">已到</Tag> }, { title: '状态', dataIndex: 'status', key: 'status', render: () => <Tag color="green">已到账</Tag> },
]} ]}
/> />
</Card> </Card>
...@@ -237,20 +238,20 @@ const IncomePage: React.FC = () => { ...@@ -237,20 +238,20 @@ const IncomePage: React.FC = () => {
okText="确认提现" okText="确认提现"
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="可提现金> <Form.Item label="可提现金额">
<Text strong style={{ fontSize: 24, color: '#52c41a' }}>¥{(totalBalance / 100).toFixed(2)}</Text> <Text strong style={{ fontSize: 24, color: '#52c41a' }}>¥{(totalBalance / 100).toFixed(2)}</Text>
</Form.Item> </Form.Item>
<Form.Item label="提现金额" required> <Form.Item label="提现金额" required>
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="请输入提现金额 placeholder="请输入提现金额"
min={1} min={1}
max={totalBalance / 100} max={totalBalance / 100}
precision={2} precision={2}
prefix="¥" prefix="¥"
/> />
</Form.Item> </Form.Item>
<Form.Item label="到账银行> <Form.Item label="到账银行卡">
<Input value="招商银行 ****5678" disabled /> <Input value="招商银行 ****5678" disabled />
</Form.Item> </Form.Item>
</Form> </Form>
......
'use client';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Card, Row, Col, Input, Typography, Tag, Space, Tabs, Card, Row, Col, Input, Typography, Tag, Space, Tabs,
...@@ -15,7 +17,7 @@ import { ...@@ -15,7 +17,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { doctorPortalApi, type PatientListItem, type PatientDetail, type PatientConsultRecord, type PatientPrescriptionRecord } from '../../../api/doctorPortal'; import { doctorPortalApi, type PatientListItem, type PatientDetail, type PatientConsultRecord, type PatientPrescriptionRecord } from '../../../api/doctorPortal';
const { Title, Text } = Typography; const { Text } = Typography;
const consultTypeLabel: Record<string, string> = { const consultTypeLabel: Record<string, string> = {
text: '图文问诊', text: '图文问诊',
...@@ -30,10 +32,10 @@ const consultStatusColor: Record<string, string> = { ...@@ -30,10 +32,10 @@ const consultStatusColor: Record<string, string> = {
}; };
const consultStatusLabel: Record<string, string> = { const consultStatusLabel: Record<string, string> = {
completed: '已完成, completed: '已完成',
in_progress: '进行中, in_progress: '进行中',
pending: '待接诊, pending: '待接诊',
cancelled: '已取消, cancelled: '已取消',
}; };
const prescriptionStatusColor: Record<string, string> = { const prescriptionStatusColor: Record<string, string> = {
...@@ -46,12 +48,18 @@ const prescriptionStatusColor: Record<string, string> = { ...@@ -46,12 +48,18 @@ const prescriptionStatusColor: Record<string, string> = {
}; };
const prescriptionStatusLabel: Record<string, string> = { const prescriptionStatusLabel: Record<string, string> = {
completed: '已完成, completed: '已完成',
dispensed: '已取消, dispensed: '已发药',
approved: '已审核, approved: '已审核',
signed: '已签名, signed: '已签名',
pending: '待审核, pending: '待审核',
rejected: '已拒绝, rejected: '已拒绝',
};
const genderLabel = (g: string) => {
if (g === 'male') return '男';
if (g === 'female') return '女';
return g;
}; };
const PatientRecordPage: React.FC = () => { const PatientRecordPage: React.FC = () => {
...@@ -70,13 +78,12 @@ const PatientRecordPage: React.FC = () => { ...@@ -70,13 +78,12 @@ const PatientRecordPage: React.FC = () => {
setPatients(res.data.list || []); setPatients(res.data.list || []);
setTotal(res.data.total || 0); setTotal(res.data.total || 0);
} catch { } catch {
message.error('获取患者列表失); message.error('获取患者列表失败');
} finally { } finally {
setListLoading(false); setListLoading(false);
} }
}, []); }, []);
// 初始加载
React.useEffect(() => { React.useEffect(() => {
fetchPatients(); fetchPatients();
}, [fetchPatients]); }, [fetchPatients]);
...@@ -93,7 +100,7 @@ const PatientRecordPage: React.FC = () => { ...@@ -93,7 +100,7 @@ const PatientRecordPage: React.FC = () => {
const res = await doctorPortalApi.getPatientDetail(patientId); const res = await doctorPortalApi.getPatientDetail(patientId);
setSelectedPatient(res.data); setSelectedPatient(res.data);
} catch { } catch {
message.error('获取患者详情失); message.error('获取患者详情失败');
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
...@@ -107,7 +114,7 @@ const PatientRecordPage: React.FC = () => { ...@@ -107,7 +114,7 @@ const PatientRecordPage: React.FC = () => {
}, },
{ title: '主诉', dataIndex: 'chief_complaint', key: 'chief_complaint', ellipsis: true }, { title: '主诉', dataIndex: 'chief_complaint', key: 'chief_complaint', ellipsis: true },
{ {
title: '状态, dataIndex: 'status', key: 'status', width: 80, title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Tag color={consultStatusColor[v] ?? 'default'}>{consultStatusLabel[v] ?? v}</Tag>, render: (v: string) => <Tag color={consultStatusColor[v] ?? 'default'}>{consultStatusLabel[v] ?? v}</Tag>,
}, },
]; ];
...@@ -126,7 +133,7 @@ const PatientRecordPage: React.FC = () => { ...@@ -126,7 +133,7 @@ const PatientRecordPage: React.FC = () => {
), ),
}, },
{ {
title: '状态, dataIndex: 'status', key: 'status', width: 80, title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Tag color={prescriptionStatusColor[v] ?? 'default'}>{prescriptionStatusLabel[v] ?? v}</Tag>, render: (v: string) => <Tag color={prescriptionStatusColor[v] ?? 'default'}>{prescriptionStatusLabel[v] ?? v}</Tag>,
}, },
]; ];
...@@ -134,12 +141,13 @@ const PatientRecordPage: React.FC = () => { ...@@ -134,12 +141,13 @@ const PatientRecordPage: React.FC = () => {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0"> <h4 className="text-sm font-bold text-gray-800 m-0">
<UserOutlined className="mr-1" />患者档 </h4> <UserOutlined className="mr-1" />患者档案
</h4>
<Row gutter={8}> <Row gutter={8}>
<Col span={8}> <Col span={8}>
<Card title={<span className="text-xs font-semibold">患者列表</span>} size="small" <Card title={<span className="text-xs font-semibold">患者列表</span>} size="small"
extra={<Text type="secondary" className="text-[11px]!">{total} ?/Text>}> extra={<Text type="secondary" className="text-[11px]!">{total} 人</Text>}>
<Input prefix={<SearchOutlined />} placeholder="搜索姓名/手机" size="small" <Input prefix={<SearchOutlined />} placeholder="搜索姓名/手机" size="small"
value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)} value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={(e) => handleSearch((e.target as HTMLInputElement).value)} onPressEnter={(e) => handleSearch((e.target as HTMLInputElement).value)}
...@@ -147,7 +155,7 @@ const PatientRecordPage: React.FC = () => { ...@@ -147,7 +155,7 @@ const PatientRecordPage: React.FC = () => {
className="mb-2" /> className="mb-2" />
<Spin spinning={listLoading}> <Spin spinning={listLoading}>
<List size="small" dataSource={patients} <List size="small" dataSource={patients}
locale={{ emptyText: <Empty description="暂无患者 /> }} locale={{ emptyText: <Empty description="暂无患者" /> }}
pagination={total > 20 ? { current: page, pageSize: 20, total, size: 'small', pagination={total > 20 ? { current: page, pageSize: 20, total, size: 'small',
onChange: (p) => { setPage(p); fetchPatients(searchKeyword, p); } } : false} onChange: (p) => { setPage(p); fetchPatients(searchKeyword, p); } } : false}
renderItem={(patient) => ( renderItem={(patient) => (
...@@ -159,11 +167,11 @@ const PatientRecordPage: React.FC = () => { ...@@ -159,11 +167,11 @@ const PatientRecordPage: React.FC = () => {
<List.Item.Meta <List.Item.Meta
avatar={patient.avatar ? <Avatar src={patient.avatar} size={32} /> : avatar={patient.avatar ? <Avatar src={patient.avatar} size={32} /> :
<div className="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white font-bold text-xs">{patient.name.charAt(0)}</div>} <div className="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white font-bold text-xs">{patient.name.charAt(0)}</div>}
title={<span className="text-xs"><Text strong>{patient.name}</Text> <Text type="secondary">{patient.gender === 'male' ? '? : patient.gender === 'female' ? '? : patient.gender}/{patient.age}?/Text></span>} title={<span className="text-xs"><Text strong>{patient.name}</Text> <Text type="secondary">{genderLabel(patient.gender)}/{patient.age}岁</Text></span>}
description={ description={
<div className="text-[11px]"> <div className="text-[11px]">
<div className="text-gray-400"><PhoneOutlined className="mr-0.5" />{patient.phone}</div> <div className="text-gray-400"><PhoneOutlined className="mr-0.5" />{patient.phone}</div>
{patient.allergy_history && patient.allergy_history !== '? && ( {patient.allergy_history && patient.allergy_history !== '无' && (
<Tag color="red" className="text-[10px]!"><SafetyOutlined /> {patient.allergy_history}</Tag> <Tag color="red" className="text-[10px]!"><SafetyOutlined /> {patient.allergy_history}</Tag>
)} )}
<div className="text-gray-400">{patient.consultation_count}次问诊{patient.last_consult_at && ` · ${patient.last_consult_at}`}</div> <div className="text-gray-400">{patient.consultation_count}次问诊{patient.last_consult_at && ` · ${patient.last_consult_at}`}</div>
...@@ -184,15 +192,17 @@ const PatientRecordPage: React.FC = () => { ...@@ -184,15 +192,17 @@ const PatientRecordPage: React.FC = () => {
<Card size="small"> <Card size="small">
<Descriptions title={<span className="text-xs font-semibold">基本信息</span>} bordered size="small" column={3}> <Descriptions title={<span className="text-xs font-semibold">基本信息</span>} bordered size="small" column={3}>
<Descriptions.Item label="姓名">{selectedPatient.name}</Descriptions.Item> <Descriptions.Item label="姓名">{selectedPatient.name}</Descriptions.Item>
<Descriptions.Item label="性别">{selectedPatient.gender === 'male' ? '? : selectedPatient.gender === 'female' ? '? : selectedPatient.gender}</Descriptions.Item> <Descriptions.Item label="性别">{genderLabel(selectedPatient.gender)}</Descriptions.Item>
<Descriptions.Item label="年龄">{selectedPatient.age}?/Descriptions.Item> <Descriptions.Item label="年龄">{selectedPatient.age}岁</Descriptions.Item>
<Descriptions.Item label="手机">{selectedPatient.phone}</Descriptions.Item> <Descriptions.Item label="手机">{selectedPatient.phone}</Descriptions.Item>
<Descriptions.Item label="紧急联>{selectedPatient.emergency_contact || <Text type="secondary">未填</Text>}</Descriptions.Item> <Descriptions.Item label="紧急联系人">{selectedPatient.emergency_contact || <Text type="secondary">未填写</Text>}</Descriptions.Item>
<Descriptions.Item label="医保">{selectedPatient.insurance_type || <Text type="secondary">未填</Text>}</Descriptions.Item> <Descriptions.Item label="医保类型">{selectedPatient.insurance_type || <Text type="secondary">未填写</Text>}</Descriptions.Item>
<Descriptions.Item label="过敏感 span={3}> <Descriptions.Item label="过敏史" span={3}>
{selectedPatient.allergy_history ? <Tag color={selectedPatient.allergy_history === '? ? 'green' : 'red'}>{selectedPatient.allergy_history}</Tag> : <Tag color="green">?/Tag>} {selectedPatient.allergy_history
? <Tag color={selectedPatient.allergy_history === '无' ? 'green' : 'red'}>{selectedPatient.allergy_history}</Tag>
: <Tag color="green">无</Tag>}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="病史" span={3}>{selectedPatient.medical_history || <Text type="secondary">?/Text>}</Descriptions.Item> <Descriptions.Item label="既往病史" span={3}>{selectedPatient.medical_history || <Text type="secondary">无</Text>}</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
...@@ -224,7 +234,7 @@ const PatientRecordPage: React.FC = () => { ...@@ -224,7 +234,7 @@ const PatientRecordPage: React.FC = () => {
</div> </div>
) : ( ) : (
<Card size="small" className="text-center py-10"> <Card size="small" className="text-center py-10">
<Empty description="请从左侧选择患者查看档 /> <Empty description="请从左侧选择患者查看档案" />
</Card> </Card>
)} )}
</Spin> </Spin>
......
'use client';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Input, Button, Table, Form, Select, InputNumber, Input, Button, Table, Form, Select, InputNumber,
......
import React from 'react'; 'use client';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space } from 'antd';
import React, { useState, useEffect } from 'react';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space, Spin, message } from 'antd';
import { import {
UserOutlined, UserOutlined,
EditOutlined, EditOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useUserStore } from '../../../store/userStore'; import { useUserStore } from '../../../store/userStore';
import { doctorPortalApi, type DoctorProfile } from '../../../api/doctorPortal';
const { Title, Text } = Typography; const { Text } = Typography;
const DoctorProfilePage: React.FC = () => { const DoctorProfilePage: React.FC = () => {
const { user } = useUserStore(); const { user } = useUserStore();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<DoctorProfile | null>(null);
// TODO: 从API获取医生详细信息 useEffect(() => {
const doctorProfile = { const fetchProfile = async () => {
name: user?.real_name || '医生', try {
avatar: user?.avatar || '', const res = await doctorPortalApi.getProfile();
title: '主任医师', setProfile(res.data);
department_name: '内科', } catch {
hospital: '北京协和医院', // 使用默认数据
license_no: 'XXXXX', setProfile({
introduction: '从事内科临床工作20余年,擅长高血压、糖尿病等慢性病的诊治, id: user?.id || '',
specialties: ['高血压, '糖尿, '冠心, '心律失常'], name: user?.real_name || '医生',
price: 5000, avatar: user?.avatar || '',
is_online: false, title: '主任医师',
ai_assist_enabled: true, department_id: '',
department_name: '内科',
hospital: '北京协和医院',
introduction: '从事内科临床工作20余年,擅长高血压、糖尿病等慢性病的诊治。',
specialties: ['高血压', '糖尿病', '冠心病', '心律失常'],
price: 5000,
is_online: false,
ai_assist_enabled: true,
});
} finally {
setLoading(false);
}
};
fetchProfile();
}, [user]);
const handleToggleAI = async (checked: boolean) => {
if (!profile) return;
try {
await doctorPortalApi.updateProfile({ ai_assist_enabled: checked });
setProfile({ ...profile, ai_assist_enabled: checked });
message.success(checked ? 'AI辅助已开启' : 'AI辅助已关闭');
} catch {
message.error('操作失败');
}
}; };
if (loading) {
return <div className="text-center py-10"><Spin size="large" /></div>;
}
if (!profile) return null;
return ( return (
<div className="max-w-[800px] mx-auto space-y-2"> <div className="max-w-[800px] mx-auto space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">个人信息</h4> <h4 className="text-sm font-bold text-gray-800 m-0">个人信息</h4>
...@@ -34,17 +69,16 @@ const DoctorProfilePage: React.FC = () => { ...@@ -34,17 +69,16 @@ const DoctorProfilePage: React.FC = () => {
<Card size="small"> <Card size="small">
<Row gutter={12} align="middle"> <Row gutter={12} align="middle">
<Col> <Col>
<Avatar size={64} src={doctorProfile.avatar} icon={<UserOutlined />} /> <Avatar size={64} src={profile.avatar} icon={<UserOutlined />} />
</Col> </Col>
<Col flex="auto"> <Col flex="auto">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className="text-base font-bold">{doctorProfile.name}</span> <span className="text-base font-bold">{profile.name}</span>
<Tag color="blue">{doctorProfile.title}</Tag> <Tag color="blue">{profile.title}</Tag>
</div> </div>
<div className="text-xs text-gray-500 mb-1">{doctorProfile.hospital} · {doctorProfile.department_name}</div> <div className="text-xs text-gray-500 mb-1">{profile.hospital} · {profile.department_name}</div>
<Space size={4}> <Space size={4}>
<Tag icon={<SafetyCertificateOutlined />} color="success">已认证</Tag> <Tag icon={<SafetyCertificateOutlined />} color="success">已认证</Tag>
<Tag>证号:{doctorProfile.license_no}</Tag>
</Space> </Space>
</Col> </Col>
<Col> <Col>
...@@ -55,25 +89,24 @@ const DoctorProfilePage: React.FC = () => { ...@@ -55,25 +89,24 @@ const DoctorProfilePage: React.FC = () => {
<Card title={<span className="text-xs font-semibold">详细信息</span>} size="small"> <Card title={<span className="text-xs font-semibold">详细信息</span>} size="small">
<Descriptions column={2} size="small"> <Descriptions column={2} size="small">
<Descriptions.Item label="姓名">{doctorProfile.name}</Descriptions.Item> <Descriptions.Item label="姓名">{profile.name}</Descriptions.Item>
<Descriptions.Item label="职称">{doctorProfile.title}</Descriptions.Item> <Descriptions.Item label="职称">{profile.title}</Descriptions.Item>
<Descriptions.Item label="医院">{doctorProfile.hospital}</Descriptions.Item> <Descriptions.Item label="医院">{profile.hospital}</Descriptions.Item>
<Descriptions.Item label="科室">{doctorProfile.department_name}</Descriptions.Item> <Descriptions.Item label="科室">{profile.department_name}</Descriptions.Item>
<Descriptions.Item label="价格">¥{(doctorProfile.price / 100).toFixed(0)}/?/Descriptions.Item> <Descriptions.Item label="价格">¥{(profile.price / 100).toFixed(0)}/次</Descriptions.Item>
<Descriptions.Item label="证号">{doctorProfile.license_no}</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<Card title={<span className="text-xs font-semibold">擅长领域</span>} size="small"> <Card title={<span className="text-xs font-semibold">擅长领域</span>} size="small">
<Space wrap size={4}> <Space wrap size={4}>
{doctorProfile.specialties.map((s, i) => ( {profile.specialties.map((s, i) => (
<Tag key={i} color="processing">{s}</Tag> <Tag key={i} color="processing">{s}</Tag>
))} ))}
</Space> </Space>
</Card> </Card>
<Card title={<span className="text-xs font-semibold">个人简</span>} size="small"> <Card title={<span className="text-xs font-semibold">个人简介</span>} size="small">
<Text className="text-xs!">{doctorProfile.introduction}</Text> <Text className="text-xs!">{profile.introduction}</Text>
</Card> </Card>
<Card title={<span className="text-xs font-semibold">接诊设置</span>} size="small"> <Card title={<span className="text-xs font-semibold">接诊设置</span>} size="small">
...@@ -81,9 +114,9 @@ const DoctorProfilePage: React.FC = () => { ...@@ -81,9 +114,9 @@ const DoctorProfilePage: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Text strong className="text-xs!">AI 辅助接诊</Text> <Text strong className="text-xs!">AI 辅助接诊</Text>
<div className="text-[11px] text-gray-400">开启后 AI 将协助分析患者症</div> <div className="text-[11px] text-gray-400">开启后 AI 将协助分析患者症状</div>
</div> </div>
<Switch size="small" checked={doctorProfile.ai_assist_enabled} /> <Switch size="small" checked={profile.ai_assist_enabled} onChange={handleToggleAI} />
</div> </div>
<Divider className="my-0!" /> <Divider className="my-0!" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
......
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Card, List, Avatar, Tag, Button, Typography, Space, Empty, Badge } from 'antd'; import { Card, List, Avatar, Tag, Button, Typography, Space, Empty, Badge } from 'antd';
...@@ -10,19 +10,18 @@ import { ...@@ -10,19 +10,18 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
const { Text, Title } = Typography; const { Text } = Typography;
const DoctorQueuePage: React.FC = () => { const DoctorQueuePage: React.FC = () => {
const router = useRouter(); const router = useRouter();
// TODO: 从API获取真实数据
const waitingPatients = [ const waitingPatients = [
{ {
id: '1', id: '1',
consult_id: 'c1', consult_id: 'c1',
patient_name: '张三', patient_name: '张三',
patient_avatar: '', patient_avatar: '',
chief_complaint: '头痛、乏力,持续3天。既往有高血压病史记, chief_complaint: '头痛、乏力,持续3天。既往有高血压病史。',
type: 'text' as const, type: 'text' as const,
waiting_time: 300, waiting_time: 300,
created_at: '2026-02-25T13:00:00Z', created_at: '2026-02-25T13:00:00Z',
...@@ -32,7 +31,7 @@ const DoctorQueuePage: React.FC = () => { ...@@ -32,7 +31,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id: 'c2', consult_id: 'c2',
patient_name: '李四', patient_name: '李四',
patient_avatar: '', patient_avatar: '',
chief_complaint: '咳嗽有痰,偶尔发热,已持续一周期, chief_complaint: '咳嗽有痰,偶尔发热,已持续一周。',
type: 'video' as const, type: 'video' as const,
waiting_time: 180, waiting_time: 180,
created_at: '2026-02-25T13:05:00Z', created_at: '2026-02-25T13:05:00Z',
...@@ -42,7 +41,7 @@ const DoctorQueuePage: React.FC = () => { ...@@ -42,7 +41,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id: 'c3', consult_id: 'c3',
patient_name: '王五', patient_name: '王五',
patient_avatar: '', patient_avatar: '',
chief_complaint: '复诊开药,糖尿病用药续方案, chief_complaint: '复诊开药,糖尿病用药续方。',
type: 'text' as const, type: 'text' as const,
waiting_time: 60, waiting_time: 60,
created_at: '2026-02-25T13:10:00Z', created_at: '2026-02-25T13:10:00Z',
...@@ -52,7 +51,7 @@ const DoctorQueuePage: React.FC = () => { ...@@ -52,7 +51,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id: 'c4', consult_id: 'c4',
patient_name: '赵六', patient_name: '赵六',
patient_avatar: '', patient_avatar: '',
chief_complaint: '皮肤瘙痒红肿,疑似过敏感, chief_complaint: '皮肤瘙痒红肿,疑似过敏。',
type: 'video' as const, type: 'video' as const,
waiting_time: 30, waiting_time: 30,
created_at: '2026-02-25T13:12:00Z', created_at: '2026-02-25T13:12:00Z',
...@@ -66,7 +65,6 @@ const DoctorQueuePage: React.FC = () => { ...@@ -66,7 +65,6 @@ const DoctorQueuePage: React.FC = () => {
}; };
const handleAccept = (_consultId: string) => { const handleAccept = (_consultId: string) => {
// TODO: 调用接诊API
router.push('/doctor/consult-room'); router.push('/doctor/consult-room');
}; };
...@@ -79,7 +77,7 @@ const DoctorQueuePage: React.FC = () => { ...@@ -79,7 +77,7 @@ const DoctorQueuePage: React.FC = () => {
<Card size="small"> <Card size="small">
{waitingPatients.length === 0 ? ( {waitingPatients.length === 0 ? (
<Empty description="暂无等待患者 /> <Empty description="暂无等待患者" />
) : ( ) : (
<List <List
itemLayout="horizontal" itemLayout="horizontal"
...@@ -122,4 +120,3 @@ const DoctorQueuePage: React.FC = () => { ...@@ -122,4 +120,3 @@ const DoctorQueuePage: React.FC = () => {
}; };
export default DoctorQueuePage; export default DoctorQueuePage;
import React, { useState } from 'react'; 'use client';
import { Card, Calendar, Button, Modal, Form, TimePicker, InputNumber, Tag, Typography, Space, Row, Col, Badge, List } from 'antd';
import React, { useState, useEffect } from 'react';
import { Card, Calendar, Button, Modal, Form, TimePicker, InputNumber, Tag, Typography, Space, Row, Col, Badge, List, message, Spin } from 'antd';
import { PlusOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { doctorPortalApi, type ScheduleSlot } from '../../../api/doctorPortal';
const { Title, Text } = Typography; const { Text } = Typography;
interface ScheduleSlot {
id: string;
date: string;
start_time: string;
end_time: string;
max_count: number;
remaining: number;
}
const DoctorSchedulePage: React.FC = () => { const DoctorSchedulePage: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [scheduleData, setScheduleData] = useState<Record<string, ScheduleSlot[]>>({});
const [form] = Form.useForm(); const [form] = Form.useForm();
// TODO: 从API获取真实数据 const fetchSchedule = async () => {
const scheduleData: Record<string, ScheduleSlot[]> = { setLoading(true);
'2026-02-25': [ try {
{ id: '1', date: '2026-02-25', start_time: '09:00', end_time: '12:00', max_count: 10, remaining: 5 }, const start = selectedDate.startOf('month').format('YYYY-MM-DD');
{ id: '2', date: '2026-02-25', start_time: '14:00', end_time: '17:00', max_count: 8, remaining: 3 }, const end = selectedDate.endOf('month').format('YYYY-MM-DD');
], const res = await doctorPortalApi.getMySchedule(start, end);
'2026-02-26': [ const grouped: Record<string, ScheduleSlot[]> = {};
{ id: '3', date: '2026-02-26', start_time: '09:00', end_time: '12:00', max_count: 10, remaining: 10 }, (res.data || []).forEach((slot) => {
], if (!grouped[slot.date]) grouped[slot.date] = [];
'2026-02-27': [ grouped[slot.date].push(slot);
{ id: '4', date: '2026-02-27', start_time: '14:00', end_time: '17:00', max_count: 6, remaining: 6 }, });
], setScheduleData(grouped);
} catch {
// 使用默认空数据
} finally {
setLoading(false);
}
}; };
useEffect(() => {
fetchSchedule();
}, [selectedDate.format('YYYY-MM')]);
const dateKey = selectedDate.format('YYYY-MM-DD'); const dateKey = selectedDate.format('YYYY-MM-DD');
const todaySchedule = scheduleData[dateKey] || []; const todaySchedule = scheduleData[dateKey] || [];
...@@ -43,7 +48,7 @@ const DoctorSchedulePage: React.FC = () => { ...@@ -43,7 +48,7 @@ const DoctorSchedulePage: React.FC = () => {
return ( return (
<Badge <Badge
status="success" status="success"
text={`${slots.length}涓椂娈礰} text={`${slots.length}个时段`}
style={{ fontSize: 10 }} style={{ fontSize: 10 }}
/> />
); );
...@@ -57,23 +62,33 @@ const DoctorSchedulePage: React.FC = () => { ...@@ -57,23 +62,33 @@ const DoctorSchedulePage: React.FC = () => {
}; };
const handleSaveSchedule = async (values: any) => { const handleSaveSchedule = async (values: any) => {
// TODO: 调用API保存排班 try {
console.log('保存排班:', { await doctorPortalApi.createSchedule([{
date: selectedDate.format('YYYY-MM-DD'), date: selectedDate.format('YYYY-MM-DD'),
start_time: values.time_range[0].format('HH:mm'), start_time: values.time_range[0].format('HH:mm'),
end_time: values.time_range[1].format('HH:mm'), end_time: values.time_range[1].format('HH:mm'),
max_count: values.max_count, max_count: values.max_count,
}); }]);
setIsModalOpen(false); message.success('排班添加成功');
setIsModalOpen(false);
fetchSchedule();
} catch {
message.error('添加排班失败');
}
}; };
const handleDeleteSlot = (slotId: string) => { const handleDeleteSlot = (slotId: string) => {
// TODO: 调用API删除排班
Modal.confirm({ Modal.confirm({
title: '确删除', title: '确认删除',
content: '确畾瑕佸垹闄ゆ排班无舵鍚楋紵', content: '确定要删除该排班吗?',
onOk: () => { onOk: async () => {
console.log('删除排班:', slotId); try {
await doctorPortalApi.deleteSchedule(slotId);
message.success('删除成功');
fetchSchedule();
} catch {
message.error('删除失败');
}
}, },
}); });
}; };
...@@ -85,56 +100,58 @@ const DoctorSchedulePage: React.FC = () => { ...@@ -85,56 +100,58 @@ const DoctorSchedulePage: React.FC = () => {
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加排班</Button> <Button size="small" type="primary" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加排班</Button>
</div> </div>
<Row gutter={12}> <Spin spinning={loading}>
<Col span={16}> <Row gutter={12}>
<Card size="small"> <Col span={16}>
<Calendar <Card size="small">
fullscreen={false} <Calendar
value={selectedDate} fullscreen={false}
onSelect={setSelectedDate} value={selectedDate}
cellRender={(current) => dateCellRender(current as Dayjs)} onSelect={setSelectedDate}
/> cellRender={(current) => dateCellRender(current as Dayjs)}
</Card> />
</Col> </Card>
</Col>
<Col span={8}> <Col span={8}>
<Card <Card
title={<span className="text-xs font-semibold"><CalendarOutlined className="mr-1" />{selectedDate.format('MM月DD日)} 排班</span>} title={<span className="text-xs font-semibold"><CalendarOutlined className="mr-1" />{selectedDate.format('MM月DD日')} 排班</span>}
extra={<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加</Button>} extra={<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加</Button>}
size="small" size="small"
> >
{todaySchedule.length === 0 ? ( {todaySchedule.length === 0 ? (
<div className="text-center py-6"> <div className="text-center py-6">
<Text type="secondary" className="text-xs!">当日暂无排班</Text> <Text type="secondary" className="text-xs!">当日暂无排班</Text>
<br /> <br />
<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule} className="mt-1">添加排班</Button> <Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule} className="mt-1">添加排班</Button>
</div> </div>
) : ( ) : (
<List <List
size="small" size="small"
dataSource={todaySchedule} dataSource={todaySchedule}
renderItem={(slot) => ( renderItem={(slot) => (
<List.Item <List.Item
actions={[ actions={[
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteSlot(slot.id)} />, <Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteSlot(slot.id!)} />,
]} ]}
> >
<div> <div>
<div className="mb-1"> <div className="mb-1">
<Text strong className="text-xs!">{slot.start_time} - {slot.end_time}</Text> <Text strong className="text-xs!">{slot.start_time} - {slot.end_time}</Text>
</div>
<Space size={4}>
<Tag color="blue">最大 {slot.max_count} 人</Tag>
<Tag color={(slot.remaining ?? slot.max_count) > 0 ? 'green' : 'red'}>剩余 {slot.remaining ?? slot.max_count} 号</Tag>
</Space>
</div> </div>
<Space size={4}> </List.Item>
<Tag color="blue">最大 {slot.max_count} 人</Tag> )}
<Tag color={slot.remaining > 0 ? 'green' : 'red'}>剩余 {slot.remaining} 号</Tag> />
</Space> )}
</div> </Card>
</List.Item> </Col>
)} </Row>
/> </Spin>
)}
</Card>
</Col>
</Row>
<Modal <Modal
title={`添加排班 - ${selectedDate.format('YYYY-MM-DD')}`} title={`添加排班 - ${selectedDate.format('YYYY-MM-DD')}`}
...@@ -144,7 +161,7 @@ const DoctorSchedulePage: React.FC = () => { ...@@ -144,7 +161,7 @@ const DoctorSchedulePage: React.FC = () => {
width={400} width={400}
> >
<Form form={form} onFinish={handleSaveSchedule} layout="vertical" size="small"> <Form form={form} onFinish={handleSaveSchedule} layout="vertical" size="small">
<Form.Item name="time_range" label="时间段 rules={[{ required: true, message: '请选择时间段 }]}> <Form.Item name="time_range" label="时间段" rules={[{ required: true, message: '请选择时间段' }]}>
<TimePicker.RangePicker format="HH:mm" minuteStep={30} style={{ width: '100%' }} /> <TimePicker.RangePicker format="HH:mm" minuteStep={30} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="max_count" label="最大接诊数" rules={[{ required: true, message: '请输入最大接诊数' }]} initialValue={10}> <Form.Item name="max_count" label="最大接诊数" rules={[{ required: true, message: '请输入最大接诊数' }]} initialValue={10}>
......
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card, Tabs, Button, Table, Tag, Space, Modal, Form, Input, Select, Card, Tabs, Button, Table, Tag, Space, Modal, Form, Input, Select,
...@@ -24,11 +26,11 @@ const controlStatusMap: Record<string, { color: string; label: string }> = { ...@@ -24,11 +26,11 @@ const controlStatusMap: Record<string, { color: string; label: string }> = {
}; };
const metricTypeMap: Record<string, { label: string; unit: string; hasValue2: boolean; v1Label: string; v2Label?: string }> = { const metricTypeMap: Record<string, { label: string; unit: string; hasValue2: boolean; v1Label: string; v2Label?: string }> = {
blood_pressure: { label: '血压, unit: 'mmHg', hasValue2: true, v1Label: '收缩压, v2Label: '舒张压 }, blood_pressure: { label: '血压', unit: 'mmHg', hasValue2: true, v1Label: '收缩压', v2Label: '舒张压' },
blood_sugar: { label: '血压, unit: 'mmol/L', hasValue2: false, v1Label: '血糖化 }, blood_sugar: { label: '血糖', unit: 'mmol/L', hasValue2: false, v1Label: '血糖值' },
weight: { label: '体重', unit: 'kg', hasValue2: false, v1Label: '体重' }, weight: { label: '体重', unit: 'kg', hasValue2: false, v1Label: '体重' },
heart_rate: { label: '心率', unit: 'bpm', hasValue2: false, v1Label: '心率' }, heart_rate: { label: '心率', unit: 'bpm', hasValue2: false, v1Label: '心率' },
temperature: { label: '体温', unit: '?, hasValue2: false, v1Label: '体温' }, temperature: { label: '体温', unit: '℃', hasValue2: false, v1Label: '体温' },
}; };
// ========== 慢病档案 Tab ========== // ========== 慢病档案 Tab ==========
...@@ -82,7 +84,7 @@ const ChronicRecordsTab: React.FC = () => { ...@@ -82,7 +84,7 @@ const ChronicRecordsTab: React.FC = () => {
{ title: '确诊医院', dataIndex: 'hospital', key: 'hospital', ellipsis: true }, { title: '确诊医院', dataIndex: 'hospital', key: 'hospital', ellipsis: true },
{ title: '主治医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 }, { title: '主治医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
{ {
title: '控制状态, dataIndex: 'control_status', key: 'control_status', width: 100, title: '控制状态', dataIndex: 'control_status', key: 'control_status', width: 100,
render: (v: string) => { render: (v: string) => {
const s = controlStatusMap[v] || { color: 'default', label: v }; const s = controlStatusMap[v] || { color: 'default', label: v };
return <Tag color={s.color}>{s.label}</Tag>; return <Tag color={s.color}>{s.label}</Tag>;
...@@ -97,7 +99,7 @@ const ChronicRecordsTab: React.FC = () => { ...@@ -97,7 +99,7 @@ const ChronicRecordsTab: React.FC = () => {
render: (_: any, r: ChronicRecord) => ( render: (_: any, r: ChronicRecord) => (
<Space> <Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} /> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Popconfirm title="确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}> <Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} /> <Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
...@@ -129,7 +131,7 @@ const ChronicRecordsTab: React.FC = () => { ...@@ -129,7 +131,7 @@ const ChronicRecordsTab: React.FC = () => {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="control_status" label="控制状态 initialValue="stable"> <Form.Item name="control_status" label="控制状态" initialValue="stable">
<Select options={Object.entries(controlStatusMap).map(([v, s]) => ({ value: v, label: s.label }))} /> <Select options={Object.entries(controlStatusMap).map(([v, s]) => ({ value: v, label: s.label }))} />
</Form.Item> </Form.Item>
</Col> </Col>
...@@ -147,7 +149,7 @@ const ChronicRecordsTab: React.FC = () => { ...@@ -147,7 +149,7 @@ const ChronicRecordsTab: React.FC = () => {
</Col> </Col>
</Row> </Row>
<Form.Item name="current_meds" label="当前用药"> <Form.Item name="current_meds" label="当前用药">
<TextArea rows={2} placeholder="当前服用的药物 /> <TextArea rows={2} placeholder="当前服用的药物" />
</Form.Item> </Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<TextArea rows={2} /> <TextArea rows={2} />
...@@ -175,7 +177,7 @@ const RenewalsTab: React.FC = () => { ...@@ -175,7 +177,7 @@ const RenewalsTab: React.FC = () => {
medicines: values.medicines?.split('\n').filter(Boolean) || [], medicines: values.medicines?.split('\n').filter(Boolean) || [],
}), }),
onSuccess: () => { onSuccess: () => {
message.success('续方申请已提); message.success('续方申请已提交');
qc.invalidateQueries({ queryKey: ['renewals'] }); qc.invalidateQueries({ queryKey: ['renewals'] });
setModalOpen(false); setModalOpen(false);
form.resetFields(); form.resetFields();
...@@ -196,9 +198,9 @@ const RenewalsTab: React.FC = () => { ...@@ -196,9 +198,9 @@ const RenewalsTab: React.FC = () => {
}; };
const statusMap: Record<string, { color: string; label: string }> = { const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待审核 }, pending: { color: 'orange', label: '待审核' },
approved: { color: 'green', label: '已通过' }, approved: { color: 'green', label: '已通过' },
rejected: { color: 'red', label: '已拒绝 }, rejected: { color: 'red', label: '已拒绝' },
}; };
const columns = [ const columns = [
...@@ -212,7 +214,7 @@ const RenewalsTab: React.FC = () => { ...@@ -212,7 +214,7 @@ const RenewalsTab: React.FC = () => {
}, },
{ title: '申请原因', dataIndex: 'reason', key: 'reason', ellipsis: true }, { title: '申请原因', dataIndex: 'reason', key: 'reason', ellipsis: true },
{ {
title: '状态, dataIndex: 'status', key: 'status', width: 90, title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => { const s = statusMap[v] || { color: 'default', label: v }; return <Tag color={s.color}>{s.label}</Tag>; }, render: (v: string) => { const s = statusMap[v] || { color: 'default', label: v }; return <Tag color={s.color}>{s.label}</Tag>; },
}, },
{ {
...@@ -254,13 +256,13 @@ const RenewalsTab: React.FC = () => { ...@@ -254,13 +256,13 @@ const RenewalsTab: React.FC = () => {
options={(records?.data || []).map(r => ({ value: r.id, label: r.disease_name }))} /> options={(records?.data || []).map(r => ({ value: r.id, label: r.disease_name }))} />
</Form.Item> </Form.Item>
<Form.Item name="disease_name" label="疾病名称" rules={[{ required: true }]}> <Form.Item name="disease_name" label="疾病名称" rules={[{ required: true }]}>
<Input placeholder="如:高血压 /> <Input placeholder="如:高血压" />
</Form.Item> </Form.Item>
<Form.Item name="medicines" label="申请药品(每行一种)" rules={[{ required: true }]}> <Form.Item name="medicines" label="申请药品(每行一种)" rules={[{ required: true }]}>
<TextArea rows={3} placeholder="苯磺酸氨氯地平片&#10;厄贝沙坦类 /> <TextArea rows={3} placeholder={'苯磺酸氨氯地平片\n厄贝沙坦片'} />
</Form.Item> </Form.Item>
<Form.Item name="reason" label="续方原因" rules={[{ required: true }]}> <Form.Item name="reason" label="续方原因" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="请说明续方原件 /> <TextArea rows={2} placeholder="请说明续方原因" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
...@@ -323,7 +325,7 @@ const RemindersTab: React.FC = () => { ...@@ -323,7 +325,7 @@ const RemindersTab: React.FC = () => {
render: (v: string) => { try { return (JSON.parse(v) as string[]).map((t, i) => <Tag key={i}>{t}</Tag>); } catch { return v; } }, render: (v: string) => { try { return (JSON.parse(v) as string[]).map((t, i) => <Tag key={i}>{t}</Tag>); } catch { return v; } },
}, },
{ {
title: '状态, dataIndex: 'is_active', key: 'is_active', width: 80, title: '状态', dataIndex: 'is_active', key: 'is_active', width: 80,
render: (v: boolean, r: MedicationReminder) => ( render: (v: boolean, r: MedicationReminder) => (
<Switch checked={v} size="small" onChange={() => toggleMutation.mutate(r.id)} /> <Switch checked={v} size="small" onChange={() => toggleMutation.mutate(r.id)} />
), ),
...@@ -333,7 +335,7 @@ const RemindersTab: React.FC = () => { ...@@ -333,7 +335,7 @@ const RemindersTab: React.FC = () => {
render: (_: any, r: MedicationReminder) => ( render: (_: any, r: MedicationReminder) => (
<Space> <Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} /> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Popconfirm title="确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}> <Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} /> <Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
...@@ -360,17 +362,17 @@ const RemindersTab: React.FC = () => { ...@@ -360,17 +362,17 @@ const RemindersTab: React.FC = () => {
<Form.Item name="medicine_name" label="药品名称" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="medicine_name" label="药品名称" rules={[{ required: true }]}><Input /></Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="dosage" label="剂量" rules={[{ required: true }]}><Input placeholder="如:1? /></Form.Item> <Form.Item name="dosage" label="剂量" rules={[{ required: true }]}><Input placeholder="如:1片" /></Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="frequency" label="服药频次" rules={[{ required: true }]}> <Form.Item name="frequency" label="服药频次" rules={[{ required: true }]}>
<Select options={['每日一次,'每日两次','每日三次','每周一次,'按需服用'].map(v => ({ value: v, label: v }))} /> <Select options={['每日一次', '每日两次', '每日三次', '每周一次', '按需服用'].map(v => ({ value: v, label: v }))} />
</Form.Item> </Form.Item>
<Form.Item name="remind_times" label="提醒时间" rules={[{ required: true }]}> <Form.Item name="remind_times" label="提醒时间" rules={[{ required: true }]}>
<TimePicker.RangePicker format="HH:mm" style={{ width: '100%' }} /> <TimePicker.RangePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}><Form.Item name="start_date" label="开始日><DatePicker style={{ width: '100%' }} /></Form.Item></Col> <Col span={12}><Form.Item name="start_date" label="开始日期"><DatePicker style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item name="end_date" label="结束日期"><DatePicker style={{ width: '100%' }} /></Form.Item></Col> <Col span={12}><Form.Item name="end_date" label="结束日期"><DatePicker style={{ width: '100%' }} /></Form.Item></Col>
</Row> </Row>
<Form.Item name="notes" label="备注"><Input /></Form.Item> <Form.Item name="notes" label="备注"><Input /></Form.Item>
...@@ -420,7 +422,7 @@ const MetricsTab: React.FC = () => { ...@@ -420,7 +422,7 @@ const MetricsTab: React.FC = () => {
const columns = [ const columns = [
{ {
title: '数量, key: 'value', title: '数值', key: 'value',
render: (_: any, r: HealthMetric) => ( render: (_: any, r: HealthMetric) => (
<Text strong style={{ color: '#1890ff' }}> <Text strong style={{ color: '#1890ff' }}>
{meta.hasValue2 ? `${r.value1}/${r.value2}` : r.value1} {r.unit} {meta.hasValue2 ? `${r.value1}/${r.value2}` : r.value1} {r.unit}
...@@ -435,15 +437,14 @@ const MetricsTab: React.FC = () => { ...@@ -435,15 +437,14 @@ const MetricsTab: React.FC = () => {
{ {
title: '操作', key: 'action', width: 80, title: '操作', key: 'action', width: 80,
render: (_: any, r: HealthMetric) => ( render: (_: any, r: HealthMetric) => (
<Popconfirm title="确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}> <Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} /> <Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
), ),
}, },
]; ];
// const latest = list[0];
const latest = list[0];
return ( return (
<> <>
...@@ -459,18 +460,18 @@ const MetricsTab: React.FC = () => { ...@@ -459,18 +460,18 @@ const MetricsTab: React.FC = () => {
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#f0f5ff' }}> <Card size="small" style={{ borderRadius: 8, background: '#f0f5ff' }}>
<Statistic title="最新增 value={meta.hasValue2 ? `${latest.value1}/${latest.value2}` : latest.value1} <Statistic title="最新值" value={meta.hasValue2 ? `${latest.value1}/${latest.value2}` : latest.value1}
suffix={meta.unit} valueStyle={{ color: '#1890ff' }} /> suffix={meta.unit} valueStyle={{ color: '#1890ff' }} />
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#f6ffed' }}> <Card size="small" style={{ borderRadius: 8, background: '#f6ffed' }}>
<Statistic title="记录次数" value={list.length} suffix="? valueStyle={{ color: '#52c41a' }} /> <Statistic title="记录次数" value={list.length} suffix="次" valueStyle={{ color: '#52c41a' }} />
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#fff7e6' }}> <Card size="small" style={{ borderRadius: 8, background: '#fff7e6' }}>
<Statistic title="最近记录 value={dayjs(latest.recorded_at).format('MM-DD')} <Statistic title="最近记录" value={dayjs(latest.recorded_at).format('MM-DD')}
valueStyle={{ color: '#fa8c16' }} /> valueStyle={{ color: '#fa8c16' }} />
</Card> </Card>
</Col> </Col>
...@@ -508,7 +509,7 @@ const MetricsTab: React.FC = () => { ...@@ -508,7 +509,7 @@ const MetricsTab: React.FC = () => {
); );
}; };
// ========== 主页?========== // ========== 主页面 ==========
const PatientChronicPage: React.FC = () => { const PatientChronicPage: React.FC = () => {
const tabItems = [ const tabItems = [
{ key: 'records', label: <><HeartOutlined /> 慢病档案</>, children: <ChronicRecordsTab /> }, { key: 'records', label: <><HeartOutlined /> 慢病档案</>, children: <ChronicRecordsTab /> },
......
...@@ -32,26 +32,23 @@ const ConsultCreatePage: React.FC = () => { ...@@ -32,26 +32,23 @@ const ConsultCreatePage: React.FC = () => {
const [preConsult, setPreConsult] = useState<PreConsultResponse | null>(null); const [preConsult, setPreConsult] = useState<PreConsultResponse | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const doctorId = searchParams.get('doctor_id') || ''; const doctorId = searchParams?.get('doctor_id') || '';
const consultType = (searchParams.get('type') || 'text') as 'text' | 'video'; const consultType = (searchParams?.get('type') || 'text') as 'text' | 'video';
const preConsultId = searchParams.get('pre_consult_id') || ''; const preConsultId = searchParams?.get('pre_consult_id') || '';
const chiefComplaintParam = searchParams.get('chief_complaint') || ''; const chiefComplaintParam = searchParams?.get('chief_complaint') || '';
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
// 获取医生信息
if (doctorId) { if (doctorId) {
const res = await doctorApi.getDoctorDetail(doctorId); const res = await doctorApi.getDoctorDetail(doctorId);
setDoctor(res.data); setDoctor(res.data);
} }
// 如果有预问诊ID,获取预问诊详情
if (preConsultId) { if (preConsultId) {
const res = await preConsultApi.getDetail(preConsultId); const res = await preConsultApi.getDetail(preConsultId);
setPreConsult(res.data); setPreConsult(res.data);
//
form.setFieldsValue({ form.setFieldsValue({
chief_complaint: res.data.chief_complaint || chiefComplaintParam, chief_complaint: res.data.chief_complaint || chiefComplaintParam,
}); });
...@@ -80,17 +77,16 @@ const ConsultCreatePage: React.FC = () => { ...@@ -80,17 +77,16 @@ const ConsultCreatePage: React.FC = () => {
pre_consult_id: preConsultId || undefined, pre_consult_id: preConsultId || undefined,
}); });
message.success('问诊创建成功!); message.success('问诊创建成功!');
// if (consultType === 'video') {
if (consultType === 'video') {
router.push(`/patient/consult/video/${res.data.id}`); router.push(`/patient/consult/video/${res.data.id}`);
} else { } else {
router.push(`/patient/consult/text/${res.data.id}`); router.push(`/patient/consult/text/${res.data.id}`);
} }
} catch (error: any) { } catch (error: any) {
if (error?.errorFields) return; if (error?.errorFields) return;
message.error('创建问诊失败: ' + (error?.response?.data?.message || error?.message || '请稍后重试)); message.error('创建问诊失败: ' + (error?.response?.data?.message || error?.message || '请稍后重试'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
...@@ -99,7 +95,7 @@ const ConsultCreatePage: React.FC = () => { ...@@ -99,7 +95,7 @@ const ConsultCreatePage: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" tip="加载中.." /> <Spin size="large" tip="加载中..." />
</div> </div>
); );
} }
...@@ -127,7 +123,7 @@ const ConsultCreatePage: React.FC = () => { ...@@ -127,7 +123,7 @@ const ConsultCreatePage: React.FC = () => {
<Text type="secondary" className="text-xs!">{doctor.hospital}</Text> <Text type="secondary" className="text-xs!">{doctor.hospital}</Text>
<div className="mt-0.5"> <div className="mt-0.5">
<Tag color="orange">¥{(doctor.price / 100).toFixed(0)}/次</Tag> <Tag color="orange">¥{(doctor.price / 100).toFixed(0)}/次</Tag>
<Text type="secondary" className="text-xs!">评分 {doctor.rating} · {doctor.consult_count}?/Text> <Text type="secondary" className="text-xs!">评分 {doctor.rating} · {doctor.consult_count} 次问诊</Text>
</div> </div>
</div> </div>
</Space> </Space>
...@@ -154,8 +150,8 @@ const ConsultCreatePage: React.FC = () => { ...@@ -154,8 +150,8 @@ const ConsultCreatePage: React.FC = () => {
<Card size="small"> <Card size="small">
<Form form={form} layout="vertical" size="small"> <Form form={form} layout="vertical" size="small">
<Form.Item label="主诉(症状描述)" name="chief_complaint" rules={[{ required: true, message: '请描述您的症状 }]}> <Form.Item label="主诉(症状描述)" name="chief_complaint" rules={[{ required: true, message: '请描述您的症状' }]}>
<TextArea rows={3} placeholder="请详细描述您的症状.." maxLength={500} showCount /> <TextArea rows={3} placeholder="请详细描述您的症状..." maxLength={500} showCount />
</Form.Item> </Form.Item>
<Form.Item label="既往病史" name="medical_history"> <Form.Item label="既往病史" name="medical_history">
<TextArea rows={2} placeholder="如有慢性病、手术史等请填写" /> <TextArea rows={2} placeholder="如有慢性病、手术史等请填写" />
...@@ -165,7 +161,7 @@ const ConsultCreatePage: React.FC = () => { ...@@ -165,7 +161,7 @@ const ConsultCreatePage: React.FC = () => {
<Space size={8}> <Space size={8}>
<Button size="small" onClick={() => router.back()}>取消</Button> <Button size="small" onClick={() => router.back()}>取消</Button>
<Button type="primary" size="small" onClick={handleSubmit} loading={submitting}> <Button type="primary" size="small" onClick={handleSubmit} loading={submitting}>
{submitting ? '提交中..' : `发起${consultType === 'video' ? '视频' : '图文'}问诊`} {submitting ? '提交中...' : `发起${consultType === 'video' ? '视频' : '图文'}问诊`}
</Button> </Button>
</Space> </Space>
</Form.Item> </Form.Item>
...@@ -176,4 +172,3 @@ const ConsultCreatePage: React.FC = () => { ...@@ -176,4 +172,3 @@ const ConsultCreatePage: React.FC = () => {
}; };
export default ConsultCreatePage; export default ConsultCreatePage;
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { Card, Row, Col, Avatar, Tag, Rate, Button, Typography, Space, Divider, Descriptions } from 'antd'; import { Card, Row, Col, Avatar, Tag, Rate, Button, Typography, Space, Divider, Descriptions } from 'antd';
...@@ -14,10 +14,11 @@ import { useQuery } from '@tanstack/react-query'; ...@@ -14,10 +14,11 @@ import { useQuery } from '@tanstack/react-query';
import { doctorApi } from '../../../api/doctor'; import { doctorApi } from '../../../api/doctor';
import ConsultCreateModal from '../../../components/ConsultCreateModal'; import ConsultCreateModal from '../../../components/ConsultCreateModal';
const { Title, Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
const DoctorDetailPage: React.FC = () => { const DoctorDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = params?.id;
const [consultModalOpen, setConsultModalOpen] = useState(false); const [consultModalOpen, setConsultModalOpen] = useState(false);
const [consultType, setConsultType] = useState<'text' | 'video'>('text'); const [consultType, setConsultType] = useState<'text' | 'video'>('text');
...@@ -53,7 +54,7 @@ const DoctorDetailPage: React.FC = () => { ...@@ -53,7 +54,7 @@ const DoctorDetailPage: React.FC = () => {
<Rate disabled value={doctor.rating} style={{ fontSize: 12 }} /> <Rate disabled value={doctor.rating} style={{ fontSize: 12 }} />
<Text type="secondary" className="text-xs!">({doctor.rating.toFixed(1)})</Text> <Text type="secondary" className="text-xs!">({doctor.rating.toFixed(1)})</Text>
<Divider type="vertical" /> <Divider type="vertical" />
<Text type="secondary" className="text-xs!">问诊 {doctor.consult_count}</Text> <Text type="secondary" className="text-xs!">问诊 {doctor.consult_count} 次</Text>
</Space> </Space>
</Col> </Col>
<Col> <Col>
...@@ -82,13 +83,13 @@ const DoctorDetailPage: React.FC = () => { ...@@ -82,13 +83,13 @@ const DoctorDetailPage: React.FC = () => {
</Card> </Card>
<Card title={<span className="text-xs font-semibold">医生简介</span>} size="small"> <Card title={<span className="text-xs font-semibold">医生简介</span>} size="small">
<Paragraph className="text-xs! mb-0!">{doctor.introduction || '暂无简介}</Paragraph> <Paragraph className="text-xs! mb-0!">{doctor.introduction || '暂无简介'}</Paragraph>
</Card> </Card>
<Card title={<span className="text-xs font-semibold">排班信息</span>} <Card title={<span className="text-xs font-semibold">排班信息</span>}
extra={<Button type="link" size="small" icon={<CalendarOutlined />}>更多</Button>} size="small"> extra={<Button type="link" size="small" icon={<CalendarOutlined />}>更多</Button>} size="small">
<Descriptions column={2} size="small"> <Descriptions column={2} size="small">
<Descriptions.Item label="出诊状> <Descriptions.Item label="出诊状态">
{doctor.is_online ? <Tag color="green">在线</Tag> : <Tag color="default">未排班</Tag>} {doctor.is_online ? <Tag color="green">在线</Tag> : <Tag color="default">未排班</Tag>}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="资质认证"> <Descriptions.Item label="资质认证">
...@@ -97,7 +98,6 @@ const DoctorDetailPage: React.FC = () => { ...@@ -97,7 +98,6 @@ const DoctorDetailPage: React.FC = () => {
</Descriptions> </Descriptions>
</Card> </Card>
{/* 创建问诊弹窗 */}
<ConsultCreateModal <ConsultCreateModal
open={consultModalOpen} open={consultModalOpen}
onCancel={() => setConsultModalOpen(false)} onCancel={() => setConsultModalOpen(false)}
...@@ -109,4 +109,3 @@ const DoctorDetailPage: React.FC = () => { ...@@ -109,4 +109,3 @@ const DoctorDetailPage: React.FC = () => {
}; };
export default DoctorDetailPage; export default DoctorDetailPage;
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Card, Row, Col, Input, Select, List, Avatar, Tag, Rate, Button, Space, Typography } from 'antd'; import { Card, Row, Col, Input, Select, List, Avatar, Tag, Rate, Button, Space, Typography } from 'antd';
...@@ -13,8 +13,8 @@ const { Text } = Typography; ...@@ -13,8 +13,8 @@ const { Text } = Typography;
const PatientDoctorsPage: React.FC = () => { const PatientDoctorsPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [keyword, setKeyword] = useState(searchParams.get('keyword') || ''); const [keyword, setKeyword] = useState(searchParams?.get('keyword') || '');
const [departmentId, setDepartmentId] = useState(searchParams.get('department') || ''); const [departmentId, setDepartmentId] = useState(searchParams?.get('department') || '');
const [sortBy, setSortBy] = useState<'rating' | 'consult_count' | 'price'>('rating'); const [sortBy, setSortBy] = useState<'rating' | 'consult_count' | 'price'>('rating');
const [consultModalOpen, setConsultModalOpen] = useState(false); const [consultModalOpen, setConsultModalOpen] = useState(false);
const [selectedDoctor, setSelectedDoctor] = useState<{ id: string; type: 'text' | 'video' } | null>(null); const [selectedDoctor, setSelectedDoctor] = useState<{ id: string; type: 'text' | 'video' } | null>(null);
...@@ -68,9 +68,9 @@ const PatientDoctorsPage: React.FC = () => { ...@@ -68,9 +68,9 @@ const PatientDoctorsPage: React.FC = () => {
style={{ width: 110 }} style={{ width: 110 }}
size="small" size="small"
options={[ options={[
{ label: '评分最高, value: 'rating' }, { label: '评分最高', value: 'rating' },
{ label: '问诊最高, value: 'consult_count' }, { label: '问诊最多', value: 'consult_count' },
{ label: '价格最高, value: 'price' }, { label: '价格最低', value: 'price' },
]} ]}
/> />
</div> </div>
...@@ -95,10 +95,10 @@ const PatientDoctorsPage: React.FC = () => { ...@@ -95,10 +95,10 @@ const PatientDoctorsPage: React.FC = () => {
<div className="text-xs text-gray-400 mb-1">{doctor.hospital}</div> <div className="text-xs text-gray-400 mb-1">{doctor.hospital}</div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Rate disabled defaultValue={doctor.rating} className="text-xs!" /> <Rate disabled defaultValue={doctor.rating} className="text-xs!" />
<span className="text-[11px] text-gray-400">问诊 {doctor.consult_count}</span> <span className="text-[11px] text-gray-400">问诊 {doctor.consult_count} 次</span>
</div> </div>
<div className="text-xs text-gray-500 line-clamp-1"> <div className="text-xs text-gray-500 line-clamp-1">
擅长:{doctor.specialties?.join('?) || doctor.introduction} 擅长:{doctor.specialties?.join('、') || doctor.introduction}
</div> </div>
</Col> </Col>
<Col> <Col>
...@@ -117,7 +117,7 @@ const PatientDoctorsPage: React.FC = () => { ...@@ -117,7 +117,7 @@ const PatientDoctorsPage: React.FC = () => {
</Space> </Space>
<div> <div>
<Button type="link" size="small" className="text-xs!" onClick={() => router.push(`/patient/doctors/${doctor.id}`)}> <Button type="link" size="small" className="text-xs!" onClick={() => router.push(`/patient/doctors/${doctor.id}`)}>
详情 ? 查看详情 &gt;
</Button> </Button>
</div> </div>
</div> </div>
...@@ -140,4 +140,3 @@ const PatientDoctorsPage: React.FC = () => { ...@@ -140,4 +140,3 @@ const PatientDoctorsPage: React.FC = () => {
}; };
export default PatientDoctorsPage; export default PatientDoctorsPage;
'use client'; 'use client';
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Row, Col } from 'antd'; import { Card, Typography, Button, Row, Col, Table, Tag, Space, Empty, Tabs, Statistic, message } from 'antd';
import { PayCircleOutlined, WechatOutlined, AlipayOutlined, FileTextOutlined, HistoryOutlined } from '@ant-design/icons'; import {
PayCircleOutlined,
WalletOutlined,
FileTextOutlined,
HistoryOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { consultApi, type Consultation } from '../../../api/consult';
import { prescriptionPatientApi, type Prescription } from '../../../api/prescription';
import dayjs from 'dayjs';
const { Text } = Typography; const { Text } = Typography;
const features = [ interface PaymentRecord {
{ icon: <WechatOutlined style={{ fontSize: 28, color: '#07c160' }} />, title: '微信/支付宝支付', desc: '便捷的在线支付方式' }, id: string;
{ icon: <PayCircleOutlined style={{ fontSize: 28, color: '#1677ff' }} />, title: '医保扣费', desc: '支持医保在线结算' }, type: string;
{ icon: <FileTextOutlined style={{ fontSize: 28, color: '#fa8c16' }} />, title: '账单明细', desc: '详细的费用清单' }, description: string;
{ icon: <HistoryOutlined style={{ fontSize: 28, color: '#722ed1' }} />, title: '支付记录', desc: '历史支付记录查询' }, amount: number;
]; status: 'paid' | 'pending' | 'refunded';
created_at: string;
}
const PatientPaymentPage: React.FC = () => { const PatientPaymentPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false);
const [consults, setConsults] = useState<Consultation[]>([]);
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [consultRes, rxRes] = await Promise.allSettled([
consultApi.getConsultList(),
prescriptionPatientApi.getList({ page: 1, page_size: 20 }),
]);
if (consultRes.status === 'fulfilled') setConsults(consultRes.value.data || []);
if (rxRes.status === 'fulfilled') setPrescriptions(rxRes.value.data?.list || []);
} catch {
// 静默处理
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const paymentRecords: PaymentRecord[] = [
...consults.map((c) => ({
id: c.id,
type: c.type === 'video' ? '视频问诊' : '图文问诊',
description: `${c.doctor_name} - ${c.chief_complaint?.substring(0, 20) || '问诊'}`,
amount: c.type === 'video' ? 10000 : 5000,
status: 'paid' as const,
created_at: c.created_at,
})),
...prescriptions.map((p) => ({
id: p.id,
type: '处方购药',
description: `处方 ${p.prescription_no} - ${p.diagnosis}`,
amount: p.total_amount,
status: (p.status === 'dispensed' || p.status === 'completed' ? 'paid' : 'pending') as 'paid' | 'pending',
created_at: p.created_at,
})),
].sort((a, b) => dayjs(b.created_at).unix() - dayjs(a.created_at).unix());
const totalPaid = paymentRecords.filter(r => r.status === 'paid').reduce((sum, r) => sum + r.amount, 0);
const totalPending = paymentRecords.filter(r => r.status === 'pending').reduce((sum, r) => sum + r.amount, 0);
const statusMap: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'orange', label: '待支付', icon: <ClockCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <PayCircleOutlined /> },
};
const columns = [
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 140,
render: (v: string) => <Text className="text-xs!">{dayjs(v).format('MM-DD HH:mm')}</Text>,
},
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="blue">{v}</Tag> },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (v: number) => <Text strong style={{ color: '#f5222d' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => {
const s = statusMap[v];
return s ? <Tag color={s.color} icon={s.icon}>{s.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: PaymentRecord) => (
r.status === 'pending'
? <Button type="primary" size="small" onClick={() => message.info('支付功能对接中')}>去支付</Button>
: <Button type="link" size="small">详情</Button>
),
},
];
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-gray-800 m-0">在线支付</h4> <h4 className="text-sm font-bold text-gray-800 m-0">
<WalletOutlined className="mr-1" />支付管理
</h4>
<Button type="primary" size="small" onClick={() => router.push('/patient/consult')}>查看问诊</Button> <Button type="primary" size="small" onClick={() => router.push('/patient/consult')}>查看问诊</Button>
</div> </div>
<Row gutter={[8, 8]}>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #52c41a' }}>
<Statistic title="已支付总额" value={totalPaid / 100} precision={2} prefix="¥" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}>
<Statistic title="待支付" value={totalPending / 100} precision={2} prefix="¥" valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #1890ff' }}>
<Statistic title="问诊支付" value={consults.length} suffix="笔" />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #722ed1' }}>
<Statistic title="购药支付" value={prescriptions.length} suffix="笔" />
</Card>
</Col>
</Row>
<Card size="small"> <Card size="small">
<div className="text-center py-4"> <Tabs defaultActiveKey="all" size="small" items={[
<PayCircleOutlined className="text-4xl text-yellow-400 mb-2" /> {
<div className="text-xs text-gray-400">支付方式说明</div> key: 'all',
</div> label: <span><FileTextOutlined /> 全部记录</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords}
rowKey="id"
size="small"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: <Empty description="暂无支付记录" /> }}
/>
),
},
{
key: 'pending',
label: <span><ClockCircleOutlined /> 待支付</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords.filter(r => r.status === 'pending')}
rowKey="id"
size="small"
loading={loading}
pagination={false}
locale={{ emptyText: <Empty description="暂无待支付订单" /> }}
/>
),
},
{
key: 'paid',
label: <span><CheckCircleOutlined /> 已支付</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords.filter(r => r.status === 'paid')}
rowKey="id"
size="small"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: <Empty description="暂无支付记录" /> }}
/>
),
},
]} />
</Card>
<Card size="small" title={<span className="text-xs font-semibold">支付方式</span>}>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
{features.map((f) => ( {[
<Col xs={24} sm={12} key={f.title}> { icon: <PayCircleOutlined style={{ fontSize: 24, color: '#07c160' }} />, title: '微信支付', desc: '推荐' },
<Card size="small" className="bg-gray-50"> { icon: <PayCircleOutlined style={{ fontSize: 24, color: '#1677ff' }} />, title: '支付宝', desc: '便捷支付' },
<div className="flex items-center gap-2"> { icon: <WalletOutlined style={{ fontSize: 24, color: '#fa8c16' }} />, title: '医保支付', desc: '在线结算' },
{f.icon} ].map((item) => (
<div> <Col span={8} key={item.title}>
<div className="text-xs font-medium">{f.title}</div> <Card size="small" hoverable className="text-center">
<Text type="secondary" className="text-[11px]!">{f.desc}</Text> <div className="mb-1">{item.icon}</div>
</div> <div className="text-xs font-medium">{item.title}</div>
</div> <Text type="secondary" className="text-[11px]!">{item.desc}</Text>
</Card> </Card>
</Col> </Col>
))} ))}
...@@ -48,4 +211,3 @@ const PatientPaymentPage: React.FC = () => { ...@@ -48,4 +211,3 @@ const PatientPaymentPage: React.FC = () => {
}; };
export default PatientPaymentPage; export default PatientPaymentPage;
'use client'; 'use client';
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Row, Col } from 'antd'; import { Card, Typography, Button, Row, Col, Table, Tag, Space, Empty, Tabs, Steps, message, Modal, Descriptions } from 'antd';
import { ShoppingCartOutlined, EnvironmentOutlined, CheckCircleOutlined, HistoryOutlined } from '@ant-design/icons'; import {
ShoppingCartOutlined,
EnvironmentOutlined,
CheckCircleOutlined,
HistoryOutlined,
MedicineBoxOutlined,
ClockCircleOutlined,
CarOutlined,
InboxOutlined,
} from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { prescriptionPatientApi, type Prescription } from '../../../api/prescription';
import dayjs from 'dayjs';
const { Text } = Typography; const { Text } = Typography;
const features = [ interface OrderItem {
{ icon: <EnvironmentOutlined style={{ fontSize: 28, color: '#1677ff' }} />, title: '配送进度跟踪', desc: '实时查看药品配送状态' }, id: string;
{ icon: <ShoppingCartOutlined style={{ fontSize: 28, color: '#eb2f96' }} />, title: '物流地图', desc: '可视化配送路线' }, prescription_no: string;
{ icon: <CheckCircleOutlined style={{ fontSize: 28, color: '#52c41a' }} />, title: '签收确认', desc: '在线确认收货' }, doctor_name: string;
{ icon: <HistoryOutlined style={{ fontSize: 28, color: '#fa8c16' }} />, title: '订单历史', desc: '查看历史药品订单' }, diagnosis: string;
medicines: string[];
total_amount: number;
status: 'pending' | 'confirmed' | 'shipping' | 'delivered' | 'completed';
created_at: string;
}
const statusSteps = [
{ title: '待发货', icon: <InboxOutlined /> },
{ title: '配送中', icon: <CarOutlined /> },
{ title: '已送达', icon: <EnvironmentOutlined /> },
{ title: '已完成', icon: <CheckCircleOutlined /> },
]; ];
const getStepIndex = (status: string) => {
switch (status) {
case 'pending': return 0;
case 'confirmed': return 0;
case 'shipping': return 1;
case 'delivered': return 2;
case 'completed': return 3;
default: return 0;
}
};
const PatientPharmacyOrderPage: React.FC = () => { const PatientPharmacyOrderPage: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false);
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
const [detailModal, setDetailModal] = useState<OrderItem | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = await prescriptionPatientApi.getList({ page: 1, page_size: 50 });
setPrescriptions(res.data?.list || []);
} catch {
// 静默处理
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const orders: OrderItem[] = prescriptions
.filter(p => p.status === 'dispensed' || p.status === 'completed' || p.status === 'signed')
.map((p) => ({
id: p.id,
prescription_no: p.prescription_no,
doctor_name: p.doctor_name,
diagnosis: p.diagnosis,
medicines: (p.items || []).map(item => item.medicine_name),
total_amount: p.total_amount,
status: p.status === 'completed' ? 'completed' : p.status === 'dispensed' ? 'shipping' : 'pending',
created_at: p.created_at,
}));
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待发货' },
confirmed: { color: 'blue', label: '已确认' },
shipping: { color: 'processing', label: '配送中' },
delivered: { color: 'cyan', label: '已送达' },
completed: { color: 'green', label: '已完成' },
};
const columns = [
{
title: '处方号', dataIndex: 'prescription_no', key: 'prescription_no', width: 140,
render: (v: string) => <Text strong className="text-xs!">{v}</Text>,
},
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 80 },
{ title: '诊断', dataIndex: 'diagnosis', key: 'diagnosis', width: 120, ellipsis: true },
{
title: '药品', dataIndex: 'medicines', key: 'medicines',
render: (v: string[]) => (
<Space size={[4, 4]} wrap>
{v.slice(0, 3).map((m, i) => <Tag key={i} color="blue">{m}</Tag>)}
{v.length > 3 && <Tag>+{v.length - 3}</Tag>}
</Space>
),
},
{
title: '金额', dataIndex: 'total_amount', key: 'total_amount', width: 90,
render: (v: number) => <Text strong style={{ color: '#f5222d' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => {
const s = statusMap[v];
return s ? <Tag color={s.color}>{s.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 110,
render: (v: string) => <Text className="text-xs!" type="secondary">{dayjs(v).format('MM-DD HH:mm')}</Text>,
},
{
title: '操作', key: 'action', width: 100,
render: (_: any, r: OrderItem) => (
<Space size={4}>
<Button type="link" size="small" onClick={() => setDetailModal(r)}>详情</Button>
{r.status === 'delivered' && (
<Button type="primary" size="small" onClick={() => message.success('确认收货成功')}>确认收货</Button>
)}
</Space>
),
},
];
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-gray-800 m-0">药品配送</h4> <h4 className="text-sm font-bold text-gray-800 m-0">
<ShoppingCartOutlined className="mr-1" />药品配送
</h4>
<Button type="primary" size="small" onClick={() => router.push('/patient/prescription')}>查看处方</Button> <Button type="primary" size="small" onClick={() => router.push('/patient/prescription')}>查看处方</Button>
</div> </div>
<Row gutter={[8, 8]}>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #1890ff' }}>
<div className="flex items-center gap-2">
<MedicineBoxOutlined className="text-xl text-[#1890ff]" />
<div>
<div className="text-xs text-gray-500">全部订单</div>
<div className="text-lg font-bold">{orders.length}</div>
</div>
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}>
<div className="flex items-center gap-2">
<ClockCircleOutlined className="text-xl text-[#fa8c16]" />
<div>
<div className="text-xs text-gray-500">待发货</div>
<div className="text-lg font-bold">{orders.filter(o => o.status === 'pending').length}</div>
</div>
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #722ed1' }}>
<div className="flex items-center gap-2">
<CarOutlined className="text-xl text-[#722ed1]" />
<div>
<div className="text-xs text-gray-500">配送中</div>
<div className="text-lg font-bold">{orders.filter(o => o.status === 'shipping').length}</div>
</div>
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #52c41a' }}>
<div className="flex items-center gap-2">
<CheckCircleOutlined className="text-xl text-[#52c41a]" />
<div>
<div className="text-xs text-gray-500">已完成</div>
<div className="text-lg font-bold">{orders.filter(o => o.status === 'completed').length}</div>
</div>
</div>
</Card>
</Col>
</Row>
<Card size="small"> <Card size="small">
<div className="text-center py-4"> <Tabs defaultActiveKey="all" size="small" items={[
<ShoppingCartOutlined className="text-4xl text-pink-500 mb-2" /> {
<div className="text-xs text-gray-400">功能开发中,敬请期待</div> key: 'all',
</div> label: <span><ShoppingCartOutlined /> 全部订单</span>,
<Row gutter={[8, 8]}> children: (
{features.map((f) => ( <Table columns={columns} dataSource={orders} rowKey="id" size="small" loading={loading}
<Col xs={24} sm={12} key={f.title}> pagination={{ pageSize: 10, size: 'small' }}
<Card size="small" className="bg-gray-50"> locale={{ emptyText: <Empty description="暂无药品订单" /> }} />
<div className="flex items-center gap-2"> ),
{f.icon} },
<div> {
<div className="text-xs font-medium">{f.title}</div> key: 'shipping',
<Text type="secondary" className="text-[11px]!">{f.desc}</Text> label: <span><CarOutlined /> 配送中</span>,
</div> children: (
</div> <Table columns={columns} dataSource={orders.filter(o => o.status === 'shipping')} rowKey="id" size="small"
</Card> loading={loading} pagination={false}
</Col> locale={{ emptyText: <Empty description="暂无配送中的订单" /> }} />
))} ),
</Row> },
{
key: 'completed',
label: <span><CheckCircleOutlined /> 已完成</span>,
children: (
<Table columns={columns} dataSource={orders.filter(o => o.status === 'completed')} rowKey="id" size="small"
loading={loading} pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: <Empty description="暂无已完成订单" /> }} />
),
},
]} />
</Card> </Card>
<Modal title="订单详情" open={!!detailModal} onCancel={() => setDetailModal(null)} footer={null} width={600}>
{detailModal && (
<div className="space-y-3">
<Steps current={getStepIndex(detailModal.status)} size="small" items={statusSteps} />
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="处方号">{detailModal.prescription_no}</Descriptions.Item>
<Descriptions.Item label="医生">{detailModal.doctor_name}</Descriptions.Item>
<Descriptions.Item label="诊断" span={2}>{detailModal.diagnosis}</Descriptions.Item>
<Descriptions.Item label="药品" span={2}>
<Space wrap size={4}>
{detailModal.medicines.map((m, i) => <Tag key={i} color="blue">{m}</Tag>)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Text strong style={{ color: '#f5222d' }}>¥{(detailModal.total_amount / 100).toFixed(2)}</Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}>{statusMap[detailModal.status]?.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="下单时间" span={2}>
{dayjs(detailModal.created_at).format('YYYY-MM-DD HH:mm')}
</Descriptions.Item>
</Descriptions>
</div>
)}
</Modal>
</div> </div>
); );
}; };
export default PatientPharmacyOrderPage; export default PatientPharmacyOrderPage;
...@@ -24,7 +24,8 @@ const statusMap: Record<string, { text: string; color: string }> = { ...@@ -24,7 +24,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
}; };
const PatientTextConsultPage: React.FC = () => { const PatientTextConsultPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = params?.id;
const router = useRouter(); const router = useRouter();
const { user } = useUserStore(); const { user } = useUserStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
......
...@@ -15,7 +15,8 @@ import { useVideoCall } from '../../../hooks/useVideoCall'; ...@@ -15,7 +15,8 @@ import { useVideoCall } from '../../../hooks/useVideoCall';
const { Title } = Typography; const { Title } = Typography;
const PatientVideoConsultPage: React.FC = () => { const PatientVideoConsultPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = params?.id;
const router = useRouter(); const router = useRouter();
const localVideoRef = useRef<HTMLVideoElement>(null); const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null); const remoteVideoRef = useRef<HTMLVideoElement>(null);
......
import React, { Suspense, lazy } from 'react'; /**
import { createBrowserRouter, Navigate } from 'react-router-dom'; * 路由配置参考文件(遗留)
import { Spin } from 'antd'; *
import AuthGuard from '../components/AuthGuard'; * 项目已迁移至 Next.js App Router,实际路由定义在 src/app/ 目录下。
import RoleRedirect from '../components/RoleRedirect'; * 此文件仅作为路由结构的参考文档,不再被任何代码引用。
*/
// ==================== 布局组件 ====================
const PatientLayout = lazy(() => import('../components/Layout/PatientLayout')); // 路由结构概览
const DoctorLayout = lazy(() => import('../components/Layout/DoctorLayout')); export const routeMap = {
const AdminLayout = lazy(() => import('../components/Layout/AdminLayout')); // 公共路由
login: '/login',
// ==================== 公共页面 ==================== register: '/register',
const LoginPage = lazy(() => import('../pages/Login'));
const RegisterPage = lazy(() => import('../pages/Register')); // 患者端
patientHome: '/patient/home',
// ==================== 患者端页面 ==================== patientDoctors: '/patient/doctors',
const PatientHomePage = lazy(() => import('../pages/patient/Home')); patientDoctorDetail: '/patient/doctors/:id',
const PatientDoctorsPage = lazy(() => import('../pages/patient/Doctors')); patientConsult: '/patient/consult',
const PatientDoctorDetailPage = lazy(() => import('../pages/patient/DoctorDetail')); patientConsultCreate: '/patient/consult/create',
const PatientPreConsultPage = lazy(() => import('../pages/patient/PreConsult')); patientConsultText: '/patient/consult/text/:id',
const PatientConsultListPage = lazy(() => import('../pages/patient/ConsultList')); patientConsultVideo: '/patient/consult/video/:id',
const PatientTextConsultPage = lazy(() => import('../pages/patient/TextConsult')); patientPreConsult: '/patient/pre-consult',
const PatientVideoConsultPage = lazy(() => import('../pages/patient/VideoConsult')); patientPrescription: '/patient/prescription',
const PatientPrescriptionPage = lazy(() => import('../pages/patient/Prescription')); patientPayment: '/patient/payment',
const PatientPaymentPage = lazy(() => import('../pages/patient/Payment')); patientPharmacyOrder: '/patient/pharmacy/order',
const PatientPharmacyOrderPage = lazy(() => import('../pages/patient/PharmacyOrder')); patientChronic: '/patient/chronic',
const PatientChronicPage = lazy(() => import('../pages/patient/Chronic')); patientHealthRecords: '/patient/health-records',
const PatientHealthRecordsPage = lazy(() => import('../pages/patient/HealthRecords')); patientProfile: '/patient/profile',
const PatientProfilePage = lazy(() => import('../pages/patient/Profile'));
const PatientConsultCreatePage = lazy(() => import('../pages/patient/ConsultCreate')); // 医生端
doctorWorkbench: '/doctor/workbench',
// ==================== 医生端页面 ==================== doctorConsult: '/doctor/consult',
const DoctorWorkbenchPage = lazy(() => import('../pages/doctor/Workbench')); doctorAIAssist: '/doctor/ai-assist',
const DoctorConsultPage = lazy(() => import('../pages/doctor/Consult')); doctorChronicReview: '/doctor/chronic/review',
const DoctorAIAssistPage = lazy(() => import('../pages/doctor/AIAssist')); doctorPatient: '/doctor/patient',
const DoctorChronicReviewPage = lazy(() => import('../pages/doctor/ChronicReview')); doctorPatientDetail: '/doctor/patient/:id',
const DoctorPatientRecordPage = lazy(() => import('../pages/doctor/PatientRecord')); doctorSchedule: '/doctor/schedule',
const DoctorSchedulePage = lazy(() => import('../pages/doctor/Schedule')); doctorIncome: '/doctor/income',
const DoctorIncomePage = lazy(() => import('../pages/doctor/Income')); doctorProfile: '/doctor/profile',
const DoctorProfilePage = lazy(() => import('../pages/doctor/Profile')); doctorCertification: '/doctor/certification',
const DoctorCertificationPage = lazy(() => import('../pages/doctor/Certification'));
// 管理端
// ==================== 管理端页面 ==================== adminDashboard: '/admin/dashboard',
const AdminDashboardPage = lazy(() => import('../pages/admin/Dashboard')); adminUsers: '/admin/users',
const AdminUsersPage = lazy(() => import('../pages/admin/Users')); adminPatients: '/admin/patients',
const AdminPatientsPage = lazy(() => import('../pages/admin/Patients')); adminDoctors: '/admin/doctors',
const AdminDoctorsPage = lazy(() => import('../pages/admin/Doctors')); adminDoctorReview: '/admin/doctor-review',
const AdminAdminsPage = lazy(() => import('../pages/admin/Admins')); adminAdmins: '/admin/admins',
const AdminDoctorReviewPage = lazy(() => import('../pages/admin/DoctorReview')); adminDepartments: '/admin/departments',
const AdminDepartmentsPage = lazy(() => import('../pages/admin/Departments')); adminConsultations: '/admin/consultations',
const AdminConsultationsPage = lazy(() => import('../pages/admin/Consultations')); adminStatistics: '/admin/statistics',
const AdminStatisticsPage = lazy(() => import('../pages/admin/Statistics')); adminPrescription: '/admin/prescription',
const AdminPrescriptionPage = lazy(() => import('../pages/admin/Prescription')); adminPharmacy: '/admin/pharmacy',
const AdminPharmacyPage = lazy(() => import('../pages/admin/Pharmacy')); adminAIConfig: '/admin/ai-config',
const AdminAIConfigPage = lazy(() => import('../pages/admin/AIConfig')); adminCompliance: '/admin/compliance',
const AdminCompliancePage = lazy(() => import('../pages/admin/Compliance')); } as const;
// 全局加载组件
const Loading: React.FC = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f0f2f5',
}}>
<Spin size="large" tip="加载中..." />
</div>
);
// 页面包装器(Suspense)
const SuspenseWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Suspense fallback={<Loading />}>{children}</Suspense>
);
// 路由配置
export const router = createBrowserRouter([
// ==================== 根路径重定向 ====================
{
path: '/',
element: <RoleRedirect />,
},
// ==================== 公共路由 ====================
{
path: '/login',
element: (
<SuspenseWrapper>
<LoginPage />
</SuspenseWrapper>
),
},
{
path: '/register',
element: (
<SuspenseWrapper>
<RegisterPage />
</SuspenseWrapper>
),
},
// ==================== 患者端路由 ====================
{
path: '/patient',
element: (
<SuspenseWrapper>
<AuthGuard requiredRole="patient">
<PatientLayout />
</AuthGuard>
</SuspenseWrapper>
),
children: [
{
index: true,
element: <Navigate to="/patient/home" replace />,
},
{
path: 'home',
element: (
<SuspenseWrapper>
<PatientHomePage />
</SuspenseWrapper>
),
},
{
path: 'doctors',
element: (
<SuspenseWrapper>
<PatientDoctorsPage />
</SuspenseWrapper>
),
},
{
path: 'doctors/:id',
element: (
<SuspenseWrapper>
<PatientDoctorDetailPage />
</SuspenseWrapper>
),
},
{
path: 'consult',
element: (
<SuspenseWrapper>
<PatientConsultListPage />
</SuspenseWrapper>
),
},
{
path: 'consult/create',
element: (
<SuspenseWrapper>
<PatientConsultCreatePage />
</SuspenseWrapper>
),
},
{
path: 'consult/text/:id',
element: (
<SuspenseWrapper>
<PatientTextConsultPage />
</SuspenseWrapper>
),
},
{
path: 'consult/video/:id',
element: (
<SuspenseWrapper>
<PatientVideoConsultPage />
</SuspenseWrapper>
),
},
{
path: 'pre-consult',
element: (
<SuspenseWrapper>
<PatientPreConsultPage />
</SuspenseWrapper>
),
},
{
path: 'prescription',
element: (
<SuspenseWrapper>
<PatientPrescriptionPage />
</SuspenseWrapper>
),
},
{
path: 'payment',
element: (
<SuspenseWrapper>
<PatientPaymentPage />
</SuspenseWrapper>
),
},
{
path: 'pharmacy/order',
element: (
<SuspenseWrapper>
<PatientPharmacyOrderPage />
</SuspenseWrapper>
),
},
{
path: 'chronic',
element: (
<SuspenseWrapper>
<PatientChronicPage />
</SuspenseWrapper>
),
},
{
path: 'health-records',
element: (
<SuspenseWrapper>
<PatientHealthRecordsPage />
</SuspenseWrapper>
),
},
{
path: 'profile',
element: (
<SuspenseWrapper>
<PatientProfilePage />
</SuspenseWrapper>
),
},
],
},
// ==================== 医生端路由 ====================
{
path: '/doctor',
element: (
<SuspenseWrapper>
<AuthGuard requiredRole="doctor">
<DoctorLayout />
</AuthGuard>
</SuspenseWrapper>
),
children: [
{
index: true,
element: <Navigate to="/doctor/workbench" replace />,
},
{
path: 'workbench',
element: (
<SuspenseWrapper>
<DoctorWorkbenchPage />
</SuspenseWrapper>
),
},
{
path: 'consult',
element: (
<SuspenseWrapper>
<DoctorConsultPage />
</SuspenseWrapper>
),
},
{
path: 'ai-assist',
element: (
<SuspenseWrapper>
<DoctorAIAssistPage />
</SuspenseWrapper>
),
},
{
path: 'chronic/review',
element: (
<SuspenseWrapper>
<DoctorChronicReviewPage />
</SuspenseWrapper>
),
},
{
path: 'patient',
element: (
<SuspenseWrapper>
<DoctorPatientRecordPage />
</SuspenseWrapper>
),
},
{
path: 'patient/:id',
element: (
<SuspenseWrapper>
<DoctorPatientRecordPage />
</SuspenseWrapper>
),
},
{
path: 'schedule',
element: (
<SuspenseWrapper>
<DoctorSchedulePage />
</SuspenseWrapper>
),
},
{
path: 'income',
element: (
<SuspenseWrapper>
<DoctorIncomePage />
</SuspenseWrapper>
),
},
{
path: 'profile',
element: (
<SuspenseWrapper>
<DoctorProfilePage />
</SuspenseWrapper>
),
},
{
path: 'certification',
element: (
<SuspenseWrapper>
<DoctorCertificationPage />
</SuspenseWrapper>
),
},
],
},
// ==================== 管理端路由 ====================
{
path: '/admin',
element: (
<SuspenseWrapper>
<AuthGuard requiredRole="admin">
<AdminLayout />
</AuthGuard>
</SuspenseWrapper>
),
children: [
{
index: true,
element: <Navigate to="/admin/dashboard" replace />,
},
{
path: 'dashboard',
element: (
<SuspenseWrapper>
<AdminDashboardPage />
</SuspenseWrapper>
),
},
{
path: 'users',
element: (
<SuspenseWrapper>
<AdminUsersPage />
</SuspenseWrapper>
),
},
{
path: 'patients',
element: (
<SuspenseWrapper>
<AdminPatientsPage />
</SuspenseWrapper>
),
},
{
path: 'doctors',
element: (
<SuspenseWrapper>
<AdminDoctorsPage />
</SuspenseWrapper>
),
},
{
path: 'doctor-review',
element: (
<SuspenseWrapper>
<AdminDoctorReviewPage />
</SuspenseWrapper>
),
},
{
path: 'admins',
element: (
<SuspenseWrapper>
<AdminAdminsPage />
</SuspenseWrapper>
),
},
{
path: 'departments',
element: (
<SuspenseWrapper>
<AdminDepartmentsPage />
</SuspenseWrapper>
),
},
{
path: 'consultations',
element: (
<SuspenseWrapper>
<AdminConsultationsPage />
</SuspenseWrapper>
),
},
{
path: 'statistics',
element: (
<SuspenseWrapper>
<AdminStatisticsPage />
</SuspenseWrapper>
),
},
{
path: 'prescription',
element: (
<SuspenseWrapper>
<AdminPrescriptionPage />
</SuspenseWrapper>
),
},
{
path: 'pharmacy',
element: (
<SuspenseWrapper>
<AdminPharmacyPage />
</SuspenseWrapper>
),
},
{
path: 'ai-config',
element: (
<SuspenseWrapper>
<AdminAIConfigPage />
</SuspenseWrapper>
),
},
{
path: 'compliance',
element: (
<SuspenseWrapper>
<AdminCompliancePage />
</SuspenseWrapper>
),
},
],
},
// ==================== 旧路由兼容重定向 ====================
{ path: '/home', element: <Navigate to="/patient/home" replace /> },
{ path: '/doctors', element: <Navigate to="/patient/doctors" replace /> },
{ path: '/consult', element: <Navigate to="/patient/consult" replace /> },
]);
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment