Commit 4ae56601 authored by yuguo's avatar yuguo

fix

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