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';
};
......
This diff is collapsed.
This diff is collapsed.
......@@ -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) {
router.replace('/login');
return;
}
if (requiredRole) {
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
if (!roles.includes(user.role as UserRole)) {
switch (user.role) {
case 'doctor':
router.replace('/doctor/workbench');
break;
case 'admin':
router.replace('/admin/dashboard');
break;
default:
router.replace('/patient/home');
break;
}
}
}
}, [isAuthenticated, user, requiredRole, router, pathname]);
if (!isAuthenticated || !user) {
return <Navigate to="/login" state={{ from: location }} replace />;
return null;
}
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 />;
case 'admin':
return <Navigate to="/admin/dashboard" replace />;
default:
return <Navigate to="/patient/home" replace />;
}
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) {
router.replace('/login');
return;
}
if (!isAuthenticated || !user) {
return <Navigate to="/login" replace />;
}
switch (user.role) {
case 'doctor':
router.replace('/doctor/workbench');
break;
case 'admin':
router.replace('/admin/dashboard');
break;
default:
router.replace('/patient/home');
break;
}
}, [isAuthenticated, user, router]);
switch (user.role) {
case 'doctor':
return <Navigate to="/doctor/workbench" replace />;
case 'admin':
return <Navigate to="/admin/dashboard" replace />;
default:
return <Navigate to="/patient/home" replace />;
}
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';
......
This diff is collapsed.
'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,
......
This diff is collapsed.
'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 = {
name: user?.real_name || '医生',
avatar: user?.avatar || '',
title: '主任医师',
department_name: '内科',
hospital: '北京协和医院',
license_no: 'XXXXX',
introduction: '从事内科临床工作20余年,擅长高血压、糖尿病等慢性病的诊治,
specialties: ['高血压, '糖尿, '冠心, '心律失常'],
price: 5000,
is_online: false,
ai_assist_enabled: true,
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: '北京协和医院',
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">
......
'use client';
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { Card, List, Avatar, Tag, Button, Typography, Space, Empty, Badge } from 'antd';
......@@ -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('保存排班:', {
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,
});
setIsModalOpen(false);
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,56 +100,58 @@ const DoctorSchedulePage: React.FC = () => {
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加排班</Button>
</div>
<Row gutter={12}>
<Col span={16}>
<Card size="small">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={setSelectedDate}
cellRender={(current) => dateCellRender(current as Dayjs)}
/>
</Card>
</Col>
<Spin spinning={loading}>
<Row gutter={12}>
<Col span={16}>
<Card size="small">
<Calendar
fullscreen={false}
value={selectedDate}
onSelect={setSelectedDate}
cellRender={(current) => dateCellRender(current as Dayjs)}
/>
</Card>
</Col>
<Col span={8}>
<Card
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"
>
{todaySchedule.length === 0 ? (
<div className="text-center py-6">
<Text type="secondary" className="text-xs!">当日暂无排班</Text>
<br />
<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule} className="mt-1">添加排班</Button>
</div>
) : (
<List
size="small"
dataSource={todaySchedule}
renderItem={(slot) => (
<List.Item
actions={[
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteSlot(slot.id)} />,
]}
>
<div>
<div className="mb-1">
<Text strong className="text-xs!">{slot.start_time} - {slot.end_time}</Text>
<Col span={8}>
<Card
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"
>
{todaySchedule.length === 0 ? (
<div className="text-center py-6">
<Text type="secondary" className="text-xs!">当日暂无排班</Text>
<br />
<Button type="link" size="small" icon={<PlusOutlined />} onClick={handleAddSchedule} className="mt-1">添加排班</Button>
</div>
) : (
<List
size="small"
dataSource={todaySchedule}
renderItem={(slot) => (
<List.Item
actions={[
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteSlot(slot.id!)} />,
]}
>
<div>
<div className="mb-1">
<Text strong className="text-xs!">{slot.start_time} - {slot.end_time}</Text>
</div>
<Space size={4}>
<Tag color="blue">最大 {slot.max_count}</Tag>
<Tag color={(slot.remaining ?? slot.max_count) > 0 ? 'green' : 'red'}>剩余 {slot.remaining ?? slot.max_count}</Tag>
</Space>
</div>
<Space size={4}>
<Tag color="blue">最大 {slot.max_count}</Tag>
<Tag color={slot.remaining > 0 ? 'green' : 'red'}>剩余 {slot.remaining}</Tag>
</Space>
</div>
</List.Item>
)}
/>
)}
</Card>
</Col>
</Row>
</List.Item>
)}
/>
)}
</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}>
......
This diff is collapsed.
......@@ -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,17 +77,16 @@ const ConsultCreatePage: React.FC = () => {
pre_consult_id: preConsultId || undefined,
});
message.success('问诊创建成功!);
message.success('问诊创建成功!');
//
if (consultType === 'video') {
if (consultType === 'video') {
router.push(`/patient/consult/video/${res.data.id}`);
} else {
router.push(`/patient/consult/text/${res.data.id}`);
}
} 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;
'use client';
'use client';
import React, { useState } from 'react';
import { useParams } from 'next/navigation';
import { Card, Row, Col, Avatar, Tag, Rate, Button, Typography, Space, Divider, Descriptions } from 'antd';
......@@ -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;
'use client';
'use client';
import React, { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Card, Row, Col, Input, Select, List, Avatar, Tag, Rate, Button, Space, Typography } from 'antd';
......@@ -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;
......@@ -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);
......
This diff is collapsed.
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