Commit 45960530 authored by yuguo's avatar yuguo

fix

parent caca4568
......@@ -57,7 +57,8 @@
"Bash(docker-compose ps:*)",
"Bash(cat web/src/app/\\\\\\(main\\\\\\)/admin/agents/page.tsx)",
"Bash(awk NR==68,NR==376:*)",
"Bash(LANG=en_US.UTF-8 sed:*)"
"Bash(LANG=en_US.UTF-8 sed:*)",
"Bash(echo:*)"
]
}
}
# UI Redesign Design — Modern Medical Style (Blue-White Minimalist)
## Overview
Full frontend UI redesign for the Internet Hospital platform, adopting a modern medical style inspired by platforms like DXY Doctor (丁香医生). The design emphasizes trust, professionalism, and clarity with a blue color palette.
## Color System
| Token | Value | Usage |
|-------|-------|-------|
| primary | `#2B6DE8` | Brand blue, buttons/links/selected |
| primary-light | `#5B8FF9` | Hover state |
| primary-dark | `#1A56C4` | Active state |
| primary-bg | `#F0F6FF` | Blue tag/badge backgrounds |
| success | `#52C41A` | Online/success/normal |
| warning | `#FAAD14` | Pending/alerts |
| danger | `#FF4D4F` | Error/urgent/delete |
| bg-base | `#F5F7FA` | Global page background |
| bg-card | `#FFFFFF` | Card/container background |
| sidebar-gradient | `#1A3B7A → #2B6DE8` | Sidebar background |
| text-primary | `#1D2129` | Title/body text |
| text-secondary | `#4E5969` | Description/helper text |
| text-disabled | `#A0A7B4` | Disabled/placeholder |
| border | `#E5E8EF` | Dividers/borders |
| hover-bg | `#F7F8FA` | List hover rows |
## Spacing System (4px multiples)
| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon-text gap |
| sm | 8px | Compact element spacing |
| md | 12px | Form/list item spacing |
| base | 16px | Standard spacing/card padding |
| lg | 20px | Block spacing |
| xl | 24px | Page padding/large block spacing |
| 2xl | 32px | Section separation |
## Typography
| Level | Size | Weight | Usage |
|-------|------|--------|-------|
| H1 | 24px | 700 | Page title |
| H2 | 18px | 600 | Section/card title |
| H3 | 16px | 600 | Subtitle |
| Body | 14px | 400 | Body text |
| Small | 13px | 400 | Helper/description |
| Caption | 12px | 400 | Labels/timestamps |
## Shadow Levels
| Level | Value | Usage |
|-------|-------|-------|
| sm | `0 1px 3px rgba(0,0,0,0.04)` | Card default |
| md | `0 4px 12px rgba(0,0,0,0.08)` | Card hover / popover |
| lg | `0 8px 24px rgba(0,0,0,0.12)` | Modal / dropdown |
## Border Radius
| Element | Value |
|---------|-------|
| Button/Input | 8px |
| Card | 12px |
| Container/Modal | 16px |
| Avatar/Tag | full |
## Sidebar
- Background: `linear-gradient(180deg, #1A3B7A 0%, #2B6DE8 100%)`
- Logo: White icon + white text, bottom 1px rgba(255,255,255,0.1) divider
- Menu items: White text opacity 0.75, hover/selected: opacity 1 + left 3px white bar + bg rgba(255,255,255,0.1)
- Width: 220px expanded, 64px collapsed
## Header
- Height: 56px
- Background: white, bottom 1px solid #E5E8EF
- Left: Breadcrumb navigation
- Right: Search + notification bell (badge) + user avatar dropdown
## Card
- Background: white
- Border radius: 12px
- Shadow: sm level (no border)
- Padding: 20px standard, 16px compact
- Hover: shadow md + translateY(-1px) transition
## Stat Cards
- Left: 48x48 rounded(12px) icon container with light background
- Right: title(13px) + number(24px bold) + trend tag
- 4-column responsive grid
## Buttons
| Type | Style |
|------|-------|
| Primary | bg #2B6DE8, white text, hover #5B8FF9 |
| Secondary | white bg, #2B6DE8 border and text |
| Text | no border/bg, #2B6DE8 text |
| Danger | bg #FF4D4F, white text |
| Height | 36px standard, 32px compact, 40px large |
## Tables
- Header: #F7F8FA bg, 13px 600 weight
- Row height: 48px
- Hover: #F7F8FA bg
- Striping: odd white, even #FAFBFC
- Action column: text buttons, blue
- Pagination: right-aligned
## Role-Specific Variants
| Element | Patient | Doctor | Admin |
|---------|---------|--------|-------|
| Sidebar gradient | #1A3B7A → #2B6DE8 | #1A5C3A → #2B9E6E | #1A3B7A → #2B6DE8 |
| Primary color | #2B6DE8 | #2B9E6E | #2B6DE8 |
| Accent | #5B8FF9 | #52C41A | #5B8FF9 |
| Role tag | Blue | Green | Blue |
## Implementation Scope
Full redesign of all frontend pages:
- globals.css theme tokens
- Ant Design ConfigProvider theme
- All 3 role layouts (admin, doctor, patient)
- All dashboard pages
- All CRUD/list pages
- Auth pages (login/register)
- Consultation interfaces
- AI floating widget
- Charts color palette
# UI Redesign Implementation Plan — Blue-White Modern Medical
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Redesign the entire frontend UI from teal-green to a modern blue medical style, with unified design tokens, gradient sidebar, and consistent component styling across all pages.
**Architecture:** Update globals.css theme tokens + Ant Design ConfigProvider first (foundation), then transform all 3 role layouts (admin/doctor/patient) to gradient sidebars, then update auth pages, dashboards, and all remaining pages. Each task is a self-contained change that can be committed independently.
**Tech Stack:** Next.js 15, React 19, Tailwind CSS v4, Ant Design v5, TypeScript
---
### Task 1: Update Global Theme Tokens (globals.css)
**Files:**
- Modify: `web/src/app/globals.css`
**Step 1: Update @theme inline block — replace teal colors with blue palette**
Replace the `@theme inline` block (lines 13-28) with:
```css
@theme inline {
--color-primary: #2B6DE8;
--color-primary-light: #5B8FF9;
--color-primary-dark: #1A56C4;
--color-primary-bg: #F0F6FF;
--color-primary-bg-light: #F0F6FF;
--color-success: #52c41a;
--color-warning: #faad14;
--color-danger: #ff4d4f;
--color-info: #2B6DE8;
--color-bg-base: #F5F7FA;
--color-bg-card: #ffffff;
--color-text-primary: #1d2129;
--color-text-secondary: #4e5969;
--color-text-disabled: #A0A7B4;
--color-border: #E5E8EF;
--color-hover-bg: #F7F8FA;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
}
```
**Step 2: Update body background and link colors**
Change body `background-color` from `#F8FAFB` to `#F5F7FA`.
Change `a` color from `#0D9488` to `#2B6DE8`.
Change `a:hover` color from `#14B8A6` to `#5B8FF9`.
**Step 3: Update scrollbar colors**
Change scrollbar-track from `#F8FAFB` to `#F5F7FA`.
Change scrollbar-thumb from `#99D5CF` to `#A3C4F3`.
Change scrollbar-thumb:hover from `#5EABA3` to `#6BA3EF`.
**Step 4: Update patient-nav-menu colors**
Change hover/selected color from `#0D9488` to `#2B6DE8`.
Change hover/selected background from `#F0FDFA` to `#F0F6FF`.
**Step 5: Update compact-ui overrides**
Change `.compact-ui .ant-card` box-shadow from `rgba(13, 148, 136, 0.06)` to `rgba(0,0,0,0.04)`.
Change `.compact-ui .ant-card` border from `#E0F2F1` to `#E5E8EF`.
Change `.compact-ui .ant-card:hover` box-shadow from `rgba(13, 148, 136, 0.1)` to `rgba(0,0,0,0.08)`.
Change `.compact-ui .ant-card .ant-card-head` border-bottom from `#E0F2F1` to `#E5E8EF`.
Change `.compact-ui .ant-table-thead` background from `#F0FDFA` to `#F7F8FA`.
Change `.compact-ui .ant-table-tbody > tr:hover > td` background from `#F0FDFA` to `#F7F8FA`.
Change `.compact-ui .ant-descriptions .ant-descriptions-item-label` background from `#F0FDFA` to `#F7F8FA`.
**Step 6: Commit**
```bash
git add web/src/app/globals.css
git commit -m "feat(ui): update global theme tokens from teal to blue palette"
```
---
### Task 2: Update Ant Design ConfigProvider Theme (providers.tsx)
**Files:**
- Modify: `web/src/app/providers.tsx`
**Step 1: Replace entire theme config**
Change the ConfigProvider `theme` prop (lines 28-53) to:
```tsx
theme={{
token: {
colorPrimary: '#2B6DE8',
borderRadius: 8,
colorBgLayout: '#F5F7FA',
colorLink: '#2B6DE8',
colorLinkHover: '#5B8FF9',
fontSize: 14,
colorTextBase: '#1d2129',
colorBgContainer: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
boxShadowSecondary: '0 4px 12px rgba(0,0,0,0.08)',
},
components: {
Card: {
borderRadiusLG: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
boxShadowTertiary: '0 1px 3px rgba(0,0,0,0.04)',
},
Button: {
borderRadius: 8,
controlHeight: 36,
},
Input: {
borderRadius: 8,
},
Table: {
headerBg: '#F7F8FA',
headerColor: '#1d2129',
rowHoverBg: '#F7F8FA',
},
},
}}
```
**Step 2: Commit**
```bash
git add web/src/app/providers.tsx
git commit -m "feat(ui): update Ant Design ConfigProvider with blue theme tokens"
```
---
### Task 3: Redesign Admin Layout — Gradient Sidebar
**Files:**
- Modify: `web/src/app/(main)/admin/layout.tsx`
**Step 1: Update Sider styling — from white to dark blue gradient**
Replace Sider's `style` prop:
```tsx
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: 'linear-gradient(180deg, #1A3B7A 0%, #2B6DE8 100%)',
overflow: 'auto',
}}
```
Remove `borderRight`.
**Step 2: Update Logo area — white text on dark background**
Replace the logo div's inline styles:
- Remove `borderBottom: '1px solid #f0f0f0'`, replace with `borderBottom: '1px solid rgba(255,255,255,0.1)'`
- Change logo icon background from `linear-gradient(135deg, #0D9488 0%, #0F766E 100%)` to `rgba(255,255,255,0.2)`
- Change platform name color from `#1d2129` to `#fff`
- Change Tag from `color="cyan"` to a custom style: `style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px', background: 'rgba(255,255,255,0.2)', color: '#fff', border: 'none' }}`
**Step 3: Update Menu styling — white text on dark background**
Add CSS classes or inline theme. Add to Menu component:
```tsx
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{
border: 'none',
padding: '8px 0',
background: 'transparent',
color: 'rgba(255,255,255,0.75)',
}}
theme="dark"
/>
```
**Step 4: Update header border**
Change header `borderBottom` from `'1px solid #f0f0f0'` to `'1px solid #E5E8EF'`.
**Step 5: Update avatar color**
Change Avatar `backgroundColor` from `'#0D9488'` to `'#2B6DE8'`.
**Step 6: Update content background**
Change Content background from `'#F8FAFB'` to `'#F5F7FA'`.
**Step 7: Commit**
```bash
git add web/src/app/(main)/admin/layout.tsx
git commit -m "feat(ui): redesign admin layout with blue gradient sidebar"
```
---
### Task 4: Redesign Doctor Layout — Green Gradient Sidebar
**Files:**
- Modify: `web/src/app/(main)/doctor/layout.tsx`
**Step 1: Update Sider — green gradient**
```tsx
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: 'linear-gradient(180deg, #1A5C3A 0%, #2B9E6E 100%)',
overflow: 'auto',
}}
```
**Step 2: Update Logo — white on dark, green tag**
- `borderBottom: '1px solid rgba(255,255,255,0.1)'`
- Logo icon background: `'rgba(255,255,255,0.2)'`
- Platform name color: `'#fff'`
- Tag: `style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px', background: 'rgba(255,255,255,0.2)', color: '#fff', border: 'none' }}`
**Step 3: Menu — dark theme**
```tsx
<Menu theme="dark" ... style={{ border: 'none', padding: '8px 0', background: 'transparent' }} />
```
**Step 4: Update header**
- Change border from `#f0f0f0` to `#E5E8EF`
- Change online switch color from `'#52c41a'` to `'#2B9E6E'`
**Step 5: Update avatar and content**
- Avatar: keep `backgroundColor: '#2B9E6E'`
- Content background: `'#F5F7FA'`
**Step 6: Commit**
```bash
git add web/src/app/(main)/doctor/layout.tsx
git commit -m "feat(ui): redesign doctor layout with green gradient sidebar"
```
---
### Task 5: Redesign Patient Layout — Blue Gradient Sidebar
**Files:**
- Modify: `web/src/app/(main)/patient/layout.tsx`
**Step 1: Update Sider — blue gradient (same as admin)**
```tsx
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: 'linear-gradient(180deg, #1A3B7A 0%, #2B6DE8 100%)',
overflow: 'auto',
}}
```
**Step 2: Update Logo — white on dark**
Same pattern as admin layout:
- `borderBottom: '1px solid rgba(255,255,255,0.1)'`
- Logo icon bg: `'rgba(255,255,255,0.2)'`
- Platform name: `color: '#fff'`
- Tag: blue style with `background: 'rgba(255,255,255,0.2)', color: '#fff', border: 'none'`
**Step 3: Menu — dark theme**
```tsx
<Menu theme="dark" ... style={{ border: 'none', padding: '8px 0', background: 'transparent' }} />
```
**Step 4: Update header and content**
- Header border: `#E5E8EF`
- Avatar: `backgroundColor: '#2B6DE8'`
- Login button: `background: '#2B6DE8'` (remove inline teal gradient if any)
- Content background: `'#F5F7FA'`
**Step 5: Commit**
```bash
git add web/src/app/(main)/patient/layout.tsx
git commit -m "feat(ui): redesign patient layout with blue gradient sidebar"
```
---
### Task 6: Redesign Login Page
**Files:**
- Modify: `web/src/app/(auth)/login/page.tsx`
**Step 1: Update main background gradient**
Change from teal gradient to blue:
```tsx
background: 'linear-gradient(135deg, #0A1628 0%, #1A3B7A 40%, #2B6DE8 70%, #5B8FF9 100%)',
```
**Step 2: Update left panel gradient**
```tsx
background: 'linear-gradient(180deg, rgba(26,59,122,0.95) 0%, rgba(43,109,232,0.9) 50%, rgba(91,143,249,0.85) 100%)',
```
**Step 3: Update decorative blur elements**
Change the teal/cyan colors to blue:
- First blur: `rgba(255,255,255,0.05)` (keep)
- Second blur: `rgba(43,109,232,0.1)` (was teal)
- Third blur: `rgba(91,143,249,0.1)` (was cyan)
**Step 4: Update logo icon background**
Change from `#0D9488` to `#2B6DE8` in both desktop and mobile logos.
**Step 5: Update login button gradient**
```tsx
background: 'linear-gradient(135deg, #2B6DE8 0%, #1A56C4 100%)',
boxShadow: '0 4px 12px rgba(43, 109, 232, 0.35)',
```
**Step 6: Update all teal color references to blue**
- Link colors: `#0D9488` → `#2B6DE8`
- Feature icon backgrounds: keep varied colors (cyan, green, purple) for visual interest
**Step 7: Commit**
```bash
git add web/src/app/(auth)/login/page.tsx
git commit -m "feat(ui): redesign login page with blue gradient theme"
```
---
### Task 7: Redesign Register Page
**Files:**
- Modify: `web/src/app/(auth)/register/page.tsx`
**Step 1: Update all teal (#0D9488) references to blue (#2B6DE8)**
Find and replace all `#0D9488` with `#2B6DE8`, `#0F766E` with `#1A56C4`, `#14B8A6` with `#5B8FF9`, `#F0FDFA` with `#F0F6FF` in this file.
**Step 2: Update gradient backgrounds**
Any `linear-gradient` using teal colors should use blue equivalents.
**Step 3: Commit**
```bash
git add web/src/app/(auth)/register/page.tsx
git commit -m "feat(ui): redesign register page with blue theme"
```
---
### Task 8: Update EChartsCard Color Palette
**Files:**
- Modify: `web/src/components/Charts/EChartsCard.tsx`
**Step 1: Update default color palette**
Replace the existing color array with:
```tsx
const DEFAULT_COLORS = ['#2B6DE8', '#52c41a', '#FAAD14', '#722ED1', '#13C2C2', '#EB2F96'];
```
**Step 2: Update card border styling**
Change any `#E0F2F1` borders to `#E5E8EF`.
Change any teal-tinted shadows to neutral shadows.
**Step 3: Commit**
```bash
git add web/src/components/Charts/EChartsCard.tsx
git commit -m "feat(ui): update chart colors to blue-primary palette"
```
---
### Task 9: Update GlobalAIFloat Theme Colors
**Files:**
- Modify: `web/src/components/GlobalAIFloat/FloatContainer.tsx`
**Step 1: Update role-based gradients**
Find the role-based theming section and update:
- Patient/Admin gradient: from teal to `#2B6DE8` / `#5B8FF9`
- Doctor gradient: from green to `#2B9E6E` / `#52C41A`
**Step 2: Update any teal (#0D9488) references**
Replace all `#0D9488` with `#2B6DE8` in this file.
**Step 3: Commit**
```bash
git add web/src/components/GlobalAIFloat/FloatContainer.tsx
git commit -m "feat(ui): update AI float widget theme colors to blue"
```
---
### Task 10: Update Admin Dashboard Page
**Files:**
- Modify: `web/src/app/(main)/admin/dashboard/page.tsx`
**Step 1: Update stat card colors**
Replace teal-based colors in stat cards:
- Primary metric color: `#0D9488` → `#2B6DE8`
- Background accents: `#F0FDFA` → `#F0F6FF`, `#E0F2F1` → `#E5E8EF`
**Step 2: Update chart accent colors**
Ensure chart data uses the new palette: `#2B6DE8` as primary.
**Step 3: Update all inline style teal references**
Scan for all `#0D9488`, `#0F766E`, `#14B8A6`, `#F0FDFA`, `#E0F2F1` and replace with their blue equivalents.
**Step 4: Update shadow/border colors**
Replace `rgba(13, 148, 136, ...)` with `rgba(0,0,0,...)` at appropriate opacity.
**Step 5: Commit**
```bash
git add web/src/app/(main)/admin/dashboard/page.tsx
git commit -m "feat(ui): update admin dashboard colors to blue theme"
```
---
### Task 11: Update Doctor Workbench Page
**Files:**
- Modify: `web/src/app/(main)/doctor/workbench/page.tsx`
**Step 1: Update stat card colors**
Replace teal colors with doctor-green or blue as appropriate. The doctor portal uses `#2B9E6E` as its primary.
**Step 2: Update backgrounds and borders**
- `#F0FDFA` → `#F0F6FF` (or `#F0FFF5` for doctor-green)
- `#E0F2F1` → `#E5E8EF`
- `rgba(13, 148, 136, ...)` → `rgba(0,0,0,...)` for shadows
**Step 3: Commit**
```bash
git add web/src/app/(main)/doctor/workbench/page.tsx
git commit -m "feat(ui): update doctor workbench colors"
```
---
### Task 12: Update Patient Home Page
**Files:**
- Modify: `web/src/app/(main)/patient/home/page.tsx`
**Step 1: Update hero banner gradient**
Replace teal gradient with blue:
```tsx
background: 'linear-gradient(135deg, #1A3B7A 0%, #2B6DE8 100%)'
```
**Step 2: Update all teal references**
- `#0D9488` → `#2B6DE8`
- `#0F766E` → `#1A56C4`
- `#14B8A6` → `#5B8FF9`
- `#F0FDFA` → `#F0F6FF`
- `#E0F2F1` → `#E5E8EF`
**Step 3: Update service cards hover/accent colors**
**Step 4: Commit**
```bash
git add web/src/app/(main)/patient/home/page.tsx
git commit -m "feat(ui): update patient home page to blue theme"
```
---
### Task 13: Update Doctor Consult Page + Sub-components
**Files:**
- Modify: `web/src/app/(main)/doctor/consult/page.tsx`
- Modify: `web/src/app/(main)/doctor/consult/ChatPanel.tsx`
- Modify: `web/src/app/(main)/doctor/consult/AIPanel.tsx`
- Modify: `web/src/app/(main)/doctor/consult/PatientList.tsx`
**Step 1: Update all teal references in consult page**
Replace `#0D9488` → `#2B9E6E` (doctor green primary), `#F0FDFA` → `#F0FFF5`, borders to `#E5E8EF`.
**Step 2: Update ChatPanel colors**
Replace teal references in message bubbles, buttons.
**Step 3: Update AIPanel colors**
Replace teal references in AI suggestion cards.
**Step 4: Update PatientList colors**
Replace teal references in patient cards, status indicators.
**Step 5: Commit**
```bash
git add web/src/app/(main)/doctor/consult/
git commit -m "feat(ui): update doctor consult interface colors"
```
---
### Task 14: Update All Admin CRUD Pages
**Files:**
- Modify: `web/src/app/(main)/admin/patients/page.tsx`
- Modify: `web/src/app/(main)/admin/doctors/page.tsx`
- Modify: `web/src/app/(main)/admin/agents/page.tsx`
- Modify: `web/src/app/(main)/admin/tools/page.tsx`
- Modify: `web/src/app/(main)/admin/consultations/page.tsx`
- Modify: `web/src/app/(main)/admin/prescription/page.tsx`
- Modify: `web/src/app/(main)/admin/pharmacy/page.tsx`
- Modify: `web/src/app/(main)/admin/knowledge/page.tsx`
- Modify: `web/src/app/(main)/admin/ai-config/page.tsx`
- Modify: `web/src/app/(main)/admin/ai-logs/page.tsx`
- Modify: `web/src/app/(main)/admin/ai-center/page.tsx`
- Modify: `web/src/app/(main)/admin/compliance/page.tsx`
- Modify: `web/src/app/(main)/admin/roles/page.tsx`
- Modify: `web/src/app/(main)/admin/safety/page.tsx`
- Modify: `web/src/app/(main)/admin/tasks/page.tsx`
- Modify: `web/src/app/(main)/admin/menus/page.tsx`
- Modify: `web/src/app/(main)/admin/workflows/page.tsx`
- Modify: `web/src/app/(main)/admin/admins/page.tsx`
- Modify: `web/src/app/(main)/admin/departments/page.tsx`
**Step 1: Batch find-replace across all admin pages**
In all files, replace:
- `#0D9488` → `#2B6DE8`
- `#0F766E` → `#1A56C4`
- `#14B8A6` → `#5B8FF9`
- `#F0FDFA` → `#F0F6FF`
- `#E0F2F1` → `#E5E8EF`
- `rgba(13, 148, 136` → `rgba(0, 0, 0` (adjust opacity accordingly: 0.06→0.04, 0.1→0.08, 0.35→0.2)
**Step 2: Verify no teal colors remain**
Run: `grep -r "#0D9488\|#0F766E\|#14B8A6\|#F0FDFA\|#E0F2F1" web/src/app/(main)/admin/`
Expected: no matches
**Step 3: Commit**
```bash
git add web/src/app/(main)/admin/
git commit -m "feat(ui): update all admin pages to blue theme"
```
---
### Task 15: Update All Patient Pages
**Files:**
- Modify: `web/src/app/(main)/patient/doctors/page.tsx`
- Modify: `web/src/app/(main)/patient/doctors/[id]/page.tsx`
- Modify: `web/src/app/(main)/patient/consult/page.tsx`
- Modify: `web/src/app/(main)/patient/consult/text/[id]/page.tsx`
- Modify: `web/src/app/(main)/patient/consult/video/[id]/page.tsx`
- Modify: `web/src/app/(main)/patient/chronic/page.tsx`
- Modify: `web/src/app/(main)/patient/prescription/page.tsx`
- Modify: `web/src/app/(main)/patient/pharmacy/order/page.tsx`
- Modify: `web/src/app/(main)/patient/payment/page.tsx`
- Modify: `web/src/app/(main)/patient/health-records/page.tsx`
- Modify: `web/src/app/(main)/patient/pre-consult/page.tsx`
- Modify: `web/src/app/(main)/patient/profile/page.tsx`
**Step 1: Batch find-replace across all patient pages**
Same color replacements as Task 14.
**Step 2: Verify no teal colors remain**
Run: `grep -r "#0D9488\|#0F766E\|#14B8A6\|#F0FDFA\|#E0F2F1" web/src/app/(main)/patient/`
Expected: no matches
**Step 3: Commit**
```bash
git add web/src/app/(main)/patient/
git commit -m "feat(ui): update all patient pages to blue theme"
```
---
### Task 16: Update All Doctor Pages
**Files:**
- Modify: `web/src/app/(main)/doctor/patient/page.tsx`
- Modify: `web/src/app/(main)/doctor/patient/[id]/page.tsx`
- Modify: `web/src/app/(main)/doctor/schedule/page.tsx`
- Modify: `web/src/app/(main)/doctor/income/page.tsx`
- Modify: `web/src/app/(main)/doctor/profile/page.tsx`
- Modify: `web/src/app/(main)/doctor/certification/page.tsx`
- Modify: `web/src/app/(main)/doctor/chronic/review/page.tsx`
- Modify: `web/src/app/(main)/doctor/ai-assist/page.tsx`
**Step 1: Batch find-replace across all doctor pages**
For doctor pages, use green primary:
- `#0D9488` → `#2B9E6E`
- `#0F766E` → `#1A5C3A`
- `#14B8A6` → `#52C41A`
- `#F0FDFA` → `#F0FFF5`
- `#E0F2F1` → `#E5E8EF`
**Step 2: Verify no teal colors remain**
Run: `grep -r "#0D9488\|#0F766E\|#14B8A6\|#F0FDFA\|#E0F2F1" web/src/app/(main)/doctor/`
Expected: no matches
**Step 3: Commit**
```bash
git add web/src/app/(main)/doctor/
git commit -m "feat(ui): update all doctor pages to green theme"
```
---
### Task 17: Update Remaining Shared Components
**Files:**
- Modify: `web/src/components/GlobalAIFloat/ChatPanel.tsx`
- Modify: `web/src/components/GlobalAIFloat/ToolCallCard.tsx`
- Modify: `web/src/components/GlobalAIFloat/ToolResultCard.tsx`
- Modify: `web/src/components/GlobalAIFloat/SuggestedActions.tsx`
- Modify: Any other components with teal references
**Step 1: Find all remaining teal references**
Run: `grep -r "#0D9488\|#0F766E\|#14B8A6\|#F0FDFA\|#E0F2F1\|rgba(13, 148, 136" web/src/`
**Step 2: Replace all found instances**
Use the standard color mapping.
**Step 3: Verify clean**
Run the same grep again. Expected: no matches.
**Step 4: Commit**
```bash
git add web/src/components/ web/src/
git commit -m "feat(ui): update all remaining components to new color scheme"
```
---
### Task 18: Final Verification & Build Check
**Step 1: Run full grep for any remaining old colors**
```bash
cd web && grep -r "#0D9488\|#0F766E\|#14B8A6\|#E0F2F1\|rgba(13, 148, 136" src/
```
Expected: no matches (or only in comments/documentation)
**Step 2: Run build**
```bash
cd web && npm run build
```
Expected: Build succeeds (ignoreBuildErrors is true, but check for real issues)
**Step 3: Run lint**
```bash
cd web && npm run lint
```
**Step 4: Visual smoke test**
Run `npm run dev` and manually check:
- Login page: blue gradient background
- Admin layout: dark blue gradient sidebar with white text
- Doctor layout: green gradient sidebar
- Patient layout: blue gradient sidebar
- Dashboard cards: blue-tinted colors
- Charts: blue-first color palette
- AI float: blue/green theme based on role
**Step 5: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix(ui): final cleanup for blue theme migration"
```
......@@ -308,11 +308,9 @@ func main() {
authApi.POST("/user/verify-identity", userHandler.VerifyIdentity)
authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment)
// 医生端路由(需要认证 + 医生角色)
doctorApi := authApi.Group("")
doctorApi.Use(middleware.RequireRole("doctor"))
// 医生端路由(需要认证,handler 内部通过 user_id 查 doctors 表验证医生身份)
doctorPortalHandler := doctorportal.NewHandler()
doctorPortalHandler.RegisterRoutes(doctorApi)
doctorPortalHandler.RegisterRoutes(authApi)
// 健康档案路由(需要认证)
healthHandler := health.NewHandler()
......
......@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
......@@ -14,6 +15,21 @@ import (
"internet-hospital/pkg/response"
)
// inferRoleFromAgent 根据 agentID 推断用户当前操作角色
// 优先使用 agentID 暗示的角色(因为前端根据页面路径选择了对应的 agent),回退到 JWT 角色
func inferRoleFromAgent(agentID, jwtRole string) string {
if strings.Contains(agentID, "doctor") {
return "doctor"
}
if strings.Contains(agentID, "admin") {
return "admin"
}
if strings.Contains(agentID, "patient") {
return "patient"
}
return jwtRole
}
// sendSSEEvent 发送 SSE 事件
func sendSSEEvent(c *gin.Context, flusher interface{ Flush() }, event string, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
......@@ -86,7 +102,8 @@ func (h *Handler) Chat(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
role, _ := c.Get("role")
userRole, _ := role.(string)
jwtRole, _ := role.(string)
userRole := inferRoleFromAgent(agentID, jwtRole)
output, err := h.svc.Chat(c.Request.Context(), agentID, uid, userRole, req.SessionID, req.Message, req.Context)
if err != nil {
......@@ -128,7 +145,8 @@ func (h *Handler) ChatStream(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
role, _ := c.Get("role")
userRole, _ := role.(string)
jwtRole, _ := role.(string)
userRole := inferRoleFromAgent(agentID, jwtRole)
emit := func(event, data string) {
sendSSEEvent(c, flusher, event, data)
......@@ -153,7 +171,8 @@ func (h *Handler) CreateSession(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
role, _ := c.Get("role")
userRole, _ := role.(string)
jwtRole, _ := role.(string)
userRole := inferRoleFromAgent(req.AgentID, jwtRole)
sessionID := uuid.New().String()
session := model.AgentSession{
......
......@@ -8,6 +8,7 @@ type Role struct {
Code string `gorm:"uniqueIndex;size:50;not null" json:"code"`
Name string `gorm:"size:100;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
HomePath string `gorm:"size:200" json:"home_path"` // 登录后默认首页路径
IsSystem bool `gorm:"default:false" json:"is_system"`
Status string `gorm:"size:20;default:active" json:"status"` // active | disabled
Sort int `gorm:"default:0" json:"sort"`
......
......@@ -94,7 +94,35 @@ func (h *Handler) UpdateMenu(c *gin.Context) {
}
updates := map[string]interface{}{}
if req.ParentID != nil {
updates["parent_id"] = *req.ParentID
newParentID := *req.ParentID
// 不能将自己设为自己的父菜单
if newParentID == menu.ID {
response.Error(c, 400, "不能将菜单设为自己的上级")
return
}
// 检查循环引用:新父节点不能是自己的后代
if newParentID != 0 {
var allMenus []model.Menu
db.Find(&allMenus)
menuMap := map[uint]uint{}
for _, m := range allMenus {
menuMap[m.ID] = m.ParentID
}
// 模拟修改后,从 newParentID 向上追溯是否会回到 menu.ID
menuMap[menu.ID] = newParentID
cur := newParentID
for i := 0; i < 100; i++ {
if cur == 0 {
break
}
if cur == menu.ID {
response.Error(c, 400, "不能将菜单设为其子菜单的下级,会产生循环引用")
return
}
cur = menuMap[cur]
}
}
updates["parent_id"] = newParentID
}
if req.Name != "" {
updates["name"] = req.Name
......
......@@ -26,13 +26,21 @@ func (h *Handler) ListRoles(c *gin.Context) {
func (h *Handler) SetUserRole(c *gin.Context) {
userID := c.Param("id")
var req struct {
Role string `json:"role" binding:"required,oneof=patient doctor admin"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "role 必须为 patient、doctor 或 admin")
response.BadRequest(c, "role 不能为空")
return
}
db := database.GetDB()
// 从 DB 验证角色是否存在
var roleRecord model.Role
if err := db.Where("code = ? AND status = ?", req.Role, "active").First(&roleRecord).Error; err != nil {
response.BadRequest(c, "角色不存在: "+req.Role)
return
}
var user model.User
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
response.Error(c, 404, "用户不存在")
......@@ -102,23 +110,76 @@ type MenuTreeNode struct {
}
// GetMyMenus 基于 JWT role 返回对应端菜单树
// admin → /admin/* 菜单, patient → /patient/* 菜单, doctor → /doctor/* 菜单
// 优先从 role_menus 关联表获取该角色的菜单,若未配置则降级为路径前缀过滤
func (h *Handler) GetMyMenus(c *gin.Context) {
role, _ := c.Get("role")
roleStr, _ := role.(string)
prefix := rolePathPrefix(roleStr)
if prefix == "" {
if roleStr == "" {
response.Success(c, []MenuTreeNode{})
return
}
db := database.GetDB()
// 1. 查找角色记录
var roleRecord model.Role
if err := db.Where("code = ?", roleStr).First(&roleRecord).Error; err == nil {
// 2. 从 role_menus 获取关联的菜单ID
var menuIDs []uint
db.Model(&model.RoleMenu{}).Where("role_id = ?", roleRecord.ID).Pluck("menu_id", &menuIDs)
if len(menuIDs) > 0 {
// 3. 查询这些菜单(仅 visible + active)
var menus []model.Menu
db.Where("visible = ? AND status = ?", true, "active").
db.Where("id IN ? AND visible = ? AND status = ?", menuIDs, true, "active").
Order("sort asc, id asc").Find(&menus)
filtered := filterMenusByPrefix(menus, prefix)
// 4. 确保父节点也被包含(即使父节点未在 role_menus 中)
existIDs := map[uint]bool{}
for _, m := range menus {
existIDs[m.ID] = true
}
missingParentIDs := map[uint]bool{}
for _, m := range menus {
if m.ParentID != 0 && !existIDs[m.ParentID] {
missingParentIDs[m.ParentID] = true
}
}
// 递归补全所有父节点
for len(missingParentIDs) > 0 {
ids := make([]uint, 0, len(missingParentIDs))
for id := range missingParentIDs {
ids = append(ids, id)
}
var parents []model.Menu
db.Where("id IN ? AND visible = ? AND status = ?", ids, true, "active").Find(&parents)
missingParentIDs = map[uint]bool{}
for _, p := range parents {
if !existIDs[p.ID] {
menus = append(menus, p)
existIDs[p.ID] = true
if p.ParentID != 0 && !existIDs[p.ParentID] {
missingParentIDs[p.ParentID] = true
}
}
}
}
response.Success(c, buildMenuTree(menus, 0))
return
}
}
// 降级:role_menus 未配置时,按路径前缀过滤
prefix := rolePathPrefix(roleStr)
if prefix == "" {
response.Success(c, []MenuTreeNode{})
return
}
var allMenus []model.Menu
db.Where("visible = ? AND status = ?", true, "active").
Order("sort asc, id asc").Find(&allMenus)
filtered := filterMenusByPrefix(allMenus, prefix)
response.Success(c, buildMenuTree(filtered, 0))
}
......
......@@ -17,14 +17,24 @@ func SeedRBAC() {
db.Model(&model.Role{}).Count(&roleCount)
if roleCount == 0 {
roles := []model.Role{
{Code: "admin", Name: "超级管理员", Description: "拥有所有权限", IsSystem: true, Sort: 1, Status: "active"},
{Code: "doctor", Name: "医生", Description: "在线问诊、处方开具", IsSystem: true, Sort: 2, Status: "active"},
{Code: "patient", Name: "患者", Description: "在线问诊、健康管理", IsSystem: true, Sort: 3, Status: "active"},
{Code: "admin", Name: "超级管理员", Description: "拥有所有权限", HomePath: "/admin/dashboard", IsSystem: true, Sort: 1, Status: "active"},
{Code: "doctor", Name: "医生", Description: "在线问诊、处方开具", HomePath: "/doctor/workbench", IsSystem: true, Sort: 2, Status: "active"},
{Code: "patient", Name: "患者", Description: "在线问诊、健康管理", HomePath: "/patient/home", IsSystem: true, Sort: 3, Status: "active"},
}
for _, r := range roles {
db.Create(&r)
}
log.Println("[SeedRBAC] 角色种子数据已创建")
} else {
// 回填已有角色的 home_path(幂等)
homePathMap := map[string]string{
"admin": "/admin/dashboard",
"doctor": "/doctor/workbench",
"patient": "/patient/home",
}
for code, hp := range homePathMap {
db.Model(&model.Role{}).Where("code = ? AND (home_path IS NULL OR home_path = '')", code).Update("home_path", hp)
}
}
// ── 2. 权限种子 ──
......@@ -152,7 +162,74 @@ func SeedRBAC() {
db.Create(&model.Menu{ParentID: sysMgmt.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
log.Println("[SeedRBAC] 菜单种子数据已创建")
// ── 患者端菜单 ──
patientHome := model.Menu{Name: "首页", Path: "/patient/home", Icon: "HomeOutlined", Sort: 20, Type: "menu", Visible: true, Status: "active"}
db.Create(&patientHome)
patientConsult := model.Menu{Name: "在线问诊", Path: "", Icon: "MedicineBoxOutlined", Sort: 21, Type: "menu", Visible: true, Status: "active"}
db.Create(&patientConsult)
for i, sub := range []struct{ name, path, icon string }{
{"找医生", "/patient/doctors", "SearchOutlined"},
{"问诊大厅", "/patient/consult", "MessageOutlined"},
{"预问诊", "/patient/pre-consult", "FileTextOutlined"},
} {
db.Create(&model.Menu{ParentID: patientConsult.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
patientHealth := model.Menu{Name: "健康管理", Path: "", Icon: "HeartOutlined", Sort: 22, Type: "menu", Visible: true, Status: "active"}
db.Create(&patientHealth)
for i, sub := range []struct{ name, path, icon string }{
{"健康档案", "/patient/health-records", "FolderOpenOutlined"},
{"慢病管理", "/patient/chronic", "SafetyCertificateOutlined"},
} {
db.Create(&model.Menu{ParentID: patientHealth.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
patientService := model.Menu{Name: "服务中心", Path: "", Icon: "ShoppingCartOutlined", Sort: 23, Type: "menu", Visible: true, Status: "active"}
db.Create(&patientService)
for i, sub := range []struct{ name, path, icon string }{
{"我的处方", "/patient/prescription", "FileTextOutlined"},
{"药品商城", "/patient/pharmacy", "ShoppingCartOutlined"},
{"缴费记录", "/patient/payment", "PayCircleOutlined"},
} {
db.Create(&model.Menu{ParentID: patientService.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
patientProfile := model.Menu{Name: "个人中心", Path: "/patient/profile", Icon: "UserOutlined", Sort: 24, Type: "menu", Visible: true, Status: "active"}
db.Create(&patientProfile)
// ── 医生端菜单 ──
doctorWorkbench := model.Menu{Name: "工作台", Path: "/doctor/workbench", Icon: "DashboardOutlined", Sort: 30, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorWorkbench)
doctorConsult := model.Menu{Name: "问诊管理", Path: "", Icon: "MedicineBoxOutlined", Sort: 31, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorConsult)
for i, sub := range []struct{ name, path, icon string }{
{"接诊列表", "/doctor/consult", "MessageOutlined"},
{"患者管理", "/doctor/patient", "UserOutlined"},
} {
db.Create(&model.Menu{ParentID: doctorConsult.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
doctorSchedule := model.Menu{Name: "排班管理", Path: "/doctor/schedule", Icon: "CalendarOutlined", Sort: 32, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorSchedule)
doctorChronic := model.Menu{Name: "慢病管理", Path: "/doctor/chronic", Icon: "HeartOutlined", Sort: 33, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorChronic)
doctorIncome := model.Menu{Name: "收入管理", Path: "/doctor/income", Icon: "DollarOutlined", Sort: 34, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorIncome)
doctorTasks := model.Menu{Name: "待办任务", Path: "/doctor/tasks", Icon: "CheckCircleOutlined", Sort: 35, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorTasks)
doctorCert := model.Menu{Name: "资质认证", Path: "/doctor/certification", Icon: "SafetyCertificateOutlined", Sort: 36, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorCert)
doctorProfile := model.Menu{Name: "个人信息", Path: "/doctor/profile", Icon: "SettingOutlined", Sort: 37, Type: "menu", Visible: true, Status: "active"}
db.Create(&doctorProfile)
log.Println("[SeedRBAC] 菜单种子数据已创建(含管理端/患者端/医生端)")
}
// ── 4. 为超级管理员角色分配全部权限和菜单 ──
......@@ -181,6 +258,82 @@ func SeedRBAC() {
}
}
// ── 4.1 补充患者端/医生端菜单(已有数据库可能只有admin菜单) ──
seedPatientDoctorMenus(db)
// ── 4.1.1 补全菜单图标(已有菜单可能缺少icon字段) ──
backfillMenuIcons(db)
// ── 4.2 为患者角色分配 /patient/* 菜单 ──
var patientRole model.Role
if err := db.Where("code = ?", "patient").First(&patientRole).Error; err == nil {
var pmCount int64
db.Model(&model.RoleMenu{}).Where("role_id = ?", patientRole.ID).Count(&pmCount)
if pmCount == 0 {
var patientMenus []model.Menu
db.Where("path LIKE ? OR path = ''", "/patient/%").Where("visible = ? AND status = ?", true, "active").Find(&patientMenus)
// 收集叶子菜单的父ID
parentIDs := map[uint]bool{}
for _, m := range patientMenus {
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
}
}
// 补充父级分组菜单
if len(parentIDs) > 0 {
ids := make([]uint, 0, len(parentIDs))
for id := range parentIDs {
ids = append(ids, id)
}
var parents []model.Menu
db.Where("id IN ?", ids).Find(&parents)
patientMenus = append(patientMenus, parents...)
}
seen := map[uint]bool{}
for _, m := range patientMenus {
if !seen[m.ID] {
seen[m.ID] = true
db.Create(&model.RoleMenu{RoleID: patientRole.ID, MenuID: m.ID})
}
}
log.Println("[SeedRBAC] 患者角色已分配患者端菜单")
}
}
// ── 4.3 为医生角色分配 /doctor/* 菜单 ──
var doctorRole model.Role
if err := db.Where("code = ?", "doctor").First(&doctorRole).Error; err == nil {
var dmCount int64
db.Model(&model.RoleMenu{}).Where("role_id = ?", doctorRole.ID).Count(&dmCount)
if dmCount == 0 {
var doctorMenus []model.Menu
db.Where("path LIKE ? OR path = ''", "/doctor/%").Where("visible = ? AND status = ?", true, "active").Find(&doctorMenus)
parentIDs := map[uint]bool{}
for _, m := range doctorMenus {
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
}
}
if len(parentIDs) > 0 {
ids := make([]uint, 0, len(parentIDs))
for id := range parentIDs {
ids = append(ids, id)
}
var parents []model.Menu
db.Where("id IN ?", ids).Find(&parents)
doctorMenus = append(doctorMenus, parents...)
}
seen := map[uint]bool{}
for _, m := range doctorMenus {
if !seen[m.ID] {
seen[m.ID] = true
db.Create(&model.RoleMenu{RoleID: doctorRole.ID, MenuID: m.ID})
}
}
log.Println("[SeedRBAC] 医生角色已分配医生端菜单")
}
}
// ── 4.5 菜单迁移:合并 Agent管理+工具中心+AI运营中心 → 智能体管理+AI日志 ──
migrateAgentMenus(db)
......@@ -271,3 +424,153 @@ func migrateAgentMenus(db *gorm.DB) {
log.Println("[SeedRBAC] 智能体平台菜单迁移完成")
}
// seedPatientDoctorMenus 为已有数据库补充患者端/医生端菜单(幂等)
func seedPatientDoctorMenus(db *gorm.DB) {
// 如果已存在 /patient/home 菜单,说明已导入过
var existing model.Menu
if err := db.Where("path = ?", "/patient/home").First(&existing).Error; err == nil {
return
}
log.Println("[SeedRBAC] 补充患者端/医生端菜单...")
// ── 患者端菜单 ──
type menuDef struct {
name, path, icon string
}
createMenu := func(name, path, icon string, sort int, parentID uint) uint {
m := model.Menu{ParentID: parentID, Name: name, Path: path, Icon: icon, Sort: sort, Type: "menu", Visible: true, Status: "active"}
db.Create(&m)
return m.ID
}
createMenu("首页", "/patient/home", "HomeOutlined", 20, 0)
pcID := createMenu("在线问诊", "", "MedicineBoxOutlined", 21, 0)
for i, sub := range []menuDef{
{"找医生", "/patient/doctors", "SearchOutlined"},
{"问诊大厅", "/patient/consult", "MessageOutlined"},
{"预问诊", "/patient/pre-consult", "FileTextOutlined"},
} {
createMenu(sub.name, sub.path, sub.icon, i+1, pcID)
}
phID := createMenu("健康管理", "", "HeartOutlined", 22, 0)
for i, sub := range []menuDef{
{"健康档案", "/patient/health-records", "FolderOpenOutlined"},
{"慢病管理", "/patient/chronic", "SafetyCertificateOutlined"},
} {
createMenu(sub.name, sub.path, sub.icon, i+1, phID)
}
psID := createMenu("服务中心", "", "ShoppingCartOutlined", 23, 0)
for i, sub := range []menuDef{
{"我的处方", "/patient/prescription", "FileTextOutlined"},
{"药品商城", "/patient/pharmacy", "ShoppingCartOutlined"},
{"缴费记录", "/patient/payment", "PayCircleOutlined"},
} {
createMenu(sub.name, sub.path, sub.icon, i+1, psID)
}
createMenu("个人中心", "/patient/profile", "UserOutlined", 24, 0)
// ── 医生端菜单 ──
createMenu("工作台", "/doctor/workbench", "DashboardOutlined", 30, 0)
dcID := createMenu("问诊管理", "", "MedicineBoxOutlined", 31, 0)
for i, sub := range []menuDef{
{"接诊列表", "/doctor/consult", "MessageOutlined"},
{"患者管理", "/doctor/patient", "UserOutlined"},
} {
createMenu(sub.name, sub.path, sub.icon, i+1, dcID)
}
createMenu("排班管理", "/doctor/schedule", "CalendarOutlined", 32, 0)
createMenu("慢病管理", "/doctor/chronic", "HeartOutlined", 33, 0)
createMenu("收入管理", "/doctor/income", "DollarOutlined", 34, 0)
createMenu("待办任务", "/doctor/tasks", "CheckCircleOutlined", 35, 0)
createMenu("资质认证", "/doctor/certification", "SafetyCertificateOutlined", 36, 0)
createMenu("个人信息", "/doctor/profile", "SettingOutlined", 37, 0)
log.Println("[SeedRBAC] 患者端/医生端菜单补充完成")
}
// backfillMenuIcons 为已存在但缺少 icon 的菜单补全图标(幂等)
func backfillMenuIcons(db *gorm.DB) {
// 路径 → 图标映射(覆盖所有三端菜单)
pathIconMap := map[string]string{
// 管理端
"/admin/dashboard": "DashboardOutlined",
"/admin/patients": "UserOutlined",
"/admin/doctors": "MedicineBoxOutlined",
"/admin/admins": "SettingOutlined",
"/admin/departments": "ApartmentOutlined",
"/admin/consultations": "FileSearchOutlined",
"/admin/prescription": "FileTextOutlined",
"/admin/pharmacy": "MedicineBoxOutlined",
"/admin/ai-config": "RobotOutlined",
"/admin/compliance": "SafetyCertificateOutlined",
"/admin/agents": "RobotOutlined",
"/admin/ai-logs": "FundOutlined",
"/admin/workflows": "DeploymentUnitOutlined",
"/admin/tasks": "CheckCircleOutlined",
"/admin/knowledge": "BookOutlined",
"/admin/safety": "SafetyOutlined",
"/admin/roles": "SafetyOutlined",
"/admin/menus": "AppstoreOutlined",
// 患者端
"/patient/home": "HomeOutlined",
"/patient/doctors": "SearchOutlined",
"/patient/consult": "MessageOutlined",
"/patient/pre-consult": "FileTextOutlined",
"/patient/health-records": "FolderOpenOutlined",
"/patient/chronic": "SafetyCertificateOutlined",
"/patient/prescription": "FileTextOutlined",
"/patient/pharmacy": "ShoppingCartOutlined",
"/patient/payment": "PayCircleOutlined",
"/patient/profile": "UserOutlined",
// 医生端
"/doctor/workbench": "DashboardOutlined",
"/doctor/consult": "MessageOutlined",
"/doctor/patient": "UserOutlined",
"/doctor/schedule": "CalendarOutlined",
"/doctor/chronic": "HeartOutlined",
"/doctor/income": "DollarOutlined",
"/doctor/tasks": "CheckCircleOutlined",
"/doctor/chronic/review": "SafetyCertificateOutlined",
"/doctor/certification": "SafetyCertificateOutlined",
"/doctor/profile": "SettingOutlined",
}
// 名称 → 图标映射(用于无 path 的分组菜单)
nameIconMap := map[string]string{
"用户管理": "TeamOutlined",
"智能体平台": "ApiOutlined",
"系统管理": "SafetyCertificateOutlined",
"在线问诊": "MedicineBoxOutlined",
"健康管理": "HeartOutlined",
"服务中心": "ShoppingCartOutlined",
"问诊管理": "MedicineBoxOutlined",
}
var updated int
var menus []model.Menu
db.Where("icon = '' OR icon IS NULL").Find(&menus)
for _, m := range menus {
icon := ""
if m.Path != "" {
icon = pathIconMap[m.Path]
}
if icon == "" && m.Path == "" {
icon = nameIconMap[m.Name]
}
if icon != "" {
db.Model(&model.Menu{}).Where("id = ?", m.ID).Update("icon", icon)
updated++
}
}
if updated > 0 {
log.Printf("[SeedRBAC] 已补全 %d 个菜单的图标", updated)
}
}
......@@ -3,6 +3,7 @@ package doctor
import (
"context"
"errors"
"time"
"gorm.io/gorm"
......@@ -44,6 +45,9 @@ func (s *Service) GetDepartments(ctx context.Context) ([]model.Department, error
type DoctorWithDepartment struct {
model.Doctor
DepartmentName string `json:"department_name"`
NextAvailableDate *string `json:"next_available_date"`
NextAvailableTime *string `json:"next_available_time"`
AvailableSlots int `json:"available_slots"`
}
func (s *Service) GetDoctorList(ctx context.Context, params *DoctorListParams) (*PaginatedResponse, error) {
......@@ -54,46 +58,59 @@ func (s *Service) GetDoctorList(ctx context.Context, params *DoctorListParams) (
params.PageSize = 20
}
// 先查询医生列表
query := s.db.Model(&model.Doctor{}).Where("status = ?", "approved")
today := time.Now().Format("2006-01-02")
weekLater := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
subQuery := s.db.Table("doctor_schedules").
Select("doctor_id, MIN(date) as next_available_date, MIN(start_time) as next_available_time, SUM(remaining) as available_slots").
Where("date >= ? AND date <= ? AND remaining > 0", today, weekLater).
Group("doctor_id")
query := s.db.Table("doctors").
Select("doctors.*, departments.name as department_name, sched.next_available_date, sched.next_available_time, sched.available_slots").
Joins("LEFT JOIN departments ON doctors.department_id = departments.id").
Joins("LEFT JOIN (?) as sched ON doctors.id = sched.doctor_id", subQuery).
Where("doctors.status = ? AND doctors.deleted_at IS NULL", "approved")
if params.DepartmentID != "" {
query = query.Where("department_id = ?", params.DepartmentID)
query = query.Where("doctors.department_id = ?", params.DepartmentID)
}
if params.Keyword != "" {
query = query.Where("name LIKE ?", "%"+params.Keyword+"%")
query = query.Where("doctors.name LIKE ?", "%"+params.Keyword+"%")
}
var total int64
query.Count(&total)
s.db.Table("doctors").Where("status = ? AND deleted_at IS NULL", "approved").
Scopes(func(db *gorm.DB) *gorm.DB {
if params.DepartmentID != "" {
db = db.Where("department_id = ?", params.DepartmentID)
}
if params.Keyword != "" {
db = db.Where("name LIKE ?", "%"+params.Keyword+"%")
}
return db
}).Count(&total)
// 有排班的排前面
baseOrder := "CASE WHEN sched.next_available_date IS NULL THEN 1 ELSE 0 END"
switch params.SortBy {
case "rating":
query = query.Order("rating DESC")
query = query.Order(baseOrder + ", doctors.rating DESC")
case "consult_count":
query = query.Order("consult_count DESC")
query = query.Order(baseOrder + ", doctors.consult_count DESC")
case "price":
query = query.Order("price ASC")
query = query.Order(baseOrder + ", doctors.price ASC")
default:
query = query.Order("rating DESC")
query = query.Order(baseOrder + ", doctors.rating DESC")
}
var doctors []model.Doctor
var result []DoctorWithDepartment
offset := (params.Page - 1) * params.PageSize
if err := query.Preload("Department").Offset(offset).Limit(params.PageSize).Find(&doctors).Error; err != nil {
if err := query.Offset(offset).Limit(params.PageSize).Scan(&result).Error; err != nil {
return nil, err
}
// 构建返回结果
var result []DoctorWithDepartment
for _, doctor := range doctors {
result = append(result, DoctorWithDepartment{
Doctor: doctor,
DepartmentName: doctor.Department.Name,
})
}
return &PaginatedResponse{
List: result,
Total: total,
......
......@@ -172,10 +172,15 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *
// 更新库存状态
tx.Exec("UPDATE medicines SET status = CASE WHEN stock <= 0 THEN 'out_of_stock' WHEN stock <= stock_warning THEN 'low_stock' ELSE 'normal' END WHERE deleted_at IS NULL")
// 如果有关联问诊,更新问诊的处方ID
// 如果有关联问诊,更新问诊的处方ID和诊断信息
if req.ConsultID != "" {
tx.Model(&model.Consultation{}).Where("id = ?", req.ConsultID).
Update("prescription_id", prescription.ID)
updates := map[string]interface{}{
"prescription_id": prescription.ID,
}
if req.Diagnosis != "" {
updates["diagnosis"] = req.Diagnosis
}
tx.Model(&model.Consultation{}).Where("id = ?", req.ConsultID).Updates(updates)
}
tx.Commit()
......
......@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
......@@ -51,6 +50,7 @@ type LoginResponse struct {
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User *model.User `json:"user"`
HomePath string `json:"home_path"`
Menus []model.Menu `json:"menus"`
}
......@@ -97,11 +97,14 @@ func (s *Service) Register(ctx context.Context, req *RegisterRequest) (*LoginRes
return nil, err
}
// 确定用户角色,默认为 patient
// 确定用户角色:验证请求的角色是否存在于 DB,默认为 patient
role := "patient"
if req.Role == "doctor" || req.Role == "admin" {
if req.Role != "" {
var roleRecord model.Role
if err := s.db.Where("code = ? AND status = ?", req.Role, "active").First(&roleRecord).Error; err == nil {
role = req.Role
}
}
fmt.Printf("最终角色: %s\n", role)
user := &model.User{
......@@ -135,11 +138,16 @@ func (s *Service) Register(ctx context.Context, req *RegisterRequest) (*LoginRes
return nil, err
}
menus := s.getUserMenus(user.ID)
homePath := s.getRoleHomePath(user.Role)
return &LoginResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
ExpiresIn: tokenPair.ExpiresIn,
User: user,
HomePath: homePath,
Menus: menus,
}, nil
}
......@@ -172,12 +180,14 @@ func (s *Service) Login(ctx context.Context, req *LoginRequest) (*LoginResponse,
}
menus := s.getUserMenus(user.ID)
homePath := s.getRoleHomePath(user.Role)
return &LoginResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
ExpiresIn: tokenPair.ExpiresIn,
User: &user,
HomePath: homePath,
Menus: menus,
}, nil
}
......@@ -193,6 +203,7 @@ func (s *Service) GetUserByID(ctx context.Context, userID string) (*model.User,
// UserProfileResponse 用户完整信息响应(包含菜单)
type UserProfileResponse struct {
User *model.User `json:"user"`
HomePath string `json:"home_path"`
Menus []model.Menu `json:"menus"`
}
......@@ -204,86 +215,80 @@ func (s *Service) GetUserProfile(ctx context.Context, userID string) (*UserProfi
}
menus := s.getUserMenus(userID)
homePath := s.getRoleHomePath(user.Role)
return &UserProfileResponse{
User: &user,
HomePath: homePath,
Menus: menus,
}, nil
}
// getUserMenus 基于 User.Role 返回对应端菜单树
// admin → /admin/* , patient → /patient/* , doctor → /doctor/*
// getRoleHomePath 查询角色的 home_path
func (s *Service) getRoleHomePath(roleCode string) string {
var role model.Role
if err := s.db.Select("home_path").Where("code = ?", roleCode).First(&role).Error; err != nil {
return ""
}
return role.HomePath
}
// getUserMenus 基于用户角色从 role_menus 关联表获取菜单树
func (s *Service) getUserMenus(userID string) []model.Menu {
var user model.User
if err := s.db.Select("role").Where("id = ?", userID).First(&user).Error; err != nil {
fmt.Printf("[getUserMenus] 查询用户失败: %v\n", err)
return nil
}
prefix := rolePathPrefix(user.Role)
fmt.Printf("[getUserMenus] userID=%s role=%s prefix=%s\n", userID, user.Role, prefix)
if prefix == "" {
return []model.Menu{}
// 查找角色记录
var roleRecord model.Role
if err := s.db.Where("code = ?", user.Role).First(&roleRecord).Error; err != nil {
return nil
}
var flatMenus []model.Menu
s.db.Where("visible = ? AND status = ?", true, "active").
Order("sort ASC, id ASC").Find(&flatMenus)
fmt.Printf("[getUserMenus] 查询到 %d 条可见菜单\n", len(flatMenus))
filtered := filterMenusByPrefix(flatMenus, prefix)
fmt.Printf("[getUserMenus] 过滤后 %d 条菜单 (prefix=%s)\n", len(filtered), prefix)
tree := buildMenuTree(filtered, 0)
fmt.Printf("[getUserMenus] 构建菜单树 %d 个顶级节点\n", len(tree))
return tree
}
// rolePathPrefix 返回角色对应的路由前缀
func rolePathPrefix(role string) string {
switch role {
case "admin":
return "/admin/"
case "patient":
return "/patient/"
case "doctor":
return "/doctor/"
default:
return ""
// 从 role_menus 获取关联的菜单ID
var menuIDs []uint
s.db.Model(&model.RoleMenu{}).Where("role_id = ?", roleRecord.ID).Pluck("menu_id", &menuIDs)
if len(menuIDs) == 0 {
return nil
}
}
// filterMenusByPrefix 筛选属于指定路径前缀的菜单(包含父级分组节点)
func filterMenusByPrefix(menus []model.Menu, prefix string) []model.Menu {
matchIDs := map[uint]bool{}
parentIDs := map[uint]bool{}
// 查询这些菜单(仅 visible + active)
var menus []model.Menu
s.db.Where("id IN ? AND visible = ? AND status = ?", menuIDs, true, "active").
Order("sort asc, id asc").Find(&menus)
// 确保父节点也被包含
existIDs := map[uint]bool{}
for _, m := range menus {
if m.Path != "" && strings.HasPrefix(m.Path, prefix) {
matchIDs[m.ID] = true
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
existIDs[m.ID] = true
}
}
}
for changed := true; changed; {
changed = false
missingParentIDs := map[uint]bool{}
for _, m := range menus {
if parentIDs[m.ID] && !matchIDs[m.ID] {
matchIDs[m.ID] = true
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
if m.ParentID != 0 && !existIDs[m.ParentID] {
missingParentIDs[m.ParentID] = true
}
changed = true
}
for len(missingParentIDs) > 0 {
ids := make([]uint, 0, len(missingParentIDs))
for id := range missingParentIDs {
ids = append(ids, id)
}
var parents []model.Menu
s.db.Where("id IN ? AND visible = ? AND status = ?", ids, true, "active").Find(&parents)
missingParentIDs = map[uint]bool{}
for _, p := range parents {
if !existIDs[p.ID] {
menus = append(menus, p)
existIDs[p.ID] = true
if p.ParentID != 0 && !existIDs[p.ParentID] {
missingParentIDs[p.ParentID] = true
}
var result []model.Menu
for _, m := range menus {
if matchIDs[m.ID] {
result = append(result, m)
}
}
return result
}
return buildMenuTree(menus, 0)
}
// buildMenuTree 将扁平菜单列表构建为树形结构
......
......@@ -161,9 +161,9 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
// v15: 根据用户消息智能筛选工具
// v15: 根据用户消息智能筛选工具(按角色过滤参数,如 navigate_page 只展示对应角色的页面)
selectedTools := a.getFilteredTools(input.Message)
tools := a.getToolSchemas(selectedTools)
tools := a.getToolSchemasForRole(selectedTools, input.UserRole)
var toolCallResults []ToolCallResult
totalTokens := 0
......@@ -276,9 +276,9 @@ func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent fu
messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
// v15: 根据用户消息智能筛选工具
// v15: 根据用户消息智能筛选工具(按角色过滤参数,如 navigate_page 只展示对应角色的页面)
selectedTools := a.getFilteredTools(input.Message)
tools := a.getToolSchemas(selectedTools)
tools := a.getToolSchemasForRole(selectedTools, input.UserRole)
var toolCallResults []ToolCallResult
totalTokens := 0
......@@ -459,7 +459,11 @@ func (a *ReActAgent) getFilteredTools(userMessage string) []string {
}
func (a *ReActAgent) getToolSchemas(toolNames []string) []interface{} {
schemas := a.registry.GetSchemas(toolNames)
return a.getToolSchemasForRole(toolNames, "")
}
func (a *ReActAgent) getToolSchemasForRole(toolNames []string, role string) []interface{} {
schemas := a.registry.GetSchemasForRole(toolNames, role)
result := make([]interface{}, len(schemas))
for i, s := range schemas {
result[i] = s
......
......@@ -26,12 +26,17 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
}
func (r *ToolRegistry) GetSchemas(names []string) []ToolSchema {
return r.GetSchemasForRole(names, "")
}
// GetSchemasForRole 获取工具 Schema,支持按角色过滤参数(如 navigate_page 只展示对应角色的页面)
func (r *ToolRegistry) GetSchemasForRole(names []string, role string) []ToolSchema {
r.mu.RLock()
defer r.mu.RUnlock()
var schemas []ToolSchema
for _, name := range names {
if t, ok := r.tools[name]; ok {
schemas = append(schemas, ToSchema(t))
schemas = append(schemas, ToSchemaForRole(t, role))
}
}
return schemas
......
......@@ -10,6 +10,13 @@ type Tool interface {
Execute(ctx context.Context, params map[string]interface{}) (interface{}, error)
}
// RoleAwareTool 角色感知工具接口 — 工具参数可根据用户角色动态调整
// 例如 navigate_page 工具只向患者展示患者端页面,避免 LLM 生成跨角色导航
type RoleAwareTool interface {
Tool
ParametersForRole(role string) []ToolParameter
}
// ToolParameter 工具参数定义
type ToolParameter struct {
Name string `json:"name"`
......@@ -52,9 +59,25 @@ type PropertySchema struct {
// ToSchema 将 Tool 转换为 OpenAI Function Calling 格式
func ToSchema(t Tool) ToolSchema {
return ToSchemaForRole(t, "")
}
// ToSchemaForRole 将 Tool 转换为 OpenAI Function Calling 格式,支持按角色过滤参数
func ToSchemaForRole(t Tool, role string) ToolSchema {
// 如果工具实现了 RoleAwareTool 且传入了角色,使用角色感知的参数
var params []ToolParameter
if role != "" {
if rat, ok := t.(RoleAwareTool); ok {
params = rat.ParametersForRole(role)
}
}
if params == nil {
params = t.Parameters()
}
props := make(map[string]PropertySchema)
required := make([]string, 0) // 必须初始化为空数组,否则 JSON 序列化为 null 会导致 OpenAI 报错
for _, p := range t.Parameters() {
for _, p := range params {
props[p.Name] = PropertySchema{
Type: p.Type,
Description: p.Description,
......
......@@ -87,56 +87,33 @@ func (t *NavigatePageTool) Description() string {
return "为用户准备页面导航(不会自动打开页面)。调用此工具后,系统会在对话中显示一个'打开页面'按钮,用户点击后才会跳转。【重要】必须根据当前用户角色选择对应端的页面:admin用户选admin_*页面,doctor用户选doctor_*页面,patient用户选patient_*页面,否则会被拒绝访问。"
}
func (t *NavigatePageTool) Parameters() []agent.ToolParameter {
return t.ParametersForRole("")
}
// ParametersForRole 实现 RoleAwareTool 接口,按用户角色过滤可导航的页面
// 患者只能看到患者端页面,医生只能看到医生端页面,admin 可看到所有页面
func (t *NavigatePageTool) ParametersForRole(role string) []agent.ToolParameter {
cache := loadMenuCache()
// 构建 page_code 枚举和说明(按角色分组)
// 构建 page_code 枚举和说明(按角色过滤)
codes := make([]string, 0, len(cache))
adminPages := make([]string, 0)
doctorPages := make([]string, 0)
patientPages := make([]string, 0)
publicPages := make([]string, 0)
var pageDescs []string
for code, entry := range cache {
codes = append(codes, code)
pageInfo := fmt.Sprintf("%s (%s)", code, entry.Name)
switch entry.RoleScope {
case "admin":
adminPages = append(adminPages, pageInfo)
case "doctor":
doctorPages = append(doctorPages, pageInfo)
case "patient":
patientPages = append(patientPages, pageInfo)
default:
publicPages = append(publicPages, pageInfo)
// 按角色过滤:非 admin 用户只能看到自己角色对应的页面和公共页面
if role != "" && role != "admin" {
if entry.RoleScope != role && entry.RoleScope != "public" {
continue
}
}
codes = append(codes, code)
pageDescs = append(pageDescs, fmt.Sprintf("- %s (%s)", code, entry.Name))
}
// 构建按角色分组的说明
var desc strings.Builder
desc.WriteString("目标页面代码。【权限说明】只能导航到当前用户角色对应的页面,否则会被拒绝。")
if len(adminPages) > 0 {
desc.WriteString("\n\n【管理端页面 - 仅admin可访问】")
for _, p := range adminPages {
desc.WriteString("\n- " + p)
}
}
if len(doctorPages) > 0 {
desc.WriteString("\n\n【医生端页面 - 仅doctor可访问】")
for _, p := range doctorPages {
desc.WriteString("\n- " + p)
}
}
if len(patientPages) > 0 {
desc.WriteString("\n\n【患者端页面 - 仅patient可访问】")
for _, p := range patientPages {
desc.WriteString("\n- " + p)
}
}
if len(publicPages) > 0 {
desc.WriteString("\n\n【公共页面】")
for _, p := range publicPages {
desc.WriteString("\n- " + p)
}
desc.WriteString("目标页面代码。可导航的页面列表:")
for _, p := range pageDescs {
desc.WriteString("\n" + p)
}
return []agent.ToolParameter{
......
-- 清理不存在的菜单ID关联
DELETE FROM role_menus WHERE menu_id NOT IN (SELECT id FROM menus);
-- 管理端: 添加 AI日志(37)
INSERT INTO role_menus (role_id, menu_id) VALUES (1, 37) ON CONFLICT DO NOTHING;
-- 医生端(role_id=2): 补全缺失菜单
-- 现有: 24(工作台),25(问诊大厅),27(排班管理),28(收入统计),29(个人中心)
-- 缺失: 26(患者管理),38(AI辅助诊断),39(资质认证),40(慢病审核),41(待办任务)
INSERT INTO role_menus (role_id, menu_id) VALUES
(2, 26), (2, 38), (2, 39), (2, 40), (2, 41)
ON CONFLICT DO NOTHING;
-- 患者端(role_id=3): 补全所有菜单
-- 30(首页),31(找医生),32(我的问诊),33(慢病管理),34(我的处方),35(健康档案),36(个人中心),42(支付记录),43(药品订单)
INSERT INTO role_menus (role_id, menu_id) VALUES
(3, 30), (3, 31), (3, 32), (3, 33), (3, 34), (3, 35), (3, 36), (3, 42), (3, 43)
ON CONFLICT DO NOTHING;
INSERT INTO menus (parent_id, name, path, icon, type, sort, visible, status, created_at, updated_at) VALUES
(4, 'AI日志', '/admin/ai-logs', 'FileTextOutlined', 'menu', 9, true, 'active', NOW(), NOW()),
(0, 'AI辅助诊断', '/doctor/ai-assist', 'RobotOutlined', 'menu', 7, true, 'active', NOW(), NOW()),
(0, '资质认证', '/doctor/certification', 'SafetyCertificateOutlined', 'menu', 8, true, 'active', NOW(), NOW()),
(0, '慢病审核', '/doctor/chronic/review', 'HeartOutlined', 'menu', 9, true, 'active', NOW(), NOW()),
(0, '待办任务', '/doctor/tasks', 'CheckCircleOutlined', 'menu', 10, true, 'active', NOW(), NOW()),
(0, '支付记录', '/patient/payment', 'DollarOutlined', 'menu', 8, true, 'active', NOW(), NOW()),
(0, '药品订单', '/patient/pharmacy/order', 'ShoppingOutlined', 'menu', 9, true, 'active', NOW(), NOW());
......@@ -88,6 +88,7 @@ func main() {
{ParentID: 4, Name: "知识库", Path: "/admin/knowledge", Icon: "BookOutlined", Type: "menu", Sort: 6, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 4, Name: "内容安全", Path: "/admin/safety", Icon: "SafetyOutlined", Type: "menu", Sort: 7, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 4, Name: "AI运营中心", Path: "/admin/ai-center", Icon: "FundOutlined", Type: "menu", Sort: 8, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 4, Name: "AI日志", Path: "/admin/ai-logs", Icon: "FileTextOutlined", Type: "menu", Sort: 9, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
}
for i := range aiMenus {
db.Create(&aiMenus[i])
......@@ -102,11 +103,43 @@ func main() {
db.Create(&sysMenus[i])
}
// 医生端菜单
doctorMenus := []Menu{
{ParentID: 0, Name: "工作台", Path: "/doctor/workbench", Icon: "DashboardOutlined", Type: "menu", Sort: 1, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "问诊大厅", Path: "/doctor/consult", Icon: "MessageOutlined", Type: "menu", Sort: 2, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "患者管理", Path: "/doctor/patient", Icon: "UserOutlined", Type: "menu", Sort: 3, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "排班管理", Path: "/doctor/schedule", Icon: "CalendarOutlined", Type: "menu", Sort: 4, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "收入统计", Path: "/doctor/income", Icon: "DollarOutlined", Type: "menu", Sort: 5, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "个人中心", Path: "/doctor/profile", Icon: "UserOutlined", Type: "menu", Sort: 6, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "AI辅助诊断", Path: "/doctor/ai-assist", Icon: "RobotOutlined", Type: "menu", Sort: 7, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "资质认证", Path: "/doctor/certification", Icon: "SafetyCertificateOutlined", Type: "menu", Sort: 8, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "慢病审核", Path: "/doctor/chronic/review", Icon: "HeartOutlined", Type: "menu", Sort: 9, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "待办任务", Path: "/doctor/tasks", Icon: "CheckCircleOutlined", Type: "menu", Sort: 10, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
}
for i := range doctorMenus {
db.Create(&doctorMenus[i])
}
// 患者端菜单
patientMenus := []Menu{
{ParentID: 0, Name: "首页", Path: "/patient/home", Icon: "HomeOutlined", Type: "menu", Sort: 1, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "找医生", Path: "/patient/doctors", Icon: "TeamOutlined", Type: "menu", Sort: 2, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "我的问诊", Path: "/patient/consult", Icon: "MessageOutlined", Type: "menu", Sort: 3, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "慢病管理", Path: "/patient/chronic", Icon: "HeartOutlined", Type: "menu", Sort: 4, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "我的处方", Path: "/patient/prescription", Icon: "FileTextOutlined", Type: "menu", Sort: 5, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "健康档案", Path: "/patient/health-records", Icon: "FolderOpenOutlined", Type: "menu", Sort: 6, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "个人中心", Path: "/patient/profile", Icon: "UserOutlined", Type: "menu", Sort: 7, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "支付记录", Path: "/patient/payment", Icon: "DollarOutlined", Type: "menu", Sort: 8, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
{ParentID: 0, Name: "药品订单", Path: "/patient/pharmacy/order", Icon: "ShoppingOutlined", Type: "menu", Sort: 9, Visible: true, Status: "active", CreatedAt: now, UpdatedAt: now},
}
for i := range patientMenus {
db.Create(&patientMenus[i])
}
fmt.Println("✅ 菜单数据更新完成!")
fmt.Println("📋 菜单结构:")
fmt.Println(" 1. 运营大盘")
fmt.Println(" 2. 用户管理 (患者/医生/管理员)")
fmt.Println(" 3. 医疗业务 (科室/问诊/处方/药品/合规)")
fmt.Println(" 4. 智能体平台 (AI配置/Agent/工具/工作流/审核/知识库/安全/运营)")
fmt.Println(" 5. 系统管理 (角色/菜单)")
fmt.Println(" 管理端: 5个顶级菜单 + 19个子菜单")
fmt.Println(" 医生端: 10个菜单")
fmt.Println(" 患者端: 9个菜单")
fmt.Println(" 总计: 43个菜单")
}
......@@ -16,6 +16,9 @@ export interface Doctor {
price: number; // 问诊价格(分)
is_online: boolean;
schedule: DoctorSchedule[];
next_available_date: string | null;
next_available_time: string | null;
available_slots: number;
}
export interface DoctorSchedule {
......
......@@ -2,7 +2,7 @@ import { post, get, put } from './request';
import { MOCK_ENABLED, mockRegister, mockLogin } from './mock';
// 用户相关类型定义
export type UserRole = 'patient' | 'doctor' | 'admin';
export type UserRole = string;
export interface LoginParams {
phone?: string;
......@@ -16,7 +16,7 @@ export interface RegisterParams {
password: string;
code: string;
real_name?: string;
role?: 'patient' | 'doctor';
role?: string;
gender?: string;
age?: number;
}
......@@ -26,7 +26,7 @@ export interface UserInfo {
phone: string;
real_name: string;
avatar: string;
role: 'patient' | 'doctor' | 'admin';
role: string;
is_verified: boolean;
status: 'active' | 'disabled';
gender?: string;
......@@ -39,6 +39,7 @@ export interface LoginResponse {
refresh_token: string;
expires_in: number;
user: UserInfo;
home_path: string;
menus: Array<{
id: number;
name: string;
......@@ -91,6 +92,7 @@ export const userApi = {
// 获取用户完整信息(包含菜单)
getUserProfile: () => get<{
user: UserInfo;
home_path: string;
menus: Array<{
id: number;
parent_id: number;
......
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Form, Input, Button, App, Modal } from 'antd';
import {
MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined,
SafetyCertificateOutlined, TeamOutlined, CloudServerOutlined,
HeartOutlined,
LockOutlined, UserOutlined, MedicineBoxOutlined,
SafetyCertificateOutlined, TeamOutlined, RobotOutlined,
ThunderboltOutlined, CheckCircleOutlined, ArrowRightOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import { userApi } from '@/api/user';
......@@ -15,15 +15,13 @@ import { userApi } from '@/api/user';
const LoginPage: React.FC = () => {
const router = useRouter();
const { message } = App.useApp();
const { setUser, setTokens } = useUserStore();
const { setUser, setTokens, setHomePath } = useUserStore();
const [loading, setLoading] = useState(false);
const [forgotOpen, setForgotOpen] = useState(false);
// 处理统一登录(支持手机号或用户名)
const handleUnifiedLogin = async (values: { account: string; password: string }) => {
setLoading(true);
try {
// 判断是手机号还是用户名
const isPhone = /^1\d{10}$/.test(values.account);
const loginData = isPhone
? { phone: values.account, password: values.password }
......@@ -32,23 +30,17 @@ const LoginPage: React.FC = () => {
const response = await userApi.login(loginData);
setTokens(response.data.access_token, response.data.refresh_token);
// 登录成功后获取用户完整信息(包含菜单)
let homePath = response.data.home_path || '/patient/home';
try {
const profileRes = await userApi.getUserProfile();
setUser(profileRes.data.user, profileRes.data.menus as any[]);
setUser(profileRes.data.user, profileRes.data.menus as any[], profileRes.data.home_path);
if (profileRes.data.home_path) homePath = profileRes.data.home_path;
} catch {
// 如果获取profile失败,使用登录返回的基本信息
setUser(response.data.user);
setUser(response.data.user, response.data.menus as any[], response.data.home_path);
}
setHomePath(homePath);
message.success('登录成功');
const role = response.data.user.role;
if (role === 'doctor') {
router.push('/doctor/workbench');
} else if (role === 'admin') {
router.push('/admin/dashboard');
} else {
router.push('/patient/home');
}
router.push(homePath);
} catch (error) {
message.error('登录失败,请检查账号密码');
} finally {
......@@ -57,200 +49,194 @@ const LoginPage: React.FC = () => {
};
return (
<div style={{
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'auto',
padding: '32px 0',
background: 'linear-gradient(135deg, #042F2E 0%, #0F766E 40%, #0D9488 70%, #14B8A6 100%)',
}}>
{/* 响应式样式 */}
<>
<style>{`
.login-left-panel { display: none; }
.login-mobile-logo { display: flex; }
.login-right-panel { padding: 32px; }
@media (min-width: 768px) {
.login-left-panel { display: flex; }
.login-mobile-logo { display: none !important; }
.login-right-panel { padding: 40px; }
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(43,109,232,0.3); }
50% { box-shadow: 0 0 40px rgba(43,109,232,0.6); }
}
.login-container {
background: linear-gradient(135deg, #0A1628 0%, #1A3B7A 25%, #2B6DE8 50%, #5B8FF9 75%, #2B6DE8 100%);
background-size: 200% 200%;
animation: gradient-shift 15s ease infinite;
}
.glass-card {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.2);
}
.feature-card:hover {
transform: translateY(-4px);
background: rgba(255,255,255,0.15);
}
@media (max-width: 1024px) {
.left-intro { display: none !important; }
.right-form { width: 100% !important; max-width: 440px !important; }
}
`}</style>
{/* 装饰背景 */}
<div className="login-container" style={{
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'auto', padding: 24,
}}>
{/* 动态背景装饰 */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: '10%', left: '5%', width: 256, height: 256, background: 'rgba(255,255,255,0.05)', borderRadius: '50%', filter: 'blur(64px)' }} />
<div style={{ position: 'absolute', bottom: '15%', right: '8%', width: 320, height: 320, background: 'rgba(20,184,166,0.1)', borderRadius: '50%', filter: 'blur(64px)' }} />
<div style={{ position: 'absolute', top: '40%', right: '20%', width: 160, height: 160, background: 'rgba(34,211,238,0.1)', borderRadius: '50%', filter: 'blur(40px)' }} />
<div style={{ position: 'absolute', top: '15%', left: '10%', width: 400, height: 400, background: 'radial-gradient(circle, rgba(91,143,249,0.15) 0%, transparent 70%)', animation: 'float 8s ease-in-out infinite' }} />
<div style={{ position: 'absolute', bottom: '20%', right: '15%', width: 300, height: 300, background: 'radial-gradient(circle, rgba(43,109,232,0.2) 0%, transparent 70%)', animation: 'float 10s ease-in-out infinite 2s' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', width: 500, height: 500, background: 'radial-gradient(circle, rgba(255,255,255,0.03) 0%, transparent 70%)', animation: 'float 12s ease-in-out infinite 4s' }} />
</div>
<div style={{
position: 'relative',
zIndex: 10,
display: 'flex',
width: '100%',
maxWidth: 920,
margin: '0 16px',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 25px 60px rgba(0,0,0,0.3)',
position: 'relative', zIndex: 10, display: 'flex', width: '100%', maxWidth: 1400,
minHeight: 680, borderRadius: 24, overflow: 'hidden',
boxShadow: '0 30px 80px rgba(0,0,0,0.4)',
}}>
{/* 左侧品牌区 - 仅在 md 及以上屏幕显示 */}
<div className="login-left-panel" style={{
flexDirection: 'column',
justifyContent: 'space-between',
width: 400,
flexShrink: 0,
padding: 32,
color: '#fff',
background: 'linear-gradient(180deg, rgba(15,118,110,0.95) 0%, rgba(13,148,136,0.9) 50%, rgba(20,184,166,0.85) 100%)',
backdropFilter: 'blur(20px)',
{/* 左侧系统介绍 - 2/3 */}
<div className="left-intro" style={{
flex: 2, padding: 60, color: '#fff',
background: 'linear-gradient(135deg, rgba(26,59,122,0.95) 0%, rgba(43,109,232,0.92) 100%)',
backdropFilter: 'blur(30px)', display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
}}>
{/* Logo & 标题 */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 48 }}>
<div style={{
width: 44, height: 44, borderRadius: 12,
background: 'rgba(255,255,255,0.2)',
width: 56, height: 56, borderRadius: 16,
background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(10px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(8px)',
}}>
<MedicineBoxOutlined style={{ fontSize: 20, color: '#fff' }} />
<MedicineBoxOutlined style={{ fontSize: 28, color: '#fff' }} />
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: 1 }}>互联网医院</div>
<div style={{ fontSize: 12, color: 'rgba(191,219,254,0.8)' }}>Internet Hospital Platform</div>
<div style={{ fontSize: 24, fontWeight: 700, letterSpacing: 1 }}>互联网医院</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)', marginTop: 2 }}>Internet Hospital Platform</div>
</div>
</div>
<h2 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12, lineHeight: 1.4 }}>
专业在线医疗<br />服务平台
</h2>
<p style={{ fontSize: 14, color: 'rgba(191,219,254,0.7)', lineHeight: 1.8 }}>
连接优质医疗资源,提供智能问诊、电子处方、<br />在线购药等一站式健康管理服务
<h1 style={{ fontSize: 42, fontWeight: 700, lineHeight: 1.3, marginBottom: 20 }}>
智能医疗<br />触手可及
</h1>
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.8)', lineHeight: 1.8, marginBottom: 48 }}>
连接全国优质医疗资源,AI 赋能精准诊疗<br />
为您提供 7×24 小时在线医疗健康服务
</p>
{/* 数据统计 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 48 }}>
<div className="glass-card" style={{ padding: 20, borderRadius: 16, transition: 'all 0.3s' }}>
<div style={{ fontSize: 32, fontWeight: 700, marginBottom: 4 }}>5000+</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>执业医师</div>
</div>
<div className="glass-card" style={{ padding: 20, borderRadius: 16, transition: 'all 0.3s' }}>
<div style={{ fontSize: 32, fontWeight: 700, marginBottom: 4 }}>200万+</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>服务患者</div>
</div>
<div className="glass-card" style={{ padding: 20, borderRadius: 16, transition: 'all 0.3s' }}>
<div style={{ fontSize: 32, fontWeight: 700, marginBottom: 4 }}>98%</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>满意度</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 专业医生团队 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
{/* 核心功能 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="feature-card glass-card" style={{
display: 'flex', alignItems: 'center', gap: 16, padding: 20, borderRadius: 16,
transition: 'all 0.3s', cursor: 'pointer',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(34,211,238,0.2)',
width: 48, height: 48, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(34,211,238,0.3) 0%, rgba(59,130,246,0.3) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<TeamOutlined style={{ color: '#67e8f9' }} />
<TeamOutlined style={{ fontSize: 22, color: '#67e8f9' }} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>专业医生团队</div>
<div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>三甲医院主治医师在线坐诊</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>三甲医生在线</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>主治及以上职称,平均从业 10 年+</div>
</div>
<ArrowRightOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.5)' }} />
</div>
{/* CA电子签名 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
<div className="feature-card glass-card" style={{
display: 'flex', alignItems: 'center', gap: 16, padding: 20, borderRadius: 16,
transition: 'all 0.3s', cursor: 'pointer',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(74,222,128,0.2)',
width: 48, height: 48, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(167,139,250,0.3) 0%, rgba(139,92,246,0.3) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<SafetyCertificateOutlined style={{ color: '#86efac' }} />
<RobotOutlined style={{ fontSize: 22, color: '#c4b5fd' }} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>CA电子签名</div>
<div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>电子处方合规安全有保障</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>AI 智能辅诊</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>预问诊分诊、辅助诊断、用药建议</div>
</div>
<ArrowRightOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.5)' }} />
</div>
{/* AI智能辅助 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 12, borderRadius: 12,
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(8px)',
<div className="feature-card glass-card" style={{
display: 'flex', alignItems: 'center', gap: 16, padding: 20, borderRadius: 16,
transition: 'all 0.3s', cursor: 'pointer',
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'rgba(167,139,250,0.2)',
width: 48, height: 48, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(74,222,128,0.3) 0%, rgba(34,197,94,0.3) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<CloudServerOutlined style={{ color: '#c4b5fd' }} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>AI智能辅助</div>
<div style={{ fontSize: 12, color: 'rgba(191,219,254,0.6)' }}>AI预问诊与辅助诊断系统</div>
<SafetyCertificateOutlined style={{ fontSize: 22, color: '#86efac' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>电子处方合规</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>CA 数字签名,药品配送到家</div>
</div>
<ArrowRightOutlined style={{ fontSize: 16, color: 'rgba(255,255,255,0.5)' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'rgba(147,197,253,0.5)', marginTop: 24 }}>
<HeartOutlined />
<span>守护每一份健康</span>
</div>
</div>
{/* 右侧登录区 */}
<div className="login-right-panel" style={{
flex: 1,
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: 520,
}}>
{/* 移动端 Logo(仅在左侧面板隐藏时显示) */}
<div className="login-mobile-logo" style={{
alignItems: 'center',
gap: 8,
marginBottom: 32,
}}>
<div style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: '#0D9488',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 18, color: '#fff' }} />
{/* 底部标语 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 13, color: 'rgba(255,255,255,0.6)' }}>
<CheckCircleOutlined />
<span>国家卫健委认证 · 互联网医院牌照 · 数据安全三级等保</span>
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1d2129' }}>互联网医院</div>
</div>
<div style={{ width: '100%', maxWidth: 340, margin: '0 auto' }}>
<div style={{ marginBottom: 32 }}>
<h3 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, color: '#1d2129' }}>欢迎登录</h3>
{/* 右侧登录表单 - 1/3 */}
<div className="right-form" style={{
flex: 1, backgroundColor: '#fff', display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: 60, minWidth: 420,
}}>
<div style={{ marginBottom: 40 }}>
<h2 style={{ fontSize: 28, fontWeight: 700, marginBottom: 8, color: '#1d2129' }}>欢迎登录</h2>
<p style={{ fontSize: 14, color: '#8c8c8c', margin: 0 }}>登录以使用完整的医疗服务功能</p>
</div>
<Form onFinish={handleUnifiedLogin} size="large">
<Form.Item
name="account"
rules={[{ required: true, message: '请输入手机号或用户名' }]}
>
<Form.Item name="account" rules={[{ required: true, message: '请输入手机号或用户名' }]}>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="手机号 / 用户名"
style={{ height: 48, borderRadius: 8, fontSize: 15 }}
style={{ height: 50, borderRadius: 10, fontSize: 15 }}
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码"
style={{ height: 48, borderRadius: 8, fontSize: 15 }}
style={{ height: 50, borderRadius: 10, fontSize: 15 }}
/>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<a onClick={() => setForgotOpen(true)} style={{ fontSize: 13, color: '#8c8c8c', cursor: 'pointer' }}>忘记密码?</a>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
<a onClick={() => setForgotOpen(true)} style={{ fontSize: 13, color: '#8c8c8c' }}>忘记密码?</a>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Button
......@@ -258,33 +244,31 @@ const LoginPage: React.FC = () => {
htmlType="submit"
loading={loading}
block
icon={<ThunderboltOutlined />}
style={{
height: 48,
borderRadius: 8,
fontWeight: 600,
fontSize: 16,
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
border: 'none',
boxShadow: '0 4px 12px rgba(13, 148, 136, 0.35)',
height: 50, borderRadius: 10, fontWeight: 600, fontSize: 16,
background: 'linear-gradient(135deg, #2B6DE8 0%, #5B8FF9 100%)',
border: 'none', boxShadow: '0 6px 20px rgba(43,109,232,0.4)',
}}
>
登录
立即登录
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 14 }}>
<div style={{ textAlign: 'center', marginTop: 28, fontSize: 14 }}>
<span style={{ color: '#8c8c8c' }}>还没有账号?</span>
<Link href="/register" style={{ marginLeft: 4, fontWeight: 500, color: '#0D9488' }}>
<Link href="/register" style={{ marginLeft: 6, fontWeight: 600, color: '#2B6DE8' }}>
立即注册
</Link>
</div>
<div style={{ marginTop: 32, paddingTop: 16, textAlign: 'center', borderTop: '1px solid #f0f0f0' }}>
<p style={{ fontSize: 12, color: '#bfbfbf', margin: 0 }}>
<div style={{ marginTop: 40, paddingTop: 24, textAlign: 'center', borderTop: '1px solid #f0f0f0' }}>
<p style={{ fontSize: 12, color: '#bfbfbf', margin: 0, lineHeight: 1.6 }}>
登录即表示同意
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#0D9488', margin: '0 2px', cursor: 'pointer' }}>《用户服务协议》</a>和
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#0D9488', margin: '0 2px', cursor: 'pointer' }}>《隐私政策》</a>
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#2B6DE8', margin: '0 4px' }}>《用户服务协议》</a>
和
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#2B6DE8', margin: '0 4px' }}>《隐私政策》</a>
</p>
</div>
</div>
......@@ -302,7 +286,7 @@ const LoginPage: React.FC = () => {
<p style={{ color: '#8c8c8c' }}>客服电话:400-888-8888</p>
</div>
</Modal>
</div>
</>
);
};
......
......@@ -16,7 +16,7 @@ const { Title, Text, Paragraph } = Typography;
const RegisterPage: React.FC = () => {
const router = useRouter();
const { message } = App.useApp();
const { setUser, setTokens } = useUserStore();
const { setUser, setTokens, setHomePath } = useUserStore();
const [loading, setLoading] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [countdown, setCountdown] = useState(0);
......@@ -47,14 +47,13 @@ const RegisterPage: React.FC = () => {
try {
const response = await userApi.register({ ...values, code: values.code, role: registerType });
setTokens(response.data.access_token, response.data.refresh_token);
setUser(response.data.user);
const homePath = response.data.home_path || '/patient/home';
setUser(response.data.user, response.data.menus as any[], homePath);
setHomePath(homePath);
message.success('注册成功');
setCurrentStep(2);
const userRole = response.data.user.role;
setTimeout(() => {
if (userRole === 'doctor') router.push('/doctor/workbench');
else if (userRole === 'admin') router.push('/admin/dashboard');
else router.push('/patient/home');
router.push(homePath);
}, 2000);
} catch (error: any) {
message.error(error.message || '注册失败');
......@@ -70,7 +69,7 @@ const RegisterPage: React.FC = () => {
justifyContent: 'center',
overflow: 'auto',
padding: '32px 0',
background: 'linear-gradient(135deg, #042F2E 0%, #0F766E 40%, #0D9488 70%, #0D9488 100%)',
background: 'linear-gradient(135deg, #0A1E4E 0%, #1A56C4 40%, #2B6DE8 70%, #2B6DE8 100%)',
}}>
{/* 装饰背景 */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
......@@ -93,7 +92,7 @@ const RegisterPage: React.FC = () => {
<div style={{ textAlign: 'center', marginBottom: 20 }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: 'linear-gradient(135deg, #0D9488, #0F766E)',
background: 'linear-gradient(135deg, #2B6DE8, #1A56C4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 12px',
}}>
......@@ -118,7 +117,7 @@ const RegisterPage: React.FC = () => {
style={{
padding: 16, borderRadius: 12, cursor: 'pointer',
transition: 'all 0.2s',
border: `2px solid ${registerType === 'patient' ? '#0D9488' : '#e5e7eb'}`,
border: `2px solid ${registerType === 'patient' ? '#2B6DE8' : '#e5e7eb'}`,
backgroundColor: registerType === 'patient' ? '#f0f5ff' : 'transparent',
}}
onClick={() => setRegisterType('patient')}>
......@@ -129,7 +128,7 @@ const RegisterPage: React.FC = () => {
backgroundColor: '#eff6ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<UserOutlined style={{ color: '#0D9488' }} />
<UserOutlined style={{ color: '#2B6DE8' }} />
</div>
<div>
<Text strong style={{ fontSize: 14 }}>我是患者</Text><br />
......@@ -142,7 +141,7 @@ const RegisterPage: React.FC = () => {
style={{
padding: 16, borderRadius: 12, cursor: 'pointer',
transition: 'all 0.2s',
border: `2px solid ${registerType === 'doctor' ? '#0D9488' : '#e5e7eb'}`,
border: `2px solid ${registerType === 'doctor' ? '#2B6DE8' : '#e5e7eb'}`,
backgroundColor: registerType === 'doctor' ? '#f0f5ff' : 'transparent',
}}
onClick={() => setRegisterType('doctor')}>
......@@ -153,7 +152,7 @@ const RegisterPage: React.FC = () => {
backgroundColor: '#eff6ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ color: '#0D9488' }} />
<MedicineBoxOutlined style={{ color: '#2B6DE8' }} />
</div>
<div>
<Text strong style={{ fontSize: 14 }}>我是医生</Text><br />
......@@ -168,8 +167,8 @@ const RegisterPage: React.FC = () => {
style={{
marginTop: 20, height: 40, borderRadius: 8,
fontWeight: 600, border: 'none',
background: 'linear-gradient(90deg, #0D9488, #0F766E)',
boxShadow: '0 4px 12px rgba(13,148,136,0.3)',
background: 'linear-gradient(90deg, #2B6DE8, #1A56C4)',
boxShadow: '0 4px 12px rgba(43,109,232,0.3)',
}}
onClick={() => setCurrentStep(1)}>下一步</Button>
</div>
......@@ -179,7 +178,7 @@ const RegisterPage: React.FC = () => {
<Form form={form} onFinish={handleRegister} size="middle">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input prefix={<MobileOutlined style={{ color: 'rgba(13,148,136,0.6)' }} />} placeholder="手机号" style={{ flex: 1 }} />
<Input prefix={<MobileOutlined style={{ color: 'rgba(43,109,232,0.6)' }} />} placeholder="手机号" style={{ flex: 1 }} />
<Button
disabled={countdown > 0}
onClick={handleSendCode}
......@@ -190,13 +189,13 @@ const RegisterPage: React.FC = () => {
</Space.Compact>
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Input prefix={<SafetyOutlined style={{ color: 'rgba(13,148,136,0.6)' }} />} placeholder="请输入6位验证码" maxLength={6} />
<Input prefix={<SafetyOutlined style={{ color: 'rgba(43,109,232,0.6)' }} />} placeholder="请输入6位验证码" maxLength={6} />
</Form.Item>
<Form.Item name="real_name" rules={[{ required: true, message: '请输入真实姓名' }]}>
<Input prefix={<UserOutlined style={{ color: 'rgba(13,148,136,0.6)' }} />} placeholder="真实姓名" />
<Input prefix={<UserOutlined style={{ color: 'rgba(43,109,232,0.6)' }} />} placeholder="真实姓名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: 'rgba(13,148,136,0.6)' }} />} placeholder="设置密码" />
<Input.Password prefix={<LockOutlined style={{ color: 'rgba(43,109,232,0.6)' }} />} placeholder="设置密码" />
</Form.Item>
<Form.Item name="confirm_password" dependencies={['password']}
rules={[{ required: true, message: '请确认密码' }, ({ getFieldValue }) => ({
......@@ -205,7 +204,7 @@ const RegisterPage: React.FC = () => {
return Promise.reject(new Error('两次输入的密码不一致'));
},
})]}>
<Input.Password prefix={<LockOutlined style={{ color: 'rgba(13,148,136,0.6)' }} />} placeholder="确认密码" />
<Input.Password prefix={<LockOutlined style={{ color: 'rgba(43,109,232,0.6)' }} />} placeholder="确认密码" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
......@@ -227,8 +226,8 @@ const RegisterPage: React.FC = () => {
<Button type="primary" htmlType="submit" loading={loading} block
style={{
height: 40, borderRadius: 8, fontWeight: 600, border: 'none',
background: 'linear-gradient(90deg, #0D9488, #0F766E)',
boxShadow: '0 4px 12px rgba(13,148,136,0.3)',
background: 'linear-gradient(90deg, #2B6DE8, #1A56C4)',
boxShadow: '0 4px 12px rgba(43,109,232,0.3)',
}}>注册</Button>
<Button block style={{ borderRadius: 8 }} onClick={() => setCurrentStep(0)}>上一步</Button>
</Space>
......@@ -254,7 +253,7 @@ const RegisterPage: React.FC = () => {
{currentStep !== 2 && (
<div style={{ textAlign: 'center', marginTop: 16, fontSize: 14 }}>
<span style={{ color: '#8c8c8c' }}>已有账号?</span>
<Link href="/login" style={{ marginLeft: 4, fontWeight: 500, color: '#0D9488' }}>立即登录</Link>
<Link href="/login" style={{ marginLeft: 4, fontWeight: 500, color: '#2B6DE8' }}>立即登录</Link>
</div>
)}
</div>
......
......@@ -168,7 +168,7 @@ const AdminAdminsPage: React.FC = () => {
key: 'admin',
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#0D9488' }} />
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#2B6DE8' }} />
<div>
<Text strong>{record.real_name || '未填写'}</Text>
<br />
......@@ -261,16 +261,8 @@ const AdminAdminsPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>管理员管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理后台管理员账号与权限</div>
</div>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {total} 条记录</span>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<Input placeholder="搜索姓名/用户名" prefix={<SearchOutlined />} value={keyword}
......@@ -282,7 +274,7 @@ const AdminAdminsPage: React.FC = () => {
</div>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable
search={false}
columns={columns}
......
......@@ -60,7 +60,7 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
const columns = [
{
title: '工具名称', dataIndex: 'name', key: 'name',
render: (v: string) => <Space><CodeOutlined style={{ color: '#0D9488' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
render: (v: string) => <Space><CodeOutlined style={{ color: '#2B6DE8' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
......@@ -100,13 +100,13 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
return (
<>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} dataSource={filtered} columns={columns} rowKey="name" loading={isLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: t => `共 ${t} 个工具` }} />
</Card>
<Modal
title={<Space><CodeOutlined style={{ color: '#0D9488' }} /><span>工具详情</span></Space>}
title={<Space><CodeOutlined style={{ color: '#2B6DE8' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, tool: null })}
footer={detailModal.tool && (
......@@ -124,7 +124,7 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
{detailModal.tool && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工具名称"><Text code style={{ color: '#0D9488' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="工具名称"><Text code style={{ color: '#2B6DE8' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="描述">{detailModal.tool.description}</Descriptions.Item>
<Descriptions.Item label="分类"><Tag color={(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).color}>{(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).label}</Tag></Descriptions.Item>
<Descriptions.Item label="状态"><Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} /></Descriptions.Item>
......
......@@ -163,13 +163,13 @@ export default function HTTPToolsTab({ search }: Props) {
</Space>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} dataSource={tools} columns={columns} rowKey="id" loading={isLoading} size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }} />
</Card>
{/* Create/Edit Modal */}
<Modal title={<Space><ApiOutlined style={{ color: '#0D9488' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
<Modal title={<Space><ApiOutlined style={{ color: '#2B6DE8' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen} onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSave} confirmLoading={saveMut.isPending} width={680} destroyOnHidden>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
......
......@@ -121,7 +121,7 @@ export default function SkillsTab({ search }: Props) {
<Col key={skill.skill_id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{ borderRadius: 12, border: '1px solid #E0F2F1', transition: 'all 0.2s' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', transition: 'all 0.2s' }}
styles={{ body: { padding: 16 } }}
hoverable
>
......
......@@ -240,7 +240,7 @@ function AgentsTab() {
title: '智能体', key: 'name',
render: (_: unknown, r: AgentDefinition) => (
<Space>
<RobotOutlined style={{ color: '#0D9488' }} />
<RobotOutlined style={{ color: '#2B6DE8' }} />
<div>
<Text strong>{r.name}</Text>
<br />
......@@ -380,7 +380,7 @@ function AgentsTab() {
<div key={i} style={{ marginBottom: 12, textAlign: m.role === 'user' ? 'right' : 'left' }}>
<div style={{
display: 'inline-block', padding: '8px 14px', borderRadius: 8, maxWidth: '85%',
background: m.role === 'user' ? '#0D9488' : '#f5f5f5',
background: m.role === 'user' ? '#2B6DE8' : '#f5f5f5',
color: m.role === 'user' ? '#fff' : '#333',
textAlign: 'left',
}}>
......@@ -493,16 +493,8 @@ export default function AgentManagementPage() {
const [activeTab, setActiveTab] = useState('agents');
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<RobotOutlined style={{ marginRight: 8, color: '#0D9488' }} />
智能体管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理智能体、工具与技能,支持在线编辑、热重载与对话测试</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......
......@@ -23,7 +23,7 @@ export const CATEGORY_CONFIG: Record<string, { color: string; label: string; ico
export type CATEGORY_CONFIG_TYPE = typeof CATEGORY_CONFIG;
export const SOURCE_CONFIG = {
builtin: { label: '内置', color: '#0D9488' },
builtin: { label: '内置', color: '#2B6DE8' },
http: { label: 'HTTP', color: '#fa8c16' },
} as const;
......
......@@ -6,7 +6,7 @@ import {
Statistic, Tabs, Input, Select, Timeline, Spin, Empty,
} from 'antd';
import {
FundOutlined, SearchOutlined, ToolOutlined, RobotOutlined,
SearchOutlined, ToolOutlined, RobotOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
......@@ -176,49 +176,41 @@ export default function AICenterPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<FundOutlined style={{ marginRight: 8, color: '#0D9488' }} />
AI 运营中心
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>全链路 AI 调用追踪、计量统计与性能监控</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="总调用次数" value={stats?.total_calls ?? 0} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="成功率" value={stats?.total_calls ? Math.round((stats.success_calls / stats.total_calls) * 100) : 0} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="总 Tokens" value={stats?.total_tokens ?? 0} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="Agent 执行" value={stats?.agent_execs ?? 0} prefix={<RobotOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="工具调用" value={stats?.tool_calls ?? 0} prefix={<ToolOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={isLoading} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="模拟调用" value={stats?.mock_calls ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......
......@@ -294,12 +294,7 @@ const AdminAIConfigPage: React.FC = () => {
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>AI 配置</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>配置平台 AI 模型与 Prompt 模板</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Tabs defaultActiveKey="model" items={[
{
key: 'model',
......@@ -308,7 +303,7 @@ const AdminAIConfigPage: React.FC = () => {
<Spin spinning={loading}>
<Row gutter={24}>
<Col span={16}>
<Card title="AI 模型配置" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title="AI 模型配置" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Alert message="此配置将全局生效,影响所有 AI 功能" type="info" showIcon style={{ marginBottom: 20 }} />
<Form form={modelForm} layout="vertical">
<Row gutter={16}>
......@@ -360,7 +355,7 @@ const AdminAIConfigPage: React.FC = () => {
</Card>
</Col>
<Col span={8}>
<Card title="使用统计" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title="使用统计" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Text type="secondary">今日调用次数</Text>
......@@ -387,7 +382,7 @@ const AdminAIConfigPage: React.FC = () => {
key: 'prompts',
label: <span><FileTextOutlined /> Prompt 模板管理</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">管理各场景的 AI Prompt 模板</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTemplate}>新建模板</Button>
......@@ -400,7 +395,7 @@ const AdminAIConfigPage: React.FC = () => {
key: 'safety',
label: <span><SafetyOutlined /> 安全词过滤</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Alert message="安全词过滤规则用于防止 AI 输出不安全或不合规的内容" type="info" showIcon style={{ marginBottom: 16 }} />
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
<ExclamationCircleOutlined style={{ fontSize: 48, marginBottom: 16 }} />
......
......@@ -7,7 +7,7 @@ import {
Badge, Collapse,
} from 'antd';
import {
FileTextOutlined, SearchOutlined, ToolOutlined, RobotOutlined,
SearchOutlined, ToolOutlined, RobotOutlined,
ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined,
HistoryOutlined, FundOutlined,
} from '@ant-design/icons';
......@@ -246,50 +246,42 @@ export default function AILogsPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<FileTextOutlined style={{ marginRight: 8, color: '#0D9488' }} />
AI 日志
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>智能体执行日志与 AI 调用追踪,默认查询当天数据</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 统计卡片 */}
<Row gutter={[16, 16]}>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="总调用" value={aiStats?.total_calls ?? 0} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="成功率" value={aiStats?.total_calls ? Math.round((aiStats.success_calls / aiStats.total_calls) * 100) : 0} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="总 Tokens" value={aiStats?.total_tokens ?? 0} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="Agent 执行" value={aiStats?.agent_execs ?? 0} prefix={<RobotOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="工具调用" value={aiStats?.tool_calls ?? 0} prefix={<ToolOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="模拟调用" value={aiStats?.mock_calls ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......
......@@ -210,30 +210,25 @@ const AdminCompliancePage: React.FC = () => {
const generatedCount = reports.filter((r) => r.status === 'generated').length;
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>合规报表</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>审计日志、数据导出与监管上报</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #2B6DE8' }}>
<Statistic title="审计日志" value={logsTotal} prefix={<FileSearchOutlined />} suffix="条" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #fa8c16' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #fa8c16' }}>
<Statistic title="待处理" value={pendingCount} valueStyle={{ color: '#fa8c16' }} suffix="项" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<Statistic title="已上报" value={submittedCount} suffix="项" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0891B2' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #0891B2' }}>
<Statistic title="已生成" value={generatedCount} suffix="项" />
</Card>
</Col>
......@@ -244,7 +239,7 @@ const AdminCompliancePage: React.FC = () => {
key: 'logs',
label: <span><AuditOutlined /> 审计日志</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input placeholder="搜索操作内容" style={{ width: 180, borderRadius: 8 }} allowClear
value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)}
......@@ -262,16 +257,16 @@ const AdminCompliancePage: React.FC = () => {
key: 'export',
label: <span><DownloadOutlined /> 数据导出</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Row gutter={[16, 16]}>
{([
{ type: 'consultations', title: '问诊数据', desc: '问诊记录、诊断、处方', icon: <FileSearchOutlined style={{ fontSize: 24, color: '#0D9488' }} /> },
{ type: 'consultations', title: '问诊数据', desc: '问诊记录、诊断、处方', icon: <FileSearchOutlined style={{ fontSize: 24, color: '#2B6DE8' }} /> },
{ type: 'users', title: '用户数据', desc: '患者和医生基本信息', icon: <UserOutlined style={{ fontSize: 24, color: '#52c41a' }} /> },
{ type: 'prescriptions', title: '处方数据', desc: '处方记录、用药统计', icon: <AuditOutlined style={{ fontSize: 24, color: '#0891B2' }} /> },
{ type: 'logs', title: '系统日志', desc: '操作日志和审计记录', icon: <SettingOutlined style={{ fontSize: 24, color: '#fa8c16' }} /> },
] as const).map((item) => (
<Col span={6} key={item.type}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', textAlign: 'center', cursor: 'pointer' }} hoverable
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', textAlign: 'center', cursor: 'pointer' }} hoverable
onClick={() => { setExportType(item.type); setExportModalVisible(true); }}>
<div style={{ marginBottom: 10 }}>{item.icon}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{item.title}</div>
......@@ -287,7 +282,7 @@ const AdminCompliancePage: React.FC = () => {
key: 'reports',
label: <span><CloudUploadOutlined /> 监管上报</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>管理合规报告,支持下载和上报</span>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateModalVisible(true)}>生成新报告</Button>
......
......@@ -111,7 +111,7 @@ const AdminConsultationsPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px' }}>
<div style={{ padding: '12px 16px' }}>
<ProTable<AdminConsultItem>
headerTitle="问诊管理"
tooltip="查看和管理平台所有问诊记录"
......
......@@ -26,6 +26,22 @@ interface TrendItem {
revenue: number;
}
const sceneLabels: Record<string, string> = {
patient_agent: '患者智能助手',
doctor_agent: '医生智能助手',
admin_agent: '管理员智能助手',
patient_universal_agent: '患者智能助手',
doctor_universal_agent: '医生智能助手',
admin_universal_agent: '管理员智能助手',
agent_actions: '操作按钮格式',
pre_consult_chat: '预问诊对话',
pre_consult_analysis: '预问诊报告',
consult_diagnosis: '鉴别诊断',
consult_medication: '用药建议',
prescription_review: '处方审核',
lab_report_interpret: '检验报告解读',
};
const AdminDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats>({
......@@ -104,41 +120,36 @@ const AdminDashboardPage: React.FC = () => {
};
// AI场景调用 TOP5 (水平柱状图)
const sceneCounts = (aiStats as Record<string, unknown>).scene_counts as Record<string, number> | undefined;
const sceneEntries = sceneCounts ? Object.entries(sceneCounts).sort((a, b) => b[1] - a[1]).slice(0, 5) : [];
// 后端返回 [{scene: "xxx", count: 5}, ...] 数组
const sceneCountsRaw = (aiStats as Record<string, unknown>).scene_counts as { scene: string; count: number }[] | undefined;
const sceneTop5 = (sceneCountsRaw || []).slice(0, 5);
const aiSceneOption = {
tooltip: { trigger: 'axis' as const },
xAxis: { type: 'value' as const, minInterval: 1 },
yAxis: { type: 'category' as const, data: sceneEntries.map(e => e[0]).reverse(), axisLabel: { fontSize: 11, width: 60, overflow: 'truncate' as const } },
yAxis: { type: 'category' as const, data: sceneTop5.map(e => sceneLabels[e.scene] || e.scene).reverse(), axisLabel: { fontSize: 11, width: 80, overflow: 'truncate' as const } },
grid: { left: 80, right: 20, top: 10, bottom: 20 },
series: [{ type: 'bar', data: sceneEntries.map(e => e[1]).reverse(), barWidth: 16, itemStyle: { borderRadius: [0, 4, 4, 0], color: CHART_COLORS[3] } }],
series: [{ type: 'bar', data: sceneTop5.map(e => e.count).reverse(), barWidth: 16, itemStyle: { borderRadius: [0, 4, 4, 0], color: CHART_COLORS[3] } }],
};
// AI 24h 分布柱状图
const hourlyData = (aiStats as Record<string, unknown>).hourly_distribution as number[] | undefined;
// 后端返回 [{hour: "09", count: 3}, ...] 数组
const hourlyTrend = (aiStats as Record<string, unknown>).hourly_trend as { hour: string; count: number }[] | undefined;
const hourlyDataMap = new Map((hourlyTrend || []).map(h => [parseInt(h.hour, 10), h.count]));
const hourlyData = Array.from({ length: 24 }, (_, i) => hourlyDataMap.get(i) || 0);
const ai24hOption = {
tooltip: { trigger: 'axis' as const },
xAxis: { type: 'category' as const, data: Array.from({ length: 24 }, (_, i) => `${i}:00`), axisLabel: { fontSize: 10, interval: 3 } },
yAxis: { type: 'value' as const, minInterval: 1 },
grid: { left: 40, right: 12, top: 10, bottom: 24 },
series: [{ type: 'bar', data: hourlyData || new Array(24).fill(0), barWidth: 10, itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_COLORS[4] } }],
series: [{ type: 'bar', data: hourlyData, barWidth: 10, itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_COLORS[4] } }],
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>运营大盘</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>平台实时运营数据概览</div>
</div>
<span style={{ fontSize: 12, color: '#bfbfbf' }}>数据实时更新</span>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Row1: 核心指标 */}
<Row gutter={[16, 16]}>
{[
{ title: '注册用户', value: stats.total_users, prefix: '', icon: <TeamOutlined style={{ fontSize: 18, color: '#0D9488' }} />, color: '#0D9488', bg: '#F0FDFA', extra: '' },
{ title: '注册用户', value: stats.total_users, prefix: '', icon: <TeamOutlined style={{ fontSize: 18, color: '#2B6DE8' }} />, color: '#2B6DE8', bg: '#F0F6FF', extra: '' },
{ title: '注册医生', value: stats.total_doctors, prefix: '', icon: <MedicineBoxOutlined style={{ fontSize: 18, color: '#52c41a' }} />, color: '#52c41a', bg: '#f6ffed', extra: '', suffix: <span style={{ fontSize: 12, color: '#bfbfbf', fontWeight: 400 }}>/ 在线 {stats.online_doctors}</span> },
{ title: '今日问诊', value: stats.today_consultations, prefix: '', icon: <MessageOutlined style={{ fontSize: 18, color: '#0891B2' }} />, color: '#0891B2', bg: '#ECFEFF', extra: '' },
{ title: '今日收入', value: (stats.revenue_today / 100), prefix: '¥', icon: <DollarOutlined style={{ fontSize: 18, color: '#fa8c16' }} />, color: '#fa8c16', bg: '#fff7e6', extra: '' },
......@@ -158,7 +169,7 @@ const AdminDashboardPage: React.FC = () => {
),
valueStyle: { fontSize: 24, fontWeight: 700, color: item.color },
}}
style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}
/>
</Col>
))}
......@@ -168,18 +179,18 @@ const AdminDashboardPage: React.FC = () => {
<EChartsCard title="问诊趋势(近7天)" option={trendOption} loading={loading} height={260} />
{/* Row3: 三栏图表 */}
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<EChartsCard title="问诊状态分布" option={doughnutOption} loading={loading} height={220} />
<Row gutter={[16, 16]} align="stretch">
<Col xs={24} md={8} style={{ display: 'flex' }}>
<EChartsCard title="问诊状态分布" option={doughnutOption} loading={loading} height={220} style={{ flex: 1 }} />
</Col>
<Col xs={24} md={8}>
<EChartsCard title="AI 场景调用 TOP5" option={aiSceneOption} loading={loading} height={220} />
<Col xs={24} md={8} style={{ display: 'flex' }}>
<EChartsCard title="AI 场景调用 TOP5" option={aiSceneOption} loading={loading} height={220} style={{ flex: 1 }} />
</Col>
<Col xs={24} md={8}>
<Col xs={24} md={8} style={{ display: 'flex' }}>
<ProCard
title={<span style={{ fontSize: 14, fontWeight: 600 }}>平台概况</span>}
headerBordered
style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', flex: 1 }}
bodyStyle={{ padding: '12px 16px' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
......@@ -195,7 +206,7 @@ const AdminDashboardPage: React.FC = () => {
<Text style={{ fontSize: 12 }}>累计问诊</Text>
<Text strong style={{ fontSize: 12 }}>{stats.total_consultations.toLocaleString()}</Text>
</div>
<Progress percent={Math.min(100, stats.total_consultations)} strokeColor="#0D9488" showInfo={false} size="small" />
<Progress percent={Math.min(100, stats.total_consultations)} strokeColor="#2B6DE8" showInfo={false} size="small" />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
......@@ -233,7 +244,7 @@ const AdminDashboardPage: React.FC = () => {
<ProCard
title={<span style={{ fontSize: 14, fontWeight: 600 }}>待审核医生 <Tag color="orange" style={{ marginLeft: 6 }}>{stats.pending_doctor_reviews}</Tag></span>}
headerBordered
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%' }}
>
<List
dataSource={recentReviews}
......@@ -261,11 +272,11 @@ const AdminDashboardPage: React.FC = () => {
{[
{ icon: <CheckCircleOutlined />, color: '#52c41a', bg: '#f6ffed', label: '累计问诊', value: stats.total_consultations.toLocaleString() },
{ icon: <RiseOutlined />, color: '#fa8c16', bg: '#fff7e6', label: '本月收入', value: `¥${(stats.revenue_month / 100).toLocaleString()}` },
{ icon: <ClockCircleOutlined />, color: '#0D9488', bg: '#F0FDFA', label: '平均响应', value: '3.2分钟' },
{ icon: <ClockCircleOutlined />, color: '#2B6DE8', bg: '#F0F6FF', label: '平均响应', value: '3.2分钟' },
{ icon: <AuditOutlined />, color: '#0891B2', bg: '#ECFEFF', label: '待审核', value: stats.pending_doctor_reviews },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div style={{ background: '#fff', borderRadius: 12, padding: '16px 20px', border: '1px solid #E0F2F1', textAlign: 'center' }}>
<div style={{ background: '#fff', borderRadius: 12, padding: '16px 20px', border: '1px solid #E5E8EF', textAlign: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: item.bg, color: item.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, margin: '0 auto 8px' }}>{item.icon}</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: item.color }}>{item.value}</div>
......
......@@ -113,12 +113,7 @@ const AdminDepartmentsPage: React.FC = () => {
);
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>科室管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理医院科室分类与排序</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ProTable<Department>
headerTitle="科室列表"
rowKey="id"
......
......@@ -334,11 +334,6 @@ const AdminDoctorsPage: React.FC = () => {
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>医生管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台注册医生,审核资质认证</div>
</div>
<ProTable<DoctorItem>
headerTitle="医生列表"
tooltip="管理平台注册医生,审核资质认证"
......
......@@ -84,7 +84,7 @@ export default function KnowledgePage() {
const colColumns = [
{
title: '集合名称', dataIndex: 'name', key: 'name',
render: (v: string) => <Space><BookOutlined style={{ color: '#0D9488' }} /><Text strong>{v}</Text></Space>,
render: (v: string) => <Space><BookOutlined style={{ color: '#2B6DE8' }} /><Text strong>{v}</Text></Space>,
},
{
title: '类别', dataIndex: 'category', key: 'category', width: 120,
......@@ -135,14 +135,9 @@ export default function KnowledgePage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>知识库管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理医学知识库集合和文档,支持语义向量检索</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 操作栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button icon={<ReloadOutlined />} onClick={() => { fetchCollections(); fetchDocuments(); }}>刷新</Button>
<Button icon={<SearchOutlined />} onClick={() => { setSearchResults([]); setSearchModal(true); }}>检索测试</Button>
......@@ -152,7 +147,7 @@ export default function KnowledgePage() {
</Card>
{/* 内容区 */}
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
items={[
{
......
'use client';
import React, { useState, useEffect, Suspense } from 'react';
import React, { useState, useEffect, useMemo, useCallback, Suspense } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
UserOutlined, SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
CloseOutlined, DownOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { getIconComponent } from '@/utils/iconMapper';
const { Sider, Content } = Layout;
const { Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营大盘' },
{
key: 'user-mgmt', icon: <TeamOutlined />, label: '用户管理',
children: [
{ key: '/admin/patients', icon: <UserOutlined />, label: '患者管理' },
{ key: '/admin/doctors', icon: <MedicineBoxOutlined />, label: '医生管理' },
{ key: '/admin/admins', icon: <SettingOutlined />, label: '管理员管理' },
],
},
{ key: '/admin/departments', icon: <ApartmentOutlined />, label: '科室管理' },
{ key: '/admin/consultations', icon: <FileSearchOutlined />, label: '问诊管理' },
{ key: '/admin/prescription', icon: <FileTextOutlined />, label: '处方监管' },
{ key: '/admin/pharmacy', icon: <MedicineBoxOutlined />, label: '药品库' },
{ key: '/admin/ai-config', icon: <RobotOutlined />, label: 'AI配置' },
{ key: '/admin/compliance', icon: <SafetyCertificateOutlined />, label: '合规报表' },
{
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: '智能体管理' },
{ key: '/admin/ai-logs', icon: <FundOutlined />, label: 'AI日志' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/admin/safety', icon: <SafetyOutlined />, label: '内容安全' },
],
},
{
key: 'system-mgmt', icon: <SafetyCertificateOutlined />, label: '系统管理',
children: [
{ key: '/admin/roles', icon: <SafetyOutlined />, label: '角色管理' },
{ key: '/admin/menus', icon: <AppstoreOutlined />, label: '菜单管理' },
],
},
];
function convertMenuTree(menus: MenuType[], isTop = true): any[] {
return menus.map(m => {
const hasChildren = m.children && m.children.length > 0;
const item: any = {
key: m.path || `menu-${m.id}`,
label: hasChildren && isTop ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{m.name}
<DownOutlined style={{ fontSize: 10, opacity: 0.45 }} />
</span>
) : m.name,
icon: getIconComponent(m.icon),
};
if (hasChildren) {
item.children = convertMenuTree(m.children, false);
}
return item;
});
}
function collectLeafPaths(menus: MenuType[]): string[] {
const paths: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
paths.push(...collectLeafPaths(m.children));
} else if (m.path) {
paths.push(m.path);
}
}
return paths;
}
function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
const keys: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
const childPaths = collectLeafPaths(m.children);
if (childPaths.some(p => targetPath.startsWith(p))) {
keys.push(m.path || `menu-${m.id}`);
}
keys.push(...findOpenKeys(m.children, targetPath));
}
}
return keys;
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#F8FAFB' }} />}>
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#F5F7FA' }} />}>
<AdminLayoutInner>{children}</AdminLayoutInner>
</Suspense>
);
}
function findMenuName(menus: MenuType[], path: string): string {
for (const m of menus) {
if (m.path === path) return m.name;
if (m.children) {
const found = findMenuName(m.children, path);
if (found) return found;
}
}
return '';
}
function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const { user, logout, menus, setMenus } = useUserStore();
const currentPath = pathname || '';
const [tabs, setTabs] = useState<Array<{ key: string; label: string }>>([]);
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 每次加载时拉取菜单
useEffect(() => {
if (!user) return;
myMenuApi.getMenus().then(res => {
if (res.data) setMenus(res.data);
}).catch(() => {});
}, [user, setMenus]);
// 路径变化时添加 Tab
useEffect(() => {
if (!currentPath || !menus.length) return;
const name = findMenuName(menus, currentPath);
if (!name) return;
setTabs(prev => {
if (prev.some(t => t.key === currentPath)) return prev;
return [...prev, { key: currentPath, label: name }];
});
}, [currentPath, menus]);
// 监听 AI 助手导航事件
useEffect(() => {
......@@ -89,8 +130,18 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
];
const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
if (key.startsWith('/')) router.push(key);
};
const handleTabChange = (key: string) => {
router.push(key);
};
const handleTabRemove = (key: string) => {
const newTabs = tabs.filter(t => t.key !== key);
setTabs(newTabs);
if (key === currentPath && newTabs.length > 0) {
router.push(newTabs[newTabs.length - 1].key);
}
};
......@@ -98,103 +149,49 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
if (key === 'logout') { logout(); router.push('/login'); }
};
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', '/admin/agents', '/admin/ai-logs',
'/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety',
'/admin/roles', '/admin/menus'];
const match = allKeys.find(k => currentPath.startsWith(k));
const getSelectedKeys = useCallback(() => {
const allPaths = collectLeafPaths(menus);
const match = allPaths.find(k => currentPath.startsWith(k));
return match ? [match] : [];
};
const getOpenKeys = () => {
if (collapsed) return [];
const keys: string[] = [];
if (['/admin/patients', '/admin/doctors', '/admin/admins'].some(k => currentPath.startsWith(k))) {
keys.push('user-mgmt');
}
if (['/admin/agents', '/admin/ai-logs',
'/admin/workflows', '/admin/tasks', '/admin/knowledge', '/admin/safety']
.some(k => currentPath.startsWith(k))) {
keys.push('ai-platform');
}
if (['/admin/roles', '/admin/menus'].some(k => currentPath.startsWith(k))) {
keys.push('system-mgmt');
}
return keys;
};
// 移除embed模式处理,智能助手导航现在直接跳转页面,保留完整菜单
}, [menus, currentPath]);
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
collapsedWidth={64}
trigger={null}
{/* Top bar with logo, menu, and user info */}
<header
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #E5E8EF',
position: 'sticky', top: 0, zIndex: 100,
}}
>
{/* Logo */}
<div
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/admin/dashboard')}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', marginRight: 24 }}
onClick={() => router.push(useUserStore.getState().homePath || '/admin/dashboard')}
>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #2B6DE8, #1A3B7A)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="cyan" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理</Tag>
</>
)}
<Tag style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px', background: '#e8f4fd', color: '#2B6DE8', border: 'none', borderRadius: 4 }}>管理</Tag>
</div>
{/* Menu */}
{/* Horizontal Menu */}
<Menu
mode="inline"
mode="horizontal"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
style={{ flex: 1, border: 'none', background: 'transparent', lineHeight: '54px' }}
/>
</Sider>
<Layout style={{ marginLeft: collapsed ? 64 : 220, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
{/* Right: Notification + User */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={2} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
......@@ -202,18 +199,45 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text>
<Avatar size={30} icon={<UserOutlined />} style={{ backgroundColor: '#2B6DE8' }} />
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>{user.real_name || '管理员'}</Text>
</Space>
</Dropdown>
) : null}
</div>
</header>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#F8FAFB' }}>
{/* Tabs */}
{tabs.length > 0 && (
<div style={{ background: '#fff', borderBottom: '1px solid #E5E8EF', padding: '0 24px', display: 'flex', alignItems: 'center', gap: 4, height: 36, position: 'sticky', top: 56, zIndex: 99 }}>
{tabs.map(t => (
<div
key={t.key}
onClick={() => handleTabChange(t.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 12px', fontSize: 13, cursor: 'pointer', borderRadius: 6,
color: t.key === currentPath ? '#2B6DE8' : '#595959',
background: t.key === currentPath ? '#F0F6FF' : 'transparent',
fontWeight: t.key === currentPath ? 600 : 400,
transition: 'all 0.2s',
}}
>
{t.label}
{tabs.length > 1 && (
<CloseOutlined
style={{ fontSize: 10, opacity: 0.4 }}
onClick={e => { e.stopPropagation(); handleTabRemove(t.key); }}
/>
)}
</div>
))}
</div>
)}
<Content style={{ background: '#F5F7FA' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
......@@ -3,13 +3,11 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Card, Button, Modal, Tree, Tag, Space, Spin, App,
Dropdown,
} from 'antd';
import {
AppstoreOutlined, ReloadOutlined,
SaveOutlined, CheckSquareOutlined, MinusSquareOutlined,
PlusOutlined, EditOutlined, DeleteOutlined,
EyeOutlined, EyeInvisibleOutlined, MoreOutlined,
EyeOutlined, EyeInvisibleOutlined,
} from '@ant-design/icons';
import {
DrawerForm, ProFormText, ProFormDigit, ProFormSelect, ProFormSwitch,
......@@ -17,6 +15,7 @@ import {
} from '@ant-design/pro-components';
import { menuApi, roleApi } from '@/api/rbac';
import type { Menu, Role } from '@/api/rbac';
import { AVAILABLE_ICONS, getIconComponent } from '@/utils/iconMapper';
type TreeDataNode = {
key: number;
......@@ -42,31 +41,17 @@ function menusToTreeData(
key: m.id,
title: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', paddingRight: 8 }}>
<span style={{ flex: 1 }}>
<span style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8 }}>
{m.icon && <span style={{ fontSize: 14 }}>{getIconComponent(m.icon)}</span>}
{m.name}
{m.path && <span style={{ color: '#999', fontSize: 12, marginLeft: 8 }}>{m.path}</span>}
{m.type && <Tag color={m.type === 'directory' ? 'blue' : m.type === 'button' ? 'orange' : 'green'} style={{ marginLeft: 6, fontSize: 11 }}>{m.type === 'directory' ? '目录' : m.type === 'button' ? '按钮' : '菜单'}</Tag>}
{!m.visible && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}><EyeInvisibleOutlined /> 隐藏</Tag>}
{m.path && <span style={{ color: '#999', fontSize: 12 }}>{m.path}</span>}
{m.type && <Tag color={m.type === 'directory' ? 'blue' : m.type === 'button' ? 'orange' : 'green'} style={{ fontSize: 11 }}>{m.type === 'directory' ? '目录' : m.type === 'button' ? '按钮' : '菜单'}</Tag>}
{!m.visible && <Tag color="default" style={{ fontSize: 11 }}><EyeInvisibleOutlined /> 隐藏</Tag>}
</span>
<Space size={4} style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{
items: [
{ key: 'addChild', icon: <PlusOutlined />, label: '添加子菜单' },
{ key: 'edit', icon: <EditOutlined />, label: '编辑' },
{ type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除', danger: true },
],
onClick: ({ key }) => {
if (key === 'edit') onEdit(m);
else if (key === 'delete') onDelete(m);
else if (key === 'addChild') onAddChild(m);
},
}}
trigger={['click']}
>
<Button type="text" size="small" icon={<MoreOutlined />} style={{ opacity: 0.6 }} />
</Dropdown>
<Button type="text" size="small" icon={<PlusOutlined />} onClick={() => onAddChild(m)} style={{ color: '#2B6DE8' }}>子菜单</Button>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(m)}>编辑</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} onClick={() => onDelete(m)} danger>删除</Button>
</Space>
</div>
),
......@@ -74,12 +59,37 @@ function menusToTreeData(
}));
}
// 将菜单树转为 TreeSelect 的 treeData
function menusToSelectData(menus: Menu[]): any[] {
return menus.map((m) => ({
// 收集某个菜单及其所有后代的 ID
function collectDescendantIds(menus: Menu[], id: number): Set<number> {
const ids = new Set<number>();
const addAllChildren = (children: Menu[]) => {
for (const c of children) {
ids.add(c.id);
if (c.children?.length) addAllChildren(c.children);
}
};
const find = (items: Menu[]) => {
for (const m of items) {
if (m.id === id) {
ids.add(m.id);
if (m.children?.length) addAllChildren(m.children);
return;
}
if (m.children?.length) find(m.children);
}
};
find(menus);
return ids;
}
// 将菜单树转为 TreeSelect 的 treeData,可排除指定 ID 集合
function menusToSelectData(menus: Menu[], excludeIds?: Set<number>): any[] {
return menus
.filter(m => !excludeIds || !excludeIds.has(m.id))
.map((m) => ({
value: m.id,
title: m.name,
children: m.children?.length ? menusToSelectData(m.children) : undefined,
children: m.children?.length ? menusToSelectData(m.children, excludeIds) : undefined,
}));
}
......@@ -124,7 +134,13 @@ export default function MenusPage() {
const [defaultParentId, setDefaultParentId] = useState<number | undefined>(undefined);
const allMenuKeys = useMemo(() => collectAllKeys(menus), [menus]);
const parentTreeData = useMemo(() => menusToSelectData(menus), [menus]);
const topOption = { value: 0, title: '顶级菜单(一级)' };
const parentTreeData = useMemo(() => [topOption, ...menusToSelectData(menus)], [menus]);
const editParentTreeData = useMemo(() => {
if (!editingMenu) return parentTreeData;
const excludeIds = collectDescendantIds(menus, editingMenu.id);
return [topOption, ...menusToSelectData(menus, excludeIds)];
}, [menus, editingMenu, parentTreeData]);
const handleEdit = useCallback((m: Menu) => {
setEditingMenu(m);
......@@ -233,8 +249,8 @@ export default function MenusPage() {
const selectedRole = roles.find((r) => r.id === selectedRoleId);
// 表单字段
const menuFormFields = (
// 表单字段(接受 treeData 参数区分新增/编辑场景)
const renderFormFields = (treeData: any[]) => (
<>
<ProFormText
name="name"
......@@ -255,7 +271,7 @@ export default function MenusPage() {
placeholder="留空表示顶级菜单"
allowClear
fieldProps={{
treeData: parentTreeData,
treeData,
treeDefaultExpandAll: true,
}}
/>
......@@ -264,10 +280,23 @@ export default function MenusPage() {
label="路由路径"
placeholder="如 /admin/menus"
/>
<ProFormText
<ProFormSelect
name="icon"
label="图标"
placeholder="Ant Design 图标名称"
placeholder="请选择图标"
showSearch
options={AVAILABLE_ICONS.map(icon => ({
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{getIconComponent(icon)}
<span>{icon}</span>
</span>
),
value: icon,
}))}
fieldProps={{
optionLabelProp: 'value',
}}
/>
<ProFormText
name="component"
......@@ -295,32 +324,10 @@ export default function MenusPage() {
);
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<AppstoreOutlined style={{ marginRight: 8, color: '#1890ff' }} />菜单管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
管理系统菜单结构,为角色分配菜单权限
</div>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchInitial}>刷新</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setDefaultParentId(undefined); setAddVisible(true); }}
>
添加菜单
</Button>
</Space>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Main content */}
<Spin spinning={loading}>
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
<div style={{ display: 'flex', gap: 16 }}>
{/* Left: Role list */}
<Card
title="角色列表"
......@@ -358,6 +365,7 @@ export default function MenusPage() {
<Space>
<Button size="small" icon={<CheckSquareOutlined />} onClick={handleSelectAll}>全选</Button>
<Button size="small" icon={<MinusSquareOutlined />} onClick={handleDeselectAll}>全不选</Button>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setDefaultParentId(undefined); setAddVisible(true); }}>添加菜单</Button>
</Space>
)
}
......@@ -365,8 +373,9 @@ export default function MenusPage() {
<Spin spinning={menuLoading}>
{treeData.length > 0 && selectedRoleId ? (
<>
<div style={{ maxHeight: 'calc(100vh - 300px)', overflowY: 'auto' }}>
<div style={{ maxHeight: 'calc(100vh - 250px)', overflowY: 'auto' }}>
<Tree
className="menu-manage-tree"
checkable
checkStrictly
defaultExpandAll
......@@ -430,7 +439,7 @@ export default function MenusPage() {
}
}}
>
{menuFormFields}
{renderFormFields(parentTreeData)}
</DrawerForm>
{/* Edit Menu Drawer */}
......@@ -470,7 +479,7 @@ export default function MenusPage() {
}
}}
>
{menuFormFields}
{renderFormFields(editParentTreeData)}
</DrawerForm>
</div>
);
......
......@@ -146,30 +146,25 @@ const AdminPharmacyPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>药品库管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台药品信息与库存状态</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #2B6DE8' }}>
<Statistic title="药品总数" value={stats.total_count} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<Statistic title="库存充足" value={stats.normal_count} valueStyle={{ color: '#52c41a' }} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #fa8c16' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #fa8c16' }}>
<Statistic title="库存预警" value={stats.low_stock_count} valueStyle={{ color: '#fa8c16' }} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #f5222d' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #f5222d' }}>
<Statistic title="缺货药品" value={stats.out_of_stock_count} valueStyle={{ color: '#f5222d' }} suffix="种" />
</Card>
</Col>
......@@ -185,7 +180,7 @@ const AdminPharmacyPage: React.FC = () => {
key: 'drugs',
label: <span><MedicineBoxOutlined /> 药品信息</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<Input prefix={<SearchOutlined />} placeholder="搜索药品" style={{ width: 200, borderRadius: 8 }} allowClear
......@@ -207,7 +202,7 @@ const AdminPharmacyPage: React.FC = () => {
key: 'stock',
label: <Badge count={stats.low_stock_count + stats.out_of_stock_count} offset={[10, 0]}>库存预警</Badge>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} columns={drugColumns}
dataSource={drugs.filter((d) => d.status !== 'normal')}
rowKey="id" pagination={false} loading={loading} size="small" />
......
......@@ -126,30 +126,25 @@ const AdminPrescriptionPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>处方监管</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>监督平台处方合规性,审查异常用药</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #2B6DE8' }}>
<Statistic title="处方总数" value={stats.total_count} suffix="张" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<Statistic title="合规" value={stats.normal_count} valueStyle={{ color: '#52c41a' }} suffix="张" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #fa8c16' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #fa8c16' }}>
<Statistic title="预警" value={stats.warning_count} valueStyle={{ color: '#fa8c16' }} suffix="张" />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #f5222d' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #f5222d' }}>
<Statistic title="异常" value={stats.rejected_count} valueStyle={{ color: '#f5222d' }} suffix="张" />
</Card>
</Col>
......@@ -159,7 +154,7 @@ const AdminPrescriptionPage: React.FC = () => {
{
key: 'all', label: '全部处方',
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input prefix={<SearchOutlined />} placeholder="搜索处方/患者/医生" style={{ width: 200, borderRadius: 8 }} allowClear
value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={() => { setPage(1); fetchList(); }} />
......@@ -175,7 +170,7 @@ const AdminPrescriptionPage: React.FC = () => {
key: 'warning',
label: <Badge count={stats.warning_count + stats.rejected_count} offset={[10, 0]}>异常预警</Badge>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Alert message="以下处方存在合规风险,请及时审查处理" type="warning" showIcon style={{ marginBottom: 16 }} />
<List dataSource={prescriptions.filter((d) => d.warning_level !== 'normal')} renderItem={(item) => (
<Card style={{ marginBottom: 12, borderRadius: 8, borderLeft: `4px solid ${item.warning_level === 'rejected' ? '#f5222d' : '#fa8c16'}` }} hoverable>
......
......@@ -63,14 +63,8 @@ export default function RolesPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<SafetyOutlined style={{ marginRight: 8, color: '#722ed1' }} />角色管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>系统角色一览(权限以 User.Role 为唯一依据)</div>
</div>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {roles.length} 个角色</span>
</div>
......
......@@ -8,7 +8,7 @@ import {
import { ProTable, DrawerForm, ProFormText, ProFormSelect, ProFormSwitch } from '@ant-design/pro-components';
import {
PlusOutlined, DeleteOutlined, EditOutlined, ImportOutlined,
SafetyOutlined, FilterOutlined,
FilterOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
......@@ -219,24 +219,16 @@ export default function SafetyPage() {
] as ColumnsType<SafetyLog>;
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<SafetyOutlined style={{ marginRight: 8, color: '#0D9488' }} />
内容安全管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>配置安全词过滤规则,保障AI输入/输出内容合规</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}><Statistic title="活跃规则" value={stats?.active_rules ?? 0} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}><Statistic title="过滤总数" value={stats?.total_logs ?? 0} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}><Statistic title="拦截次数" value={stats?.blocked ?? 0} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}><Statistic title="替换次数" value={stats?.replaced ?? 0} valueStyle={{ color: '#0D9488' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}><Statistic title="警告次数" value={stats?.warned ?? 0} valueStyle={{ color: '#fa8c16' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}><Statistic title="活跃规则" value={stats?.active_rules ?? 0} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}><Statistic title="过滤总数" value={stats?.total_logs ?? 0} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}><Statistic title="拦截次数" value={stats?.blocked ?? 0} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}><Statistic title="替换次数" value={stats?.replaced ?? 0} valueStyle={{ color: '#2B6DE8' }} /></Card></Col>
<Col span={4}><Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}><Statistic title="警告次数" value={stats?.warned ?? 0} valueStyle={{ color: '#fa8c16' }} /></Card></Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......
......@@ -6,7 +6,7 @@ import {
message, Row, Col, Statistic, Empty,
} from 'antd';
import {
CheckOutlined, CloseOutlined, EyeOutlined, ClockCircleOutlined,
CheckOutlined, CloseOutlined, EyeOutlined,
FilterOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
......@@ -163,39 +163,31 @@ export default function AdminTasksPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#0D9488' }} />
人工审核任务
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>工作流中需要人工处理的审核任务</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="待处理" value={pendingCount} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="已通过" value={approvedCount} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="已拒绝" value={rejectedCount} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Statistic title="总任务" value={tasks.length} />
</Card>
</Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Space style={{ marginBottom: 16 }}>
<FilterOutlined />
<Select
......
......@@ -60,7 +60,7 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
const columns = [
{
title: '工具名称', dataIndex: 'name', key: 'name',
render: (v: string) => <Space><CodeOutlined style={{ color: '#0D9488' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
render: (v: string) => <Space><CodeOutlined style={{ color: '#2B6DE8' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
......@@ -100,13 +100,13 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
return (
<>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} dataSource={filtered} columns={columns} rowKey="name" loading={isLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: t => `共 ${t} 个工具` }} />
</Card>
<Drawer
title={<Space><CodeOutlined style={{ color: '#0D9488' }} /><span>工具详情</span></Space>}
title={<Space><CodeOutlined style={{ color: '#2B6DE8' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onClose={() => setDetailModal({ open: false, tool: null })}
placement="right"
......@@ -126,7 +126,7 @@ export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFI
{detailModal.tool && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工具名称"><Text code style={{ color: '#0D9488' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="工具名称"><Text code style={{ color: '#2B6DE8' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="描述">{detailModal.tool.description}</Descriptions.Item>
<Descriptions.Item label="分类"><Tag color={(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).color}>{(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).label}</Tag></Descriptions.Item>
<Descriptions.Item label="状态"><Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} /></Descriptions.Item>
......
......@@ -163,14 +163,14 @@ export default function HTTPToolsTab({ search }: Props) {
</Space>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} dataSource={tools} columns={columns} rowKey="id" loading={isLoading} size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }} />
</Card>
{/* Create/Edit DrawerForm */}
<DrawerForm
title={<Space><ApiOutlined style={{ color: '#0D9488' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
title={<Space><ApiOutlined style={{ color: '#2B6DE8' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen}
onOpenChange={(open) => { setModalOpen(open); if (!open) form.resetFields(); }}
onFinish={async () => { handleSave(); return false; }}
......
......@@ -122,7 +122,7 @@ export default function SkillsTab({ search }: Props) {
<Col key={skill.skill_id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{ borderRadius: 12, border: '1px solid #E0F2F1', transition: 'all 0.2s' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', transition: 'all 0.2s' }}
styles={{ body: { padding: 16 } }}
hoverable
>
......
......@@ -35,7 +35,7 @@ const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: Reac
export type CATEGORY_CONFIG_TYPE = typeof CATEGORY_CONFIG;
export const SOURCE_CONFIG = {
builtin: { label: '内置', color: '#0D9488' },
builtin: { label: '内置', color: '#2B6DE8' },
http: { label: 'HTTP', color: '#fa8c16' },
} as const;
......@@ -93,19 +93,10 @@ export default function UnifiedToolsPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工具中心</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
管理所有 Agent 工具与技能包 — 共 <Text strong>{totalCount}</Text> 个工具,
已启用 <Text strong style={{ color: '#52c41a' }}>{enabledCount}</Text> 个
</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Filter bar */}
<div style={{
padding: '10px 16px', borderRadius: 12, border: '1px solid #E0F2F1', background: '#fff',
padding: '10px 16px', borderRadius: 12, border: '1px solid #E5E8EF', background: '#fff',
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<Input
......
......@@ -211,14 +211,9 @@ export default function WorkflowsPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工作流管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>设计和管理 AI 工作流,实现复杂业务流程自动化</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 操作栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DeploymentUnitOutlined style={{ color: '#8c8c8c' }} />
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {workflows.length} 个工作流</span>
......@@ -229,7 +224,7 @@ export default function WorkflowsPage() {
</Card>
{/* 工作流列表 */}
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} dataSource={workflows} columns={columns} rowKey="id" loading={tableLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: (t) => `共 ${t} 条` }}
/>
......
......@@ -155,17 +155,10 @@ const AIAssistPage: React.FC = () => {
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<RobotOutlined style={{ marginRight: 6 }} />AI 辅助诊断
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>AI分析仅供参考,最终诊断以医生临床判断为准</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Alert message="AI 辅助诊断仅供参考,最终诊断以医生临床判断为准" type="info" showIcon style={{ fontSize: 12 }} />
<Card size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div className="text-xs font-semibold mb-1">症状描述</div>
<TextArea rows={3} placeholder="请输入患者的主诉和症状描述.." value={symptoms}
onChange={(e) => setSymptoms(e.target.value)} className="mb-2" size="small" />
......
......@@ -103,8 +103,8 @@ const DoctorCertificationPage: React.FC = () => {
if (currentStep === 1) {
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ maxWidth: 480, margin: '60px auto', textAlign: 'center', borderRadius: 12, border: '1px solid #E0F2F1', padding: '40px 20px' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ maxWidth: 480, margin: '60px auto', textAlign: 'center', borderRadius: 12, border: '1px solid #E5E8EF', padding: '40px 20px' }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#f6ffed', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px', fontSize: 32, color: '#52c41a' }}>
<CheckCircleOutlined />
</div>
......@@ -119,12 +119,7 @@ const DoctorCertificationPage: React.FC = () => {
}
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>医生资质认证</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>提交执业资质,通过审核后即可开始接诊</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Steps
current={currentStep}
items={[
......@@ -135,7 +130,7 @@ const DoctorCertificationPage: React.FC = () => {
style={{ maxWidth: 600 }}
/>
<Card style={{ maxWidth: 680, borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ maxWidth: 680, borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="license_no" label="执业证号" rules={[{ required: true, message: '请输入执业证书号' }]}>
<Input placeholder="请输入医师执业证书号" />
......
......@@ -140,13 +140,8 @@ const ChronicReviewPage: React.FC = () => {
return (
<Spin spinning={loading}>
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>慢病续方审核</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>审核患者的慢病续方申请,AI 已预生成处方草稿</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="pending" items={[
{
key: 'pending',
......
......@@ -274,10 +274,10 @@ const AIPanel: React.FC<AIPanelProps> = ({
key={i}
style={{
fontSize: 12, marginTop: 6, paddingLeft: 8,
borderLeft: msg.role === 'user' ? '2px solid #0D9488' : '2px solid #52c41a',
borderLeft: msg.role === 'user' ? '2px solid #2B9E6E' : '2px solid #52c41a',
}}
>
<Text strong style={{ fontSize: 12, color: msg.role === 'user' ? '#0D9488' : '#52c41a' }}>
<Text strong style={{ fontSize: 12, color: msg.role === 'user' ? '#2B9E6E' : '#52c41a' }}>
{msg.role === 'user' ? '患者' : 'AI助手'}
</Text>
<div style={{ marginTop: 2, color: '#333', lineHeight: 1.5 }}>{msg.content}</div>
......@@ -357,7 +357,7 @@ const AIPanel: React.FC<AIPanelProps> = ({
<div style={{
height: '100%',
borderRadius: 12,
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
background: '#fff',
display: 'flex',
flexDirection: 'column',
......
......@@ -242,20 +242,20 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
padding: '8px 12px', background: '#f5f5f5', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<FileTextOutlined style={{ fontSize: 20, color: '#0D9488' }} />
<FileTextOutlined style={{ fontSize: 20, color: '#2B9E6E' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>文件</div>
<div style={{ fontSize: 11, color: '#999' }}>点击下载</div>
</div>
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<DownloadOutlined style={{ fontSize: 16, color: '#0D9488' }} />
<DownloadOutlined style={{ fontSize: 16, color: '#2B9E6E' }} />
</a>
</div>
);
case 'audio':
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlayCircleOutlined style={{ fontSize: 20, color: '#0D9488' }} />
<PlayCircleOutlined style={{ fontSize: 20, color: '#2B9E6E' }} />
<audio controls src={mediaUrl} style={{ height: 32 }} />
</div>
);
......@@ -357,7 +357,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
color: '#374151', background: '#f9fafb', border: '1px solid #f0f0f0',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#F0FDFA'; }}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#F0FFF5'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#f9fafb'; }}
>
{tpl.content}
......@@ -379,7 +379,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
display: 'flex',
flexDirection: 'column',
borderRadius: 12,
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
background: '#fff',
overflow: 'hidden',
}}>
......@@ -415,7 +415,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
<span style={{ color: '#0D9488', fontFamily: 'monospace', marginRight: 4 }}>{activeConsult.serial_number}</span>
<span style={{ color: '#2B9E6E', fontFamily: 'monospace', marginRight: 4 }}>{activeConsult.serial_number}</span>
{activeConsult.patient_gender && `${activeConsult.patient_gender} · `}
{activeConsult.patient_age && `${activeConsult.patient_age}岁 · `}
{activeConsult.chief_complaint
......@@ -525,7 +525,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
<>
{/* 对方正在输入提示 */}
{typingUser && (
<div style={{ padding: '4px 12px', fontSize: 11, color: '#0D9488', background: '#F0FDFA' }}>
<div style={{ padding: '4px 12px', fontSize: 11, color: '#2B9E6E', background: '#F0FFF5' }}>
{typingUser}正在输入...
</div>
)}
......@@ -571,7 +571,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
beforeUpload={file => { handleUpload(file); return false; }}
>
<Tooltip title="发送图片">
<Button type="text" size="small" icon={<PictureOutlined />} style={{ color: '#0D9488', fontSize: 13 }} />
<Button type="text" size="small" icon={<PictureOutlined />} style={{ color: '#2B9E6E', fontSize: 13 }} />
</Tooltip>
</Upload>
<Upload
......
......@@ -56,8 +56,8 @@ const PatientCard: React.FC<{
onClick={() => !isWaiting && onSelect()}
style={{
padding: '10px 12px',
background: isSelected ? '#F0FDFA' : '#fff',
borderLeft: `3px solid ${isSelected ? '#0D9488' : riskColor}`,
background: isSelected ? '#F0FFF5' : '#fff',
borderLeft: `3px solid ${isSelected ? '#2B9E6E' : riskColor}`,
borderBottom: '1px solid #f5f5f5',
cursor: isWaiting ? 'default' : 'pointer',
transition: 'background 0.15s',
......@@ -93,7 +93,7 @@ const PatientCard: React.FC<{
</Text>
{patient.is_revisit && (
<Tooltip title={`复诊 · 第${patient.visit_count + 1}次就诊`}>
<ThunderboltOutlined style={{ fontSize: 11, color: '#0D9488' }} />
<ThunderboltOutlined style={{ fontSize: 11, color: '#2B9E6E' }} />
</Tooltip>
)}
</div>
......@@ -102,7 +102,7 @@ const PatientCard: React.FC<{
: <MessageOutlined style={{ fontSize: 12, color: '#52c41a' }} />
}
</div>
<div style={{ fontSize: 10, color: '#0D9488', fontFamily: 'monospace', marginBottom: 2 }}>
<div style={{ fontSize: 10, color: '#2B9E6E', fontFamily: 'monospace', marginBottom: 2 }}>
{patient.serial_number}
</div>
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>
......@@ -253,7 +253,7 @@ const PatientList: React.FC<PatientListProps> = ({
<div style={{
height: '100%',
borderRadius: 12,
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
background: '#fff',
display: 'flex',
flexDirection: 'column',
......
......@@ -161,7 +161,7 @@ const PatientProfileDrawer: React.FC<PatientProfileDrawerProps> = ({ open, patie
const statusInfo = controlStatusMap[cr.control_status] || { text: cr.control_status, color: 'default' };
return (
<div key={cr.id} style={{
padding: '10px 12px', border: '1px solid #E0F2F1', borderRadius: 8, background: '#fafafa',
padding: '10px 12px', border: '1px solid #E5E8EF', borderRadius: 8, background: '#fafafa',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text strong style={{ fontSize: 13 }}>{cr.disease_name}</Text>
......@@ -244,7 +244,7 @@ const PatientProfileDrawer: React.FC<PatientProfileDrawerProps> = ({ open, patie
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{profile.family_history.map((fm, i) => (
<div key={i} style={{
padding: '8px 12px', border: '1px solid #E0F2F1', borderRadius: 8, background: '#fafafa',
padding: '8px 12px', border: '1px solid #E5E8EF', borderRadius: 8, background: '#fafafa',
}}>
<Text strong style={{ fontSize: 12 }}>{fm.name}</Text>
<Tag style={{ marginLeft: 6, fontSize: 10 }}>{fm.relation}</Tag>
......@@ -263,7 +263,7 @@ const PatientProfileDrawer: React.FC<PatientProfileDrawerProps> = ({ open, patie
<Drawer
title={
<Space>
<UserOutlined style={{ color: '#0D9488' }} />
<UserOutlined style={{ color: '#2B9E6E' }} />
<span>患者画像</span>
{profile?.basic_info?.name && (
<Tag color="blue">{profile.basic_info.name}</Tag>
......
......@@ -91,7 +91,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
>
<div style={{ marginBottom: 8 }}>
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#2B9E6E' }} />
<Text strong>{consult.patient_name || '患者'}</Text>
<Tag color={consult.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{consult.type === 'video' ? '视频' : '图文'}
......@@ -186,7 +186,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
return (
<Card
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%', overflow: 'auto' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%', overflow: 'auto' }}
styles={{ body: { padding: 12 } }}
size="small"
>
......
......@@ -274,13 +274,13 @@ const ConsultPage: React.FC = () => {
const statItems = [
{ label: '候诊中', value: waitingCount, color: '#fa8c16', icon: <ClockCircleOutlined />, unit: '人' },
{ label: '接诊中', value: inProgressCount, color: '#52c41a', icon: <MessageOutlined />, unit: '人' },
{ label: '今日完成', value: completedCount, color: '#0D9488', icon: <CheckCircleOutlined />, unit: '人' },
{ label: '今日完成', value: completedCount, color: '#2B9E6E', icon: <CheckCircleOutlined />, unit: '人' },
{ label: '今日收入', value: workbenchStats ? (workbenchStats.income_today / 100).toFixed(0) : '—', color: '#0891B2', icon: <DashboardOutlined />, unit: '元' },
];
const efficiencyItems = workbenchStats ? [
{ label: '平均候诊', value: workbenchStats.avg_wait_minutes.toFixed(1), unit: '分钟', icon: <FieldTimeOutlined />, color: '#fa8c16' },
{ label: '平均问诊', value: workbenchStats.avg_consult_minutes.toFixed(1), unit: '分钟', icon: <ClockCircleOutlined />, color: '#0D9488' },
{ label: '平均问诊', value: workbenchStats.avg_consult_minutes.toFixed(1), unit: '分钟', icon: <ClockCircleOutlined />, color: '#2B9E6E' },
{ label: '处方率', value: workbenchStats.prescription_rate.toFixed(0), unit: '%', icon: <MedicineBoxOutlined />, color: '#52c41a' },
{ label: '满意度', value: workbenchStats.avg_satisfaction > 0 ? workbenchStats.avg_satisfaction.toFixed(1) : '—', unit: workbenchStats.avg_satisfaction > 0 ? '分' : '', icon: <StarOutlined />, color: '#faad14' },
{ label: '今日转诊', value: workbenchStats.transfer_count, unit: '次', icon: <SwapOutlined />, color: '#13c2c2' },
......@@ -288,17 +288,10 @@ const ConsultPage: React.FC = () => {
] : [];
return (
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 16, padding: '20px 24px' }}>
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 16px' }}>
{/* 顶部标题 + 统计卡片 */}
<div style={{ flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageOutlined style={{ color: '#0D9488' }} />
问诊大厅
</div>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>实时接诊管理,AI辅助诊断</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
{statItems.map(({ label, value, color, icon, unit }) => (
<div
......@@ -307,7 +300,7 @@ const ConsultPage: React.FC = () => {
background: '#fff',
borderRadius: 10,
padding: '8px 18px',
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
minWidth: 100,
boxShadow: '0 1px 4px rgba(0,0,0,0.04)',
}}
......@@ -326,10 +319,10 @@ const ConsultPage: React.FC = () => {
onClick={() => setShowEfficiency(!showEfficiency)}
style={{
cursor: 'pointer', padding: '6px 10px', borderRadius: 8,
background: showEfficiency ? '#F0FDFA' : '#f5f5f5',
background: showEfficiency ? '#F0FFF5' : '#f5f5f5',
border: `1px solid ${showEfficiency ? '#91d5ff' : '#e8e8e8'}`,
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 12, color: showEfficiency ? '#0D9488' : '#8c8c8c',
fontSize: 12, color: showEfficiency ? '#2B9E6E' : '#8c8c8c',
transition: 'all 0.2s',
}}
>
......
......@@ -107,17 +107,10 @@ const IncomePage: React.FC = () => {
return (
<Spin spinning={loading}>
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>收入统计</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看您的问诊收入与提现记录</div>
</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={[16, 16]}>
<Col span={8}>
<div style={{ borderRadius: 12, padding: '20px 24px', background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)', position: 'relative', overflow: 'hidden' }}>
<div style={{ borderRadius: 12, padding: '12px 16px', background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: -30, right: -30, width: 100, height: 100, borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }} />
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.8)', marginBottom: 6 }}>可提现余额</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff', marginBottom: 12 }}>¥{(stats.total_balance / 100).toFixed(2)}</div>
......@@ -126,7 +119,7 @@ const IncomePage: React.FC = () => {
</div>
</Col>
<Col span={8}>
<div style={{ background: '#fff', borderRadius: 12, padding: '20px 24px', border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<div style={{ background: '#fff', borderRadius: 12, padding: '12px 16px', border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<div style={{ fontSize: 13, color: '#8c8c8c', marginBottom: 6 }}>本月收入</div>
<div style={{ fontSize: 26, fontWeight: 700, color: '#52c41a', marginBottom: 6 }}>¥{(stats.month_income / 100).toFixed(2)}</div>
<div style={{ fontSize: 12, color: '#bfbfbf', display: 'flex', alignItems: 'center', gap: 4 }}>
......@@ -135,9 +128,9 @@ const IncomePage: React.FC = () => {
</div>
</Col>
<Col span={8}>
<div style={{ background: '#fff', borderRadius: 12, padding: '20px 24px', border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<div style={{ background: '#fff', borderRadius: 12, padding: '12px 16px', border: '1px solid #E5E8EF', borderLeft: '4px solid #2B9E6E' }}>
<div style={{ fontSize: 13, color: '#8c8c8c', marginBottom: 6 }}>本月问诊量</div>
<div style={{ fontSize: 26, fontWeight: 700, color: '#0D9488', marginBottom: 6 }}>
<div style={{ fontSize: 26, fontWeight: 700, color: '#2B9E6E', marginBottom: 6 }}>
{stats.month_consults} <span style={{ fontSize: 14, color: '#bfbfbf', fontWeight: 400 }}>次</span>
</div>
<div style={{ fontSize: 12, color: '#bfbfbf' }}>日均 {(stats.month_consults / Math.max(1, new Date().getDate())).toFixed(1)} 次</div>
......@@ -152,7 +145,7 @@ const IncomePage: React.FC = () => {
key: 'records',
label: <span><FileTextOutlined /> 收入明细</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ marginBottom: 16 }}>
<Space>
<RangePicker value={dateRange} onChange={(dates) => setDateRange(dates as any)} />
......@@ -200,7 +193,7 @@ const IncomePage: React.FC = () => {
<Text type="secondary">图文问诊</Text>
<Text>¥{(bill.text_income / 100).toFixed(0)}</Text>
</Space>
<Progress percent={Math.round(bill.text_income / bill.total_income * 100)} size="small" strokeColor="#0D9488" />
<Progress percent={Math.round(bill.text_income / bill.total_income * 100)} size="small" strokeColor="#2B9E6E" />
</div>
<div style={{ marginBottom: 8 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
......@@ -227,7 +220,7 @@ const IncomePage: React.FC = () => {
key: 'withdraw',
label: <span><BankOutlined /> 提现记录</span>,
children: (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Table
dataSource={withdrawals}
rowKey="id"
......
......@@ -4,52 +4,34 @@ import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'reac
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag, Popover, List } from 'antd';
import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
DollarOutlined, FolderOpenOutlined, SafetyCertificateOutlined,
CheckCircleOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
HomeOutlined, FileTextOutlined, HeartOutlined, VideoCameraOutlined,
PayCircleOutlined, ShoppingCartOutlined, SearchOutlined,
UserOutlined, SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
DownOutlined, CloseOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { doctorPortalApi } from '@/api/doctorPortal';
import { notificationApi, type Notification } from '@/api/notification';
import { getIconComponent } from '@/utils/iconMapper';
const { Sider, Content } = Layout;
const { Text } = Typography;
const ICON_MAP: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
MessageOutlined: <MessageOutlined />,
CalendarOutlined: <CalendarOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
DollarOutlined: <DollarOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
HomeOutlined: <HomeOutlined />,
FileTextOutlined: <FileTextOutlined />,
HeartOutlined: <HeartOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PayCircleOutlined: <PayCircleOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
SearchOutlined: <SearchOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
};
function convertMenuTree(menus: MenuType[]): any[] {
function convertMenuTree(menus: MenuType[], isTop = true): any[] {
return menus.map(m => {
const hasChildren = m.children && m.children.length > 0;
const item: any = {
key: m.path || `menu-${m.id}`,
label: m.name,
icon: ICON_MAP[m.icon] || null,
label: hasChildren && isTop ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{m.name}
<DownOutlined style={{ fontSize: 10, opacity: 0.45 }} />
</span>
) : m.name,
icon: getIconComponent(m.icon),
};
if (m.children && m.children.length > 0) {
item.children = convertMenuTree(m.children);
if (hasChildren) {
item.children = convertMenuTree(m.children, false);
}
return item;
});
......@@ -83,31 +65,61 @@ function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
export default function DoctorLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f8ff' }} />}>
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#F5F7FA' }} />}>
<DoctorLayoutInner>{children}</DoctorLayoutInner>
</Suspense>
);
}
function findMenuName(menus: MenuType[], path: string): string {
for (const m of menus) {
if (m.path === path) return m.name;
if (m.children) {
const found = findMenuName(m.children, path);
if (found) return found;
}
}
return '';
}
function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, logout, menus, setMenus } = useUserStore();
const [isOnline, setIsOnline] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [tabs, setTabs] = useState<Array<{ key: string; label: string }>>([]);
const currentPath = pathname || '';
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 菜单为空时主动拉取
// 每次加载时拉取菜单
useEffect(() => {
if (!user || menus.length > 0) return;
if (!user) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
if (res.data) setMenus(res.data);
}).catch(() => {});
}, [user, setMenus]);
// 路径变化时添加 Tab
useEffect(() => {
if (!currentPath || !menus.length) return;
const name = findMenuName(menus, currentPath);
if (!name) return;
setTabs(prev => {
if (prev.some(t => t.key === currentPath)) return prev;
return [...prev, { key: currentPath, label: name }];
});
}, [currentPath, menus]);
// 加载初始接诊状态
useEffect(() => {
if (!user) return;
doctorPortalApi.getProfile().then(res => {
if (res.data) setIsOnline(res.data.is_online);
}).catch(() => {});
}, [user, menus.length, setMenus]);
}, [user]);
useEffect(() => {
if (!user) return;
......@@ -149,8 +161,18 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
];
const handleMenuClick = ({ key }: { key: string }) => {
if (key.startsWith('/')) {
if (key.startsWith('/')) router.push(key);
};
const handleTabChange = (key: string) => {
router.push(key);
};
const handleTabRemove = (key: string) => {
const newTabs = tabs.filter(t => t.key !== key);
setTabs(newTabs);
if (key === currentPath && newTabs.length > 0) {
router.push(newTabs[newTabs.length - 1].key);
}
};
......@@ -161,6 +183,7 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
await doctorPortalApi.toggleOnlineStatus(checked);
} catch {
setIsOnline(prev);
import('antd').then(({ message: msg }) => msg.error('切换接诊状态失败,请检查医生认证信息'));
}
};
......@@ -176,87 +199,50 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
return match ? [match] : [];
}, [menus, currentPath]);
const getOpenKeys = useCallback(() => {
if (collapsed) return [];
return findOpenKeys(menus, currentPath);
}, [menus, currentPath, collapsed]);
// 移除embed模式处理,智能助手导航现在直接跳转页面,保留完整菜单
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={200}
collapsedWidth={64}
trigger={null}
{/* Top bar with logo, menu, and user info */}
<header
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #E5E8EF',
position: 'sticky', top: 0, zIndex: 100,
}}
>
{/* Logo */}
<div
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/doctor/workbench')}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', marginRight: 24 }}
onClick={() => router.push(useUserStore.getState().homePath || '/doctor/workbench')}
>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #2B9E6E, #1A5C3A)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="green" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>医生</Tag>
</>
)}
<Tag style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px', background: '#e8f9f3', color: '#2B9E6E', border: 'none', borderRadius: 4 }}>医生</Tag>
</div>
{/* Menu */}
{/* Horizontal Menu */}
<Menu
mode="inline"
mode="horizontal"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
style={{ flex: 1, border: 'none', background: 'transparent', lineHeight: '54px' }}
/>
</Sider>
<Layout style={{ marginLeft: collapsed ? 64 : 200, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
{/* Right: Online status + Notification + User */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Switch
checked={isOnline}
onChange={handleOnlineToggle}
checkedChildren="接诊中"
unCheckedChildren="离线"
style={{ backgroundColor: isOnline ? '#52c41a' : undefined }}
style={{ backgroundColor: isOnline ? '#2B9E6E' : undefined }}
/>
<Popover
trigger="click"
......@@ -288,7 +274,7 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size={30} src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} />
<Avatar size={30} src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#2B9E6E' }} />
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>{user.real_name || user.phone}</Text>
</Space>
</Dropdown>
......@@ -296,10 +282,37 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#F8FAFB' }}>
{/* Tabs */}
{tabs.length > 0 && (
<div style={{ background: '#fff', borderBottom: '1px solid #E5E8EF', padding: '0 24px', display: 'flex', alignItems: 'center', gap: 4, height: 36, position: 'sticky', top: 56, zIndex: 99 }}>
{tabs.map(t => (
<div
key={t.key}
onClick={() => handleTabChange(t.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 12px', fontSize: 13, cursor: 'pointer', borderRadius: 6,
color: t.key === currentPath ? '#2B9E6E' : '#595959',
background: t.key === currentPath ? '#e8f9f3' : 'transparent',
fontWeight: t.key === currentPath ? 600 : 400,
transition: 'all 0.2s',
}}
>
{t.label}
{tabs.length > 1 && (
<CloseOutlined
style={{ fontSize: 10, opacity: 0.4 }}
onClick={e => { e.stopPropagation(); handleTabRemove(t.key); }}
/>
)}
</div>
))}
</div>
)}
<Content style={{ background: '#F5F7FA' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
......@@ -140,18 +140,13 @@ const PatientRecordPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>患者档案</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看您接诊过的患者健康记录与历史问诊</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={16} align="stretch">
<Col span={8}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>患者列表</span>}
extra={<Text type="secondary" style={{ fontSize: 13 }}>{total} 人</Text>}
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%' }}
>
<Input prefix={<SearchOutlined />} placeholder="搜索姓名/手机"
value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)}
......@@ -205,7 +200,7 @@ const PatientRecordPage: React.FC = () => {
<Spin spinning={detailLoading}>
{selectedPatient ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Descriptions title={<span style={{ fontSize: 14, fontWeight: 600 }}>基本信息</span>} bordered size="small" column={3}>
<Descriptions.Item label="姓名">{selectedPatient.name}</Descriptions.Item>
<Descriptions.Item label="性别">{genderLabel(selectedPatient.gender)}</Descriptions.Item>
......@@ -222,7 +217,7 @@ const PatientRecordPage: React.FC = () => {
</Descriptions>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="consult" items={[
{
key: 'consult',
......@@ -254,7 +249,7 @@ const PatientRecordPage: React.FC = () => {
</Card>
</div>
) : (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', textAlign: 'center', padding: '60px 0' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', textAlign: 'center', padding: '60px 0' }}>
<Empty description="请从左侧选择患者查看档案" />
</Card>
)}
......
......@@ -140,18 +140,13 @@ const PatientRecordPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>患者档案</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看您接诊过的患者健康记录与历史问诊</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row gutter={16} align="stretch">
<Col span={8}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>患者列表</span>}
extra={<Text type="secondary" style={{ fontSize: 13 }}>{total} 人</Text>}
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%' }}
>
<Input prefix={<SearchOutlined />} placeholder="搜索姓名/手机"
value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)}
......@@ -205,7 +200,7 @@ const PatientRecordPage: React.FC = () => {
<Spin spinning={detailLoading}>
{selectedPatient ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Descriptions title={<span style={{ fontSize: 14, fontWeight: 600 }}>基本信息</span>} bordered size="small" column={3}>
<Descriptions.Item label="姓名">{selectedPatient.name}</Descriptions.Item>
<Descriptions.Item label="性别">{genderLabel(selectedPatient.gender)}</Descriptions.Item>
......@@ -222,7 +217,7 @@ const PatientRecordPage: React.FC = () => {
</Descriptions>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="consult" items={[
{
key: 'consult',
......@@ -254,7 +249,7 @@ const PatientRecordPage: React.FC = () => {
</Card>
</div>
) : (
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', textAlign: 'center', padding: '60px 0' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', textAlign: 'center', padding: '60px 0' }}>
<Empty description="请从左侧选择患者查看档案" />
</Card>
)}
......
......@@ -64,7 +64,7 @@ const DoctorProfilePage: React.FC = () => {
if (!profile) return null;
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Hero 卡片 */}
<div style={{
borderRadius: 12, overflow: 'hidden',
......@@ -96,7 +96,7 @@ const DoctorProfilePage: React.FC = () => {
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>详细信息</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>详细信息</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="姓名">{profile.name}</Descriptions.Item>
<Descriptions.Item label="职称">{profile.title}</Descriptions.Item>
......@@ -111,16 +111,16 @@ const DoctorProfilePage: React.FC = () => {
</Col>
<Col xs={24} md={12}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>接诊设置</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>接诊设置</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#f8faff', borderRadius: 10, border: '1px solid #E0F2F1' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#f8faff', borderRadius: 10, border: '1px solid #E5E8EF' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1d2129' }}>AI 辅助接诊</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>开启后 AI 将协助分析患者症状</div>
</div>
<Switch checked={profile.ai_assist_enabled} onChange={handleToggleAI} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#f8faff', borderRadius: 10, border: '1px solid #E0F2F1' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#f8faff', borderRadius: 10, border: '1px solid #E5E8EF' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1d2129' }}>自动接诊</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>开启后图文问诊将自动接诊</div>
......@@ -138,7 +138,7 @@ const DoctorProfilePage: React.FC = () => {
</Col>
</Row>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>擅长领域</span>} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>擅长领域</span>} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Space wrap size={8}>
{(profile.specialties || []).map((s, i) => (
<Tag key={i} color="processing" style={{ borderRadius: 20, padding: '4px 12px', fontSize: 13 }}>{s}</Tag>
......@@ -146,7 +146,7 @@ const DoctorProfilePage: React.FC = () => {
</Space>
</Card>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>个人简介</span>} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>个人简介</span>} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Text style={{ fontSize: 14, color: '#595959', lineHeight: 1.7 }}>{profile.introduction}</Text>
</Card>
......
......@@ -92,19 +92,15 @@ const DoctorSchedulePage: React.FC = () => {
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>排班管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理您的接诊时间段安排</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddSchedule} style={{ borderRadius: 8 }}>添加排班</Button>
</div>
<Spin spinning={loading}>
<Row gutter={16}>
<Col span={16}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Calendar
fullscreen={false}
value={selectedDate}
......@@ -118,12 +114,12 @@ const DoctorSchedulePage: React.FC = () => {
<Card
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
<CalendarOutlined style={{ marginRight: 6, color: '#0D9488' }} />
<CalendarOutlined style={{ marginRight: 6, color: '#2B9E6E' }} />
{selectedDate.format('MM月DD日')} 排班
</span>
}
extra={<Button type="link" icon={<PlusOutlined />} onClick={handleAddSchedule}>添加</Button>}
style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}
>
{todaySchedule.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
......
......@@ -165,16 +165,8 @@ export default function DoctorTasksPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#0D9488' }} />
待办任务
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>工作流人工审核任务,需要您及时处理</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......
......@@ -125,10 +125,7 @@ const DoctorWorkbenchPage: React.FC = () => {
if (!isVerified) {
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>医生工作台</h2>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Alert
message="请先完成资质认证"
description={
......@@ -149,14 +146,10 @@ const DoctorWorkbenchPage: React.FC = () => {
}
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>医生工作台</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>每10秒自动刷新数据</div>
</div>
<Button type="primary" style={{ borderRadius: 8 }} onClick={() => router.push('/doctor/consult')}>
<Button type="primary" style={{ borderRadius: 8, marginLeft: 'auto' }} onClick={() => router.push('/doctor/consult')}>
进入问诊大厅
</Button>
</div>
......@@ -164,7 +157,7 @@ const DoctorWorkbenchPage: React.FC = () => {
{/* Row1: 核心指标 */}
<Row gutter={[16, 16]}>
{[
{ title: '今日问诊', value: stats.today_consult_count, icon: <MessageOutlined />, color: '#0D9488', bg: '#F0FDFA', suffix: '人次' },
{ title: '今日问诊', value: stats.today_consult_count, icon: <MessageOutlined />, color: '#2B9E6E', bg: '#F0FFF5', suffix: '人次' },
{ title: '等待接诊', value: stats.waiting_count, icon: <ClockCircleOutlined />, color: '#fa8c16', bg: '#fff7e6', suffix: '人' },
{ title: '今日完成', value: stats.completed_today, icon: <CheckCircleOutlined />, color: '#52c41a', bg: '#f6ffed', suffix: '例' },
{ title: '今日收入', value: stats.income_today, icon: <DollarOutlined />, color: '#0891B2', bg: '#ECFEFF', suffix: '元' },
......@@ -172,7 +165,7 @@ const DoctorWorkbenchPage: React.FC = () => {
<Col xs={12} sm={6} key={i}>
<div style={{
background: '#fff', borderRadius: 12, padding: '16px 20px',
border: '1px solid #E0F2F1', transition: 'box-shadow 0.2s',
border: '1px solid #E5E8EF', transition: 'box-shadow 0.2s',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, background: item.bg, color: item.color }}>{item.icon}</div>
......@@ -203,7 +196,7 @@ const DoctorWorkbenchPage: React.FC = () => {
title={<span style={{ fontSize: 14, fontWeight: 600 }}>等待接诊 <Badge count={stats.waiting_count} style={{ backgroundColor: '#fa8c16', marginLeft: 4 }} /></span>}
extra={<Button type="link" onClick={() => router.push('/doctor/consult')}>查看全部</Button>}
loading={loading}
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%' }}
>
<List
dataSource={waitingPatients}
......@@ -240,7 +233,7 @@ const DoctorWorkbenchPage: React.FC = () => {
<Col xs={24} md={10}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>综合数据</span>}
style={{ borderRadius: 12, border: '1px solid #E0F2F1', height: '100%' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF', height: '100%' }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
......
......@@ -458,7 +458,7 @@ const MetricsTab: React.FC = () => {
{
title: '数值', key: 'value',
render: (_: any, r: HealthMetric) => (
<Text strong style={{ color: '#0D9488' }}>
<Text strong style={{ color: '#2B6DE8' }}>
{meta.hasValue2 ? `${r.value1}/${r.value2}` : r.value1} {r.unit}
</Text>
),
......@@ -511,7 +511,7 @@ const MetricsTab: React.FC = () => {
<Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#f0f5ff' }}>
<Statistic title="最新值" value={meta.hasValue2 ? `${latest.value1}/${latest.value2}` : latest.value1}
suffix={meta.unit} valueStyle={{ color: '#0D9488' }} />
suffix={meta.unit} valueStyle={{ color: '#2B6DE8' }} />
</Card>
</Col>
<Col span={8}>
......@@ -574,12 +574,8 @@ const PatientChronicPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>慢病管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理您的慢性病档案、续方申请、用药提醒和健康指标</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs items={tabItems} />
</Card>
</div>
......
......@@ -103,20 +103,15 @@ const PatientConsultListPage: React.FC = () => {
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 页面标题 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>我的问诊</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看和管理您的全部问诊记录</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<MedicineBoxOutlined />} onClick={() => router.push('/patient/doctors')}
style={{ borderRadius: 8 }}>
发起问诊
</Button>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
......@@ -145,7 +140,7 @@ const PatientConsultListPage: React.FC = () => {
<Avatar
src={consult.doctor_avatar || null}
size={48}
style={{ backgroundColor: '#F0FDFA', color: '#0D9488' }}
style={{ backgroundColor: '#F0F6FF', color: '#2B6DE8' }}
icon={<MedicineBoxOutlined />}
/>
}
......
......@@ -122,7 +122,7 @@ const PatientTextConsultPage: React.FC = () => {
icon={isAI ? <RobotOutlined /> : <MedicineBoxOutlined />}
src={!isAI ? consult?.doctor_avatar : undefined}
style={{
backgroundColor: isAI ? '#0891B2' : '#0D9488',
backgroundColor: isAI ? '#0891B2' : '#2B6DE8',
marginRight: 10,
flexShrink: 0,
}}
......@@ -138,7 +138,7 @@ const PatientTextConsultPage: React.FC = () => {
style={{
padding: '10px 14px',
borderRadius: isMe ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
background: isMe ? '#0D9488' : isAI ? '#ECFEFF' : '#f5f5f5',
background: isMe ? '#2B6DE8' : isAI ? '#ECFEFF' : '#f5f5f5',
color: isMe ? '#fff' : '#1d2129',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
whiteSpace: 'pre-wrap',
......@@ -189,15 +189,7 @@ const PatientTextConsultPage: React.FC = () => {
onClick={() => router.push('/patient/consult')}
style={{ color: '#8c8c8c', padding: '4px 8px' }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageOutlined style={{ color: '#1890ff' }} />
图文问诊
</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>
{consult?.doctor_name ? `${consult.doctor_name} 医生` : '在线问诊对话'}
</div>
</div>
<div style={{ flex: 1 }} />
<div style={{
padding: '4px 14px',
borderRadius: 8,
......@@ -219,7 +211,7 @@ const PatientTextConsultPage: React.FC = () => {
width: 240,
flexShrink: 0,
borderRadius: 12,
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
background: '#fff',
display: 'flex',
flexDirection: 'column',
......@@ -230,7 +222,7 @@ const PatientTextConsultPage: React.FC = () => {
size={56}
src={consult?.doctor_avatar}
icon={<MedicineBoxOutlined />}
style={{ backgroundColor: '#0D9488' }}
style={{ backgroundColor: '#2B6DE8' }}
/>
<div style={{ fontSize: 16, fontWeight: 600, marginTop: 10, color: '#1d2129' }}>
{consult?.doctor_name || '医生'}
......@@ -245,7 +237,7 @@ const PatientTextConsultPage: React.FC = () => {
<div style={{ padding: '16px 20px', flex: 1 }}>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>
<MedicineBoxOutlined style={{ color: '#0D9488' }} />
<MedicineBoxOutlined style={{ color: '#2B6DE8' }} />
问诊类型
</div>
<Tag color="green" style={{ marginLeft: 18 }}>图文问诊</Tag>
......@@ -253,7 +245,7 @@ const PatientTextConsultPage: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>
<ClockCircleOutlined style={{ color: '#0D9488' }} />
<ClockCircleOutlined style={{ color: '#2B6DE8' }} />
发起时间
</div>
<div style={{ marginLeft: 18, fontSize: 13, color: '#1d2129' }}>
......@@ -296,7 +288,7 @@ const PatientTextConsultPage: React.FC = () => {
flex: 1,
minWidth: 0,
borderRadius: 12,
border: '1px solid #E0F2F1',
border: '1px solid #E5E8EF',
background: '#fff',
display: 'flex',
flexDirection: 'column',
......@@ -311,7 +303,7 @@ const PatientTextConsultPage: React.FC = () => {
gap: 10,
flexShrink: 0,
}}>
<Avatar size={32} src={consult?.doctor_avatar} icon={<MedicineBoxOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Avatar size={32} src={consult?.doctor_avatar} icon={<MedicineBoxOutlined />} style={{ backgroundColor: '#2B6DE8' }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}>{consult?.doctor_name || '医生'}</Text>
<Tag
......
......@@ -78,7 +78,7 @@ const PatientVideoConsultPage: React.FC = () => {
return (
<div style={{ height: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column' }}>
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column', borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column', borderRadius: 12, border: '1px solid #E5E8EF' }}>
<div className="text-sm font-bold mb-2">视频问诊</div>
<div style={{ flex: 1, display: 'flex', gap: 8, background: '#000', borderRadius: 6, padding: 8, minHeight: 350, position: 'relative' }}>
<div style={{ flex: 1, position: 'relative' }}>
......
......@@ -35,8 +35,8 @@ const DoctorDetailPage: React.FC = () => {
}
return (
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Row gutter={12}>
<Col flex="80px">
<Avatar size={64} src={doctor.avatar} />
......@@ -74,7 +74,7 @@ const DoctorDetailPage: React.FC = () => {
</Row>
</Card>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>擅长领域</span>} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>擅长领域</span>} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Space wrap size={4}>
{doctor.specialties?.map((s, i) => (
<Tag key={i} color="processing">{s}</Tag>
......@@ -82,12 +82,12 @@ const DoctorDetailPage: React.FC = () => {
</Space>
</Card>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>医生简介</span>} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>医生简介</span>} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Paragraph className="text-xs! mb-0!">{doctor.introduction || '暂无简介'}</Paragraph>
</Card>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>排班信息</span>}
extra={<Button type="link" size="small" icon={<CalendarOutlined />}>更多</Button>} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
extra={<Button type="link" size="small" icon={<CalendarOutlined />}>更多</Button>} size="small" style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="出诊状态">
{doctor.is_online ? <Tag color="green">在线</Tag> : <Tag color="default">未排班</Tag>}
......
......@@ -8,6 +8,7 @@ import {
import {
SearchOutlined, VideoCameraOutlined, MessageOutlined,
StarFilled, FireFilled, UserOutlined, RightOutlined,
CalendarOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { doctorApi, type Doctor } from '@/api/doctor';
......@@ -54,7 +55,7 @@ const PatientDoctorsPageInner: React.FC = () => {
<aside style={{
width: 200, flexShrink: 0,
background: '#fff',
borderRight: '1px solid #E0F2F1',
borderRight: '1px solid #E5E8EF',
padding: '20px 0',
}}>
<div style={{ padding: '0 16px 12px', fontSize: 12, fontWeight: 600, color: '#8c8c8c', letterSpacing: 1 }}>
......@@ -66,9 +67,9 @@ const PatientDoctorsPageInner: React.FC = () => {
style={{
padding: '9px 20px', fontSize: 14, cursor: 'pointer',
fontWeight: !departmentId ? 600 : 400,
color: !departmentId ? '#0D9488' : '#1d2129',
background: !departmentId ? '#F0FDFA' : 'transparent',
borderRight: !departmentId ? '3px solid #0D9488' : '3px solid transparent',
color: !departmentId ? '#2B6DE8' : '#1d2129',
background: !departmentId ? '#F0F6FF' : 'transparent',
borderRight: !departmentId ? '3px solid #2B6DE8' : '3px solid transparent',
transition: 'all 0.15s',
}}
>
......@@ -83,12 +84,12 @@ const PatientDoctorsPageInner: React.FC = () => {
style={{
padding: '9px 20px', fontSize: 14, cursor: 'pointer',
fontWeight: active ? 600 : 400,
color: active ? '#0D9488' : '#4e5969',
background: active ? '#F0FDFA' : 'transparent',
borderRight: active ? '3px solid #0D9488' : '3px solid transparent',
color: active ? '#2B6DE8' : '#4e5969',
background: active ? '#F0F6FF' : 'transparent',
borderRight: active ? '3px solid #2B6DE8' : '3px solid transparent',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = '#F8FAFB'; }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = '#F5F7FA'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
{d.name}
......@@ -98,7 +99,7 @@ const PatientDoctorsPageInner: React.FC = () => {
</aside>
{/* 右侧主内容 */}
<main style={{ flex: 1, padding: '20px 24px', minWidth: 0 }}>
<main style={{ flex: 1, padding: '12px 16px', minWidth: 0 }}>
{/* 搜索 + 排序栏 */}
<div style={{
......@@ -132,7 +133,7 @@ const PatientDoctorsPageInner: React.FC = () => {
{/* 结果数量 */}
{!isLoading && (
<div style={{ fontSize: 13, color: '#8c8c8c', marginBottom: 16 }}>
共找到 <span style={{ color: '#0D9488', fontWeight: 600 }}>{doctors.length}</span> 位医生
共找到 <span style={{ color: '#2B6DE8', fontWeight: 600 }}>{doctors.length}</span> 位医生
</div>
)}
......@@ -184,10 +185,10 @@ const DoctorCard: React.FC<{
style={{
background: '#fff',
borderRadius: 12,
border: `1px solid ${hovered ? '#5EABA3' : '#E0F2F1'}`,
border: `1px solid ${hovered ? '#5B8FF9' : '#E5E8EF'}`,
padding: '20px',
transition: 'all 0.2s',
boxShadow: hovered ? '0 4px 16px rgba(13,148,136,0.12)' : '0 1px 3px rgba(13,148,136,0.06)',
boxShadow: hovered ? '0 4px 16px rgba(43,109,232,0.08)' : '0 1px 3px rgba(0,0,0,0.04)',
cursor: 'default',
}}
>
......@@ -199,7 +200,7 @@ const DoctorCard: React.FC<{
size={64}
src={doctor.avatar || null}
icon={<UserOutlined />}
style={{ backgroundColor: '#F0FDFA', color: '#0D9488', fontSize: 28 }}
style={{ backgroundColor: '#F0F6FF', color: '#2B6DE8', fontSize: 28 }}
/>
{doctor.is_online && (
<span style={{
......@@ -242,7 +243,7 @@ const DoctorCard: React.FC<{
{/* 价格 */}
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#0D9488', lineHeight: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#2B6DE8', lineHeight: 1 }}>
¥{doctor.price || 0}
</div>
<div style={{ fontSize: 11, color: '#bfbfbf', marginTop: 2 }}>元 / 次</div>
......@@ -265,6 +266,33 @@ const DoctorCard: React.FC<{
{doctor.specialties?.join('、') || doctor.introduction || '暂无介绍'}
</div>
{/* 排班状态 */}
<div style={{
fontSize: 13, lineHeight: 1.6,
padding: '8px 12px',
background: doctor.next_available_date ? '#F0FDF4' : '#f5f5f5',
borderRadius: 8,
marginBottom: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
{doctor.next_available_date ? (
<>
<span style={{ color: '#16a34a', display: 'flex', alignItems: 'center', gap: 4 }}>
<CalendarOutlined />
最近可约:{doctor.next_available_date} {doctor.next_available_time}
</span>
<span style={{ color: '#2B6DE8', fontWeight: 500 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
近7天余号:{doctor.available_slots}个
</span>
</>
) : (
<span style={{ color: '#bfbfbf' }}>暂无排班</span>
)}
</div>
{/* 底部操作按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button
......
......@@ -397,12 +397,8 @@ const FamilyTab: React.FC = () => {
// ==================== 主页面 ====================
const PatientHealthRecordsPage: React.FC = () => (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>健康档案</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理您的健康信息、检验报告、家庭成员和健康趋势</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="profile" items={[
{ key: 'profile', label: <span><MedicineBoxOutlined /> 健康信息</span>, children: <HealthProfileTab /> },
{ key: 'reports', label: <span><FileTextOutlined /> 检验报告</span>, children: <LabReportsTab /> },
......
......@@ -28,7 +28,7 @@ import request from '@/api/request';
const { Text } = Typography;
const services = [
{ icon: <VideoCameraOutlined />, title: '视频问诊', desc: '面对面视频交流', path: '/patient/doctors?type=video', color: '#0D9488', bg: '#F0FDFA' },
{ icon: <VideoCameraOutlined />, title: '视频问诊', desc: '面对面视频交流', path: '/patient/doctors?type=video', color: '#2B6DE8', bg: '#F0F6FF' },
{ icon: <MessageOutlined />, title: '图文问诊', desc: '随时在线咨询', path: '/patient/doctors?type=text', color: '#52c41a', bg: '#f6ffed' },
{ icon: <RobotOutlined />, title: 'AI智能问诊', desc: 'AI辅助症状分析', path: '/patient/pre-consult', color: '#0891B2', bg: '#ECFEFF' },
{ icon: <MedicineBoxOutlined />, title: '找医生', desc: '按科室找专家', path: '/patient/doctors', color: '#fa8c16', bg: '#fff7e6' },
......@@ -37,7 +37,7 @@ const services = [
];
const quickLinks = [
{ icon: <VideoCameraOutlined />, title: '我的问诊', desc: '查看进行中的问诊', path: '/patient/consult', color: '#0D9488' },
{ icon: <VideoCameraOutlined />, title: '我的问诊', desc: '查看进行中的问诊', path: '/patient/consult', color: '#2B6DE8' },
{ icon: <ShoppingCartOutlined />, title: '药品配送', desc: '处方药品快速配送', path: '/patient/pharmacy/order', color: '#fa8c16' },
{ icon: <FolderOpenOutlined />, title: '健康档案', desc: '查看我的健康数据', path: '/patient/health-records', color: '#52c41a' },
];
......@@ -130,13 +130,13 @@ const PatientHomePage: React.FC = () => {
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Banner */}
<div style={{
borderRadius: 12,
overflow: 'hidden',
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
background: 'linear-gradient(135deg, #2B6DE8 0%, #1A56C4 100%)',
position: 'relative',
}}>
<div style={{ position: 'absolute', top: -60, right: -60, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
......@@ -269,7 +269,7 @@ const PatientHomePage: React.FC = () => {
<Card
title="医疗服务"
styles={{ header: { fontWeight: 700, fontSize: 15 } }}
style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}
>
<Row gutter={[12, 12]}>
{services.map((service, index) => (
......@@ -306,7 +306,7 @@ const PatientHomePage: React.FC = () => {
<Card
title="快捷入口"
styles={{ header: { fontWeight: 700, fontSize: 15 }, body: { padding: '12px 20px' } }}
style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{quickLinks.map((link) => (
......@@ -316,16 +316,16 @@ const PatientHomePage: React.FC = () => {
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', borderRadius: 10, cursor: 'pointer',
border: '1px solid #E0F2F1', transition: 'all 0.2s',
background: '#F8FAFB',
border: '1px solid #E5E8EF', transition: 'all 0.2s',
background: '#F5F7FA',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#F0FDFA';
e.currentTarget.style.borderColor = '#5EABA3';
e.currentTarget.style.background = '#F0F6FF';
e.currentTarget.style.borderColor = '#5B8FF9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#F8FAFB';
e.currentTarget.style.borderColor = '#E0F2F1';
e.currentTarget.style.background = '#F5F7FA';
e.currentTarget.style.borderColor = '#E5E8EF';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
......@@ -353,7 +353,7 @@ const PatientHomePage: React.FC = () => {
<Card
title="科室导航"
styles={{ header: { fontWeight: 700, fontSize: 15 } }}
style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}
style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{departments.map((dept) => (
......@@ -363,16 +363,16 @@ const PatientHomePage: React.FC = () => {
style={{
cursor: 'pointer', borderRadius: 20, padding: '4px 14px',
fontSize: 13, lineHeight: '22px',
border: '1px solid #99D5CF', color: '#0D9488',
background: '#F0FDFA', transition: 'all 0.2s',
border: '1px solid #93B4F9', color: '#2B6DE8',
background: '#F0F6FF', transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#0D9488';
(e.currentTarget as HTMLElement).style.background = '#2B6DE8';
(e.currentTarget as HTMLElement).style.color = '#fff';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '#F0FDFA';
(e.currentTarget as HTMLElement).style.color = '#0D9488';
(e.currentTarget as HTMLElement).style.background = '#F0F6FF';
(e.currentTarget as HTMLElement).style.color = '#2B6DE8';
}}
>
{dept}
......
......@@ -4,53 +4,44 @@ import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'reac
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag, Popover, List } from 'antd';
import {
HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined,
HeartOutlined, SettingOutlined, LogoutOutlined, BellOutlined,
VideoCameraOutlined, PayCircleOutlined,
ShoppingCartOutlined, FolderOpenOutlined, SearchOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, DashboardOutlined,
MessageOutlined, CalendarOutlined, DollarOutlined,
SafetyCertificateOutlined, CheckCircleOutlined,
UserOutlined, MedicineBoxOutlined, SettingOutlined, LogoutOutlined, BellOutlined,
DownOutlined, CloseOutlined,
} from '@ant-design/icons';
import { getIconComponent } from '@/utils/iconMapper';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Content } = Layout;
const { Text } = Typography;
// 图标名称 → React 图标组件映射
const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
UserOutlined: <UserOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
FileTextOutlined: <FileTextOutlined />,
HeartOutlined: <HeartOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PayCircleOutlined: <PayCircleOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
SearchOutlined: <SearchOutlined />,
SettingOutlined: <SettingOutlined />,
DashboardOutlined: <DashboardOutlined />,
MessageOutlined: <MessageOutlined />,
CalendarOutlined: <CalendarOutlined />,
DollarOutlined: <DollarOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
BellOutlined: <BellOutlined />,
};
function findMenuName(menus: MenuType[], path: string): string {
for (const m of menus) {
if (m.path === path) return m.name;
if (m.children) {
const found = findMenuName(m.children, path);
if (found) return found;
}
}
return '';
}
function convertMenuTree(menus: MenuType[]): any[] {
function convertMenuTree(menus: MenuType[], isTop = true): any[] {
return menus.map(m => {
const hasChildren = m.children && m.children.length > 0;
const item: any = {
key: m.path || `menu-${m.id}`,
label: m.name,
icon: ICON_MAP[m.icon] || null,
label: hasChildren && isTop ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{m.name}
<DownOutlined style={{ fontSize: 10, opacity: 0.45 }} />
</span>
) : m.name,
icon: getIconComponent(m.icon),
};
if (m.children && m.children.length > 0) {
item.children = convertMenuTree(m.children);
if (hasChildren) {
item.children = convertMenuTree(m.children, false);
}
return item;
});
......@@ -84,7 +75,7 @@ function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
export default function PatientLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f8ff' }} />}>
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#F5F7FA' }} />}>
<PatientLayoutInner>{children}</PatientLayoutInner>
</Suspense>
);
......@@ -94,20 +85,31 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, logout, menus, setMenus } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [tabs, setTabs] = useState<Array<{ key: string; label: string }>>([]);
const currentPath = pathname || '';
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 菜单为空时主动拉取
// 每次加载时拉取菜单
useEffect(() => {
if (!user || menus.length > 0) return;
if (!user) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
if (res.data) setMenus(res.data);
}).catch(() => {});
}, [user, menus.length, setMenus]);
}, [user, setMenus]);
// 路径变化时添加 Tab
useEffect(() => {
if (!currentPath || !menus.length) return;
const name = findMenuName(menus, currentPath);
if (!name) return;
setTabs(prev => {
if (prev.some(t => t.key === currentPath)) return prev;
return [...prev, { key: currentPath, label: name }];
});
}, [currentPath, menus]);
useEffect(() => {
if (!user) return;
......@@ -152,6 +154,18 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
if (key.startsWith('/')) router.push(key);
};
const handleTabChange = (key: string) => {
router.push(key);
};
const handleTabRemove = (key: string) => {
const newTabs = tabs.filter(t => t.key !== key);
setTabs(newTabs);
if (key === currentPath && newTabs.length > 0) {
router.push(newTabs[newTabs.length - 1].key);
}
};
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') router.push('/patient/profile');
......@@ -163,80 +177,45 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
return match ? [match] : [];
}, [menus, currentPath]);
const getOpenKeys = useCallback(() => {
if (collapsed) return [];
return findOpenKeys(menus, currentPath);
}, [menus, currentPath, collapsed]);
// 移除embed模式处理,智能助手导航现在直接跳转页面,保留完整菜单
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={200}
collapsedWidth={64}
trigger={null}
{/* Top bar with logo, menu, and user info */}
<header
style={{
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #E5E8EF',
position: 'sticky', top: 0, zIndex: 100,
}}
>
{/* Logo */}
<div
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/patient/home')}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', marginRight: 24 }}
onClick={() => router.push(useUserStore.getState().homePath || '/patient/home')}
>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #2B6DE8, #1A3B7A)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="cyan" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>患者</Tag>
</>
)}
<Tag style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px', background: '#e8f4fd', color: '#2B6DE8', border: 'none', borderRadius: 4 }}>患者</Tag>
</div>
{/* Menu */}
{/* Horizontal Menu */}
<Menu
mode="inline"
mode="horizontal"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
style={{ flex: 1, border: 'none', background: 'transparent', lineHeight: '54px' }}
/>
</Sider>
<Layout style={{ marginLeft: collapsed ? 64 : 200, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
{/* Right: Notification + User */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Popover
trigger="click"
......@@ -268,7 +247,7 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size={30} src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Avatar size={30} src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#2B6DE8' }} />
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>{user.real_name || user.phone}</Text>
</Space>
</Dropdown>
......@@ -280,10 +259,37 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#F8FAFB' }}>
{/* Tabs */}
{tabs.length > 0 && (
<div style={{ background: '#fff', borderBottom: '1px solid #E5E8EF', padding: '0 24px', display: 'flex', alignItems: 'center', gap: 4, height: 36, position: 'sticky', top: 56, zIndex: 99 }}>
{tabs.map(t => (
<div
key={t.key}
onClick={() => handleTabChange(t.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 12px', fontSize: 13, cursor: 'pointer', borderRadius: 6,
color: t.key === currentPath ? '#2B6DE8' : '#595959',
background: t.key === currentPath ? '#F0F6FF' : 'transparent',
fontWeight: t.key === currentPath ? 600 : 400,
transition: 'all 0.2s',
}}
>
{t.label}
{tabs.length > 1 && (
<CloseOutlined
style={{ fontSize: 10, opacity: 0.4 }}
onClick={e => { e.stopPropagation(); handleTabRemove(t.key); }}
/>
)}
</div>
))}
</div>
)}
<Content style={{ background: '#F5F7FA' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
......@@ -99,7 +99,7 @@ const PatientPaymentPage: React.FC = () => {
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (v: number) => <Text strong style={{ color: '#0D9488' }}>¥{(v / 100).toFixed(2)}</Text>,
render: (v: number) => <Text strong style={{ color: '#2B6DE8' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
......@@ -125,13 +125,8 @@ const PatientPaymentPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 页面标题 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>支付管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看您的支付记录和订单状态</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<WalletOutlined />} onClick={() => router.push('/patient/consult')}
style={{ borderRadius: 8 }}>
查看问诊
......@@ -140,28 +135,28 @@ const PatientPaymentPage: React.FC = () => {
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<Statistic title="已支付总额" value={totalPaid / 100} precision={2} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 22 }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #fa8c16' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #fa8c16' }}>
<Statistic title="待支付" value={totalPending / 100} precision={2} prefix="¥" valueStyle={{ color: '#fa8c16', fontSize: 22 }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #2B6DE8' }}>
<Statistic title="问诊支付" value={consultCount} suffix="笔" valueStyle={{ fontSize: 22 }} />
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0891B2' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #0891B2' }}>
<Statistic title="购药支付" value={pharmacyCount} suffix="笔" valueStyle={{ fontSize: 22 }} />
</Card>
</Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="all" items={[
{
key: 'all',
......@@ -211,7 +206,7 @@ const PatientPaymentPage: React.FC = () => {
]} />
</Card>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>支付方式</span>} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>支付方式</span>} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Row gutter={[8, 8]}>
{[
{ icon: <PayCircleOutlined style={{ fontSize: 24, color: '#07c160' }} />, title: '微信支付', desc: '推荐' },
......@@ -248,7 +243,7 @@ const PatientPaymentPage: React.FC = () => {
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-gray-500 text-sm mb-2">支付金额</div>
<div style={{ fontSize: 32, fontWeight: 700, color: '#0D9488' }}>
<div style={{ fontSize: 32, fontWeight: 700, color: '#2B6DE8' }}>
¥{(selectedOrder.amount / 100).toFixed(2)}
</div>
<div className="text-xs text-gray-400 mt-1">
......
......@@ -110,7 +110,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
},
{
title: '金额', dataIndex: 'total_amount', key: 'total_amount', width: 90,
render: (v: number) => <Text strong style={{ color: '#0D9488' }}>¥{(v / 100).toFixed(2)}</Text>,
render: (v: number) => <Text strong style={{ color: '#2B6DE8' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
......@@ -145,13 +145,8 @@ const PatientPharmacyOrderPage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 页面标题 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>药品配送</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看处方药品的配送状态和订单记录</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<ShoppingCartOutlined />} onClick={() => router.push('/patient/prescription')}
style={{ borderRadius: 8 }}>
查看处方
......@@ -161,9 +156,9 @@ const PatientPharmacyOrderPage: React.FC = () => {
{/* 统计卡片 */}
<Row gutter={[16, 16]}>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0D9488' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #2B6DE8' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#F0FDFA', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#0D9488' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#F0F6FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#2B6DE8' }}>
<MedicineBoxOutlined />
</div>
<div>
......@@ -174,7 +169,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #fa8c16' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #fa8c16' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#fff7e6', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#fa8c16' }}>
<ClockCircleOutlined />
......@@ -187,7 +182,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #0891B2' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #0891B2' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ECFEFF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#0891B2' }}>
<CarOutlined />
......@@ -200,7 +195,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
</Card>
</Col>
<Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1', borderLeft: '4px solid #52c41a' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF', borderLeft: '4px solid #52c41a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f6ffed', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#52c41a' }}>
<CheckCircleOutlined />
......@@ -215,7 +210,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
</Row>
{/* 订单列表 */}
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Tabs defaultActiveKey="all" items={[
{
key: 'all',
......@@ -261,7 +256,7 @@ const PatientPharmacyOrderPage: React.FC = () => {
</Space>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Text strong style={{ color: '#0D9488' }}>¥{(detailModal.total_amount / 100).toFixed(2)}</Text>
<Text strong style={{ color: '#2B6DE8' }}>¥{(detailModal.total_amount / 100).toFixed(2)}</Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}>{statusMap[detailModal.status]?.label}</Tag>
......
......@@ -79,7 +79,7 @@ const PatientPrescriptionPage: React.FC = () => {
},
{
title: '金额', dataIndex: 'total_amount', key: 'total_amount', width: 100,
render: (v: number) => <Text strong style={{ color: '#0D9488' }}>¥{(v / 100).toFixed(2)}</Text>,
render: (v: number) => <Text strong style={{ color: '#2B6DE8' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{ title: '状态', dataIndex: 'status', key: 'status', width: 90, render: (v: string) => getStatusTag(v) },
{
......@@ -96,9 +96,9 @@ const PatientPrescriptionPage: React.FC = () => {
if (!loading && prescriptions.length === 0) {
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ maxWidth: 500, margin: '60px auto', textAlign: 'center', borderRadius: 12, border: '1px solid #E0F2F1', padding: '40px 20px' }}>
<FileTextOutlined style={{ fontSize: 48, color: '#0D9488', marginBottom: 16 }} />
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card style={{ maxWidth: 500, margin: '60px auto', textAlign: 'center', borderRadius: 12, border: '1px solid #E5E8EF', padding: '40px 20px' }}>
<FileTextOutlined style={{ fontSize: 48, color: '#2B6DE8', marginBottom: 16 }} />
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, color: '#1d2129' }}>暂无处方记录</div>
<div style={{ fontSize: 13, color: '#8c8c8c', marginBottom: 20 }}>完成问诊后医生会为您开具处方</div>
<Button type="primary" onClick={() => router.push('/patient/doctors')} style={{ borderRadius: 8 }}>去问诊</Button>
......@@ -108,14 +108,9 @@ const PatientPrescriptionPage: React.FC = () => {
}
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 页面标题 */}
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>电子处方</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看医生为您开具的所有电子处方</div>
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<Spin spinning={loading}>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<ProTable search={false} columns={columns} dataSource={prescriptions} rowKey="id"
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 张` }} />
</Card>
......@@ -182,7 +177,7 @@ const PatientPrescriptionPage: React.FC = () => {
))}
<Divider style={{ margin: '8px 0' }} />
<div style={{ textAlign: 'right' }}>
<Text strong style={{ fontSize: 16, color: '#0D9488' }}>合计:¥{(currentRx.total_amount / 100).toFixed(2)}</Text>
<Text strong style={{ fontSize: 16, color: '#2B6DE8' }}>合计:¥{(currentRx.total_amount / 100).toFixed(2)}</Text>
</div>
</Card>
{currentRx.remark && (
......
......@@ -33,7 +33,7 @@ const PatientProfilePage: React.FC = () => {
const { message } = App.useApp();
const serviceLinks = [
{ icon: <VideoCameraOutlined />, title: '就诊记录', path: '/patient/consult', color: '#0D9488' },
{ icon: <VideoCameraOutlined />, title: '就诊记录', path: '/patient/consult', color: '#2B6DE8' },
{ icon: <FileTextOutlined />, title: '电子处方', path: '/patient/prescription', color: '#13c2c2' },
{ icon: <FolderOpenOutlined />, title: '健康档案', path: '/patient/health-records', color: '#52c41a' },
{ icon: <HeartOutlined />, title: '慢病管理', path: '/patient/chronic', color: '#eb2f96' },
......@@ -42,11 +42,11 @@ const PatientProfilePage: React.FC = () => {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 个人信息 Hero 卡片 */}
<div style={{
borderRadius: 12, overflow: 'hidden',
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
background: 'linear-gradient(135deg, #2B6DE8 0%, #1A56C4 100%)',
padding: '28px 32px',
position: 'relative',
}}>
......@@ -80,7 +80,7 @@ const PatientProfilePage: React.FC = () => {
<Row gutter={[16, 16]}>
{/* 基本信息 */}
<Col xs={24} sm={12}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>基本信息</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>基本信息</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="用户ID">
<Text copyable style={{ fontSize: 13 }}>{user?.id || '-'}</Text>
......@@ -96,7 +96,7 @@ const PatientProfilePage: React.FC = () => {
{/* 实名认证 */}
<Col xs={24} sm={12}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>实名认证</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>实名认证</span>} style={{ height: '100%', borderRadius: 12, border: '1px solid #E5E8EF' }}>
{user?.is_verified ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{
......@@ -129,7 +129,7 @@ const PatientProfilePage: React.FC = () => {
</Row>
{/* 更多服务 */}
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>更多服务</span>} style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>更多服务</span>} style={{ borderRadius: 12, border: '1px solid #E5E8EF' }}>
<Row gutter={[12, 12]}>
{serviceLinks.map((link) => (
<Col xs={12} sm={8} md={4} key={link.title}>
......@@ -137,12 +137,12 @@ const PatientProfilePage: React.FC = () => {
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 14px', borderRadius: 10, cursor: 'pointer',
border: '1px solid #E0F2F1', transition: 'all 0.2s',
background: '#F8FAFB',
border: '1px solid #E5E8EF', transition: 'all 0.2s',
background: '#F5F7FA',
}}
onClick={() => router.push(link.path)}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F0FDFA'; e.currentTarget.style.borderColor = '#5EABA3'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#F8FAFB'; e.currentTarget.style.borderColor = '#E0F2F1'; }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F0F6FF'; e.currentTarget.style.borderColor = '#5B8FF9'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#F5F7FA'; e.currentTarget.style.borderColor = '#E5E8EF'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
......
......@@ -11,20 +11,25 @@
@import "tailwindcss/utilities" layer(utilities);
@theme inline {
--color-primary: #0D9488;
--color-primary-light: #14B8A6;
--color-primary-dark: #0F766E;
--color-primary-bg: #F0FDFA;
--color-primary-bg-light: #F0FDFA;
--color-primary: #2B6DE8;
--color-primary-light: #5B8FF9;
--color-primary-dark: #1A56C4;
--color-primary-bg: #F0F6FF;
--color-primary-bg-light: #F0F6FF;
--color-success: #52c41a;
--color-warning: #faad14;
--color-danger: #ff4d4f;
--color-info: #0D9488;
--color-bg-base: #F8FAFB;
--color-info: #2B6DE8;
--color-bg-base: #F5F7FA;
--color-bg-card: #ffffff;
--color-text-primary: #1d2129;
--color-text-secondary: #4e5969;
--color-border: #E0F2F1;
--color-text-disabled: #A0A7B4;
--color-border: #E5E8EF;
--color-hover-bg: #F7F8FA;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
}
/* ===== 全局基础样式(无层级,最高优先级)===== */
......@@ -42,18 +47,18 @@ body {
font-size: 14px;
line-height: 1.6;
color: #1d2129;
background-color: #F8FAFB;
background-color: #F5F7FA;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: #0D9488;
color: #2B6DE8;
text-decoration: none;
}
a:hover {
color: #14B8A6;
color: #5B8FF9;
}
/* ===== 滚动条 ===== */
......@@ -63,14 +68,14 @@ a:hover {
height: 6px;
}
::-webkit-scrollbar-track {
background: #F8FAFB;
background: #F5F7FA;
}
::-webkit-scrollbar-thumb {
background: #99D5CF;
background: #A3C4F3;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #5EABA3;
background: #6BA3EF;
}
/* ===== 患者端白色导航菜单 ===== */
......@@ -96,13 +101,13 @@ a:hover {
.patient-nav-menu .ant-menu-submenu-title:hover,
.patient-nav-menu .ant-menu-item-active,
.patient-nav-menu .ant-menu-submenu-active > .ant-menu-submenu-title {
color: #0D9488 !important;
background: #F0FDFA !important;
color: #2B6DE8 !important;
background: #F0F6FF !important;
}
.patient-nav-menu .ant-menu-item-selected {
color: #0D9488 !important;
background: #F0FDFA !important;
color: #2B6DE8 !important;
background: #F0F6FF !important;
font-weight: 600 !important;
}
......@@ -151,23 +156,28 @@ a:hover {
border-radius: 8px !important;
}
/* ===== 菜单管理 Tree 节点撑满宽度 ===== */
.menu-manage-tree .ant-tree-node-content-wrapper {
flex: 1 !important;
}
/* ===== Ant Design 紧凑覆盖(仅作用于 .compact-ui 内部) ===== */
.compact-ui .ant-card {
border-radius: 10px !important;
box-shadow: 0 1px 3px rgba(13, 148, 136, 0.06) !important;
border: 1px solid #E0F2F1 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important;
border: 1px solid #E5E8EF !important;
transition: box-shadow 0.2s ease !important;
}
.compact-ui .ant-card:hover {
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.1) !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.08) !important;
}
.compact-ui .ant-card .ant-card-head {
min-height: 44px !important;
padding: 0 20px !important;
font-size: 14px !important;
font-weight: 600 !important;
border-bottom: 1px solid #E0F2F1 !important;
border-bottom: 1px solid #E5E8EF !important;
color: #1d2129 !important;
}
.compact-ui .ant-card .ant-card-body {
......@@ -180,7 +190,7 @@ a:hover {
.compact-ui .ant-table-thead > tr > th,
.compact-ui .ant-table-thead > tr > td {
padding: 10px 12px !important;
background: #F0FDFA !important;
background: #F7F8FA !important;
font-weight: 600 !important;
font-size: 13px !important;
color: #1d2129 !important;
......@@ -189,7 +199,7 @@ a:hover {
padding: 8px 12px !important;
}
.compact-ui .ant-table-tbody > tr:hover > td {
background: #F0FDFA !important;
background: #F7F8FA !important;
}
.compact-ui .ant-form-item {
......@@ -202,7 +212,7 @@ a:hover {
.compact-ui .ant-modal .ant-modal-header {
padding: 16px 20px !important;
border-bottom: 1px solid #e6f0fa !important;
border-bottom: 1px solid #E5E8EF !important;
}
.compact-ui .ant-modal .ant-modal-body {
padding: 16px 20px !important;
......@@ -222,7 +232,7 @@ a:hover {
.compact-ui .ant-descriptions .ant-descriptions-item-label {
font-size: 13px !important;
padding: 8px 12px !important;
background: #F0FDFA !important;
background: #F7F8FA !important;
}
.compact-ui .ant-descriptions .ant-descriptions-item-content {
font-size: 13px !important;
......
......@@ -27,21 +27,22 @@ export default function Providers({ children }: { children: React.ReactNode }) {
locale={zhCN}
theme={{
token: {
colorPrimary: '#0D9488',
colorPrimary: '#2B6DE8',
borderRadius: 8,
colorBgLayout: '#F8FAFB',
colorLink: '#0D9488',
colorLinkHover: '#14B8A6',
colorBgLayout: '#F5F7FA',
colorLink: '#2B6DE8',
colorLinkHover: '#5B8FF9',
fontSize: 14,
colorTextBase: '#1d2129',
colorBgContainer: '#ffffff',
boxShadow: '0 1px 3px rgba(13, 148, 136, 0.06)',
boxShadowSecondary: '0 2px 8px rgba(13, 148, 136, 0.08)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
boxShadowSecondary: '0 4px 12px rgba(0,0,0,0.08)',
},
components: {
Card: {
boxShadow: '0 1px 3px rgba(13, 148, 136, 0.06)',
boxShadowTertiary: '0 1px 3px rgba(13, 148, 136, 0.06)',
borderRadiusLG: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
boxShadowTertiary: '0 1px 3px rgba(0,0,0,0.04)',
},
Button: {
borderRadius: 8,
......@@ -50,6 +51,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
Input: {
borderRadius: 8,
},
Table: {
headerBg: '#F7F8FA',
headerColor: '#1d2129',
rowHoverBg: '#F7F8FA',
},
},
}}
>
......
......@@ -13,10 +13,10 @@ interface AuthGuardProps {
/**
* 路由鉴权守卫
* - 未登录时重定向到登录页
* - 已登录但角色不匹配时跳转到对应端首页
* - 已登录但角色不匹配时跳转到用户的 homePath
*/
const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRole }) => {
const { isAuthenticated, user } = useUserStore();
const { isAuthenticated, user, homePath } = useUserStore();
const router = useRouter();
const pathname = usePathname();
......@@ -28,21 +28,11 @@ const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRole }) => {
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;
if (!roles.includes(user.role)) {
router.replace(homePath || '/patient/home');
}
}
}
}, [isAuthenticated, user, requiredRole, router, pathname]);
}, [isAuthenticated, user, requiredRole, router, pathname, homePath]);
if (!isAuthenticated || !user) {
return null;
......@@ -50,7 +40,7 @@ const AuthGuard: React.FC<AuthGuardProps> = ({ children, requiredRole }) => {
if (requiredRole) {
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
if (!roles.includes(user.role as UserRole)) {
if (!roles.includes(user.role)) {
return null;
}
}
......
......@@ -17,7 +17,7 @@ echarts.use([
RadarComponent, TitleComponent, CanvasRenderer,
]);
export const CHART_COLORS = ['#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#13c2c2', '#eb2f96'];
export const CHART_COLORS = ['#2B6DE8', '#52c41a', '#FAAD14', '#722ED1', '#13C2C2', '#EB2F96'];
interface EChartsCardProps {
title: string;
......@@ -25,15 +25,16 @@ interface EChartsCardProps {
height?: number;
option: Record<string, unknown>;
loading?: boolean;
style?: React.CSSProperties;
}
export default function EChartsCard({ title, extra, height = 280, option, loading }: EChartsCardProps) {
export default function EChartsCard({ title, extra, height = 280, option, loading, style }: EChartsCardProps) {
return (
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>{title}</span>}
extra={extra}
loading={loading}
style={{ borderRadius: 12, border: '1px solid #edf2fc' }}
style={{ borderRadius: 12, border: '1px solid #edf2fc', ...style }}
styles={{ body: { padding: '12px 16px' } }}
>
<ReactECharts
......
......@@ -192,7 +192,7 @@ const ChatRoom: React.FC<ChatRoomProps> = ({
size={32}
className="mr-2 flex-shrink-0"
icon={msg.sender_type === 'doctor' ? <MedicineBoxOutlined /> : <UserOutlined />}
style={{ backgroundColor: msg.sender_type === 'doctor' ? '#52c41a' : '#0D9488' }}
style={{ backgroundColor: msg.sender_type === 'doctor' ? '#52c41a' : '#2B6DE8' }}
/>
)}
<div style={{ maxWidth: '70%' }}>
......@@ -202,7 +202,7 @@ const ChatRoom: React.FC<ChatRoomProps> = ({
<div
className="rounded-lg px-3 py-2 text-sm"
style={{
background: isMe ? '#0D9488' : '#f5f5f5',
background: isMe ? '#2B6DE8' : '#f5f5f5',
color: isMe ? '#fff' : '#333',
}}
>
......@@ -222,7 +222,7 @@ const ChatRoom: React.FC<ChatRoomProps> = ({
size={32}
className="ml-2 flex-shrink-0"
icon={userType === 'doctor' ? <MedicineBoxOutlined /> : <UserOutlined />}
style={{ backgroundColor: userType === 'doctor' ? '#52c41a' : '#0D9488' }}
style={{ backgroundColor: userType === 'doctor' ? '#52c41a' : '#2B6DE8' }}
/>
)}
</div>
......
'use client';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Input, Button, Tag, Spin, Tooltip, App } from 'antd';
import { Input, Button, Tag, Spin, Tooltip, App, Popconfirm } from 'antd';
import {
SendOutlined,
RobotOutlined,
......@@ -14,8 +14,12 @@ import {
ClearOutlined,
CompassOutlined,
PauseCircleOutlined,
HistoryOutlined,
PlusOutlined,
DeleteOutlined,
MessageOutlined,
} from '@ant-design/icons';
import { agentApi } from '../../api/agent';
import { agentApi, type AgentSession } from '../../api/agent';
import { useAIAssistStore } from '../../store/aiAssistStore';
import MarkdownRenderer from '../MarkdownRenderer';
import ToolCallCard from './ToolCallCard';
......@@ -48,6 +52,32 @@ const QUICK_ICON: Record<WidgetRole, React.ReactNode> = {
admin: <CompassOutlined style={{ marginRight: 2 }} />,
};
// 相对时间格式化
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const d = new Date(dateStr).getTime();
const diff = now - d;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 172800000) return '昨天';
return `${Math.floor(diff / 86400000)}天前`;
}
// 从 history JSON 提取摘要
function extractSummary(historyStr: string): string {
try {
const history = JSON.parse(historyStr || '[]') as Array<{ role: string; content: string }>;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].role === 'user' && history[i].content) {
const text = history[i].content.trim();
return text.length > 30 ? text.slice(0, 30) + '...' : text;
}
}
} catch { /* ignore */ }
return '新对话';
}
const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const { message: antMessage } = App.useApp();
const store = useAIAssistStore();
......@@ -63,6 +93,11 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// 历史面板状态
const [historyOpen, setHistoryOpen] = useState(false);
const [historySessions, setHistorySessions] = useState<AgentSession[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const agentId = ROLE_AGENT_ID[role];
const agentName = ROLE_AGENT_NAME[role];
const quickItems = QUICK_ITEMS[role];
......@@ -162,6 +197,64 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
setMessages(prev => prev.map(m => m.streaming ? { ...m, streaming: false } : m));
}, []);
// ========== 历史面板相关 ==========
const fetchHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const res = await agentApi.getSessions(agentId);
setHistorySessions(res.data || []);
} catch (e) {
console.warn('[ChatPanel] 获取历史会话失败:', e);
} finally {
setHistoryLoading(false);
}
}, [agentId]);
useEffect(() => {
if (historyOpen) fetchHistory();
}, [historyOpen, fetchHistory]);
const loadSession = useCallback((session: AgentSession) => {
stopStreaming();
setSessionId(session.session_id);
try {
const history = JSON.parse(session.history || '[]') as Array<{
role: string; content: string;
tool_calls?: ToolCall[];
meta?: { iterations?: number; tokens?: number; agent_id?: string };
}>;
if (history.length > 0) {
const restored: ChatMessage[] = history.map(h => ({
role: h.role as 'user' | 'assistant',
content: h.content,
toolCalls: h.tool_calls || undefined,
meta: h.meta || undefined,
timestamp: new Date(),
}));
setMessages(restored);
} else {
setMessages([{ role: 'system', content: welcomeContent, timestamp: new Date() }]);
}
} catch {
setMessages([{ role: 'system', content: welcomeContent, timestamp: new Date() }]);
}
setSessionLoaded(true);
}, [stopStreaming, setSessionId, setMessages, setSessionLoaded, welcomeContent]);
const deleteSession = useCallback(async (sid: string) => {
try {
await agentApi.deleteSession(sid);
setHistorySessions(prev => prev.filter(s => s.session_id !== sid));
if (sid === sessionId) {
await createNewSession();
setMessages([{ role: 'system', content: welcomeContent, timestamp: new Date() }]);
setSessionLoaded(true);
}
} catch {
antMessage.error('删除失败');
}
}, [sessionId, createNewSession, setMessages, setSessionLoaded, welcomeContent, antMessage]);
// 统一的 stream 回调工厂 — sendMessage 和 handleSendFromAction 共用
const createStreamCallbacks = useCallback((msgId: number) => ({
onSession: ({ session_id }: { session_id: string }) => {
......@@ -264,6 +357,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
setMessages([{ role: 'system', content: welcomeContent, timestamp: new Date() }]);
// 标记为已加载,防止自动恢复旧会话
setSessionLoaded(true);
// 刷新历史列表
if (historyOpen) fetchHistory();
};
const handleSendFromAction = useCallback((prompt: string) => {
......@@ -401,7 +496,100 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', height: '100%' }}>
{/* 左侧历史面板 */}
<div style={{
width: historyOpen ? 220 : 0,
overflow: 'hidden',
transition: 'width 0.2s ease',
borderRight: historyOpen ? '1px solid #e5e7eb' : 'none',
background: '#fafbfc',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}>
<div style={{ minWidth: 220, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 面板头部 */}
<div style={{
padding: '10px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#374151' }}>历史会话</span>
<Tooltip title="新对话">
<Button
size="small"
type="primary"
icon={<PlusOutlined style={{ fontSize: 12 }} />}
onClick={handleNewChat}
style={{ borderRadius: 6, height: 26, width: 26, padding: 0 }}
/>
</Tooltip>
</div>
{/* 会话列表 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{historyLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin size="small" /></div>
) : historySessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 20, fontSize: 12, color: '#bfbfbf' }}>暂无历史会话</div>
) : (
historySessions.map(s => {
const isActive = s.session_id === sessionId;
return (
<div
key={s.session_id}
onClick={() => loadSession(s)}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: isActive ? '#e8f4fd' : 'transparent',
borderLeft: isActive ? '3px solid #3b82f6' : '3px solid transparent',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'flex-start',
gap: 6,
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = '#f0f2f5'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<MessageOutlined style={{ fontSize: 12, color: isActive ? '#3b82f6' : '#bfbfbf', marginTop: 2, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 12, color: isActive ? '#1d2129' : '#4e5969',
fontWeight: isActive ? 600 : 400,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{extractSummary(s.history)}
</div>
<div style={{ fontSize: 11, color: '#bfbfbf', marginTop: 2 }}>
{formatRelativeTime(s.updated_at)}
</div>
</div>
<Popconfirm
title="删除此会话?"
onConfirm={(e) => { e?.stopPropagation(); deleteSession(s.session_id); }}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<DeleteOutlined
onClick={e => e.stopPropagation()}
style={{ fontSize: 11, color: '#d9d9d9', flexShrink: 0, marginTop: 2 }}
onMouseEnter={e => { (e.target as HTMLElement).style.color = '#ff4d4f'; }}
onMouseLeave={e => { (e.target as HTMLElement).style.color = '#d9d9d9'; }}
/>
</Popconfirm>
</div>
);
})
)}
</div>
</div>
</div>
{/* 右侧聊天区 */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
{/* Header */}
<div style={{
padding: '8px 12px', borderBottom: '1px solid #f3f4f6',
......@@ -409,6 +597,16 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
background: role === 'patient' ? '#f0fdf4' : role === 'doctor' ? '#eff6ff' : '#faf5ff',
}}>
<span style={{ fontSize: 13, fontWeight: 500, color: '#374151' }}>{agentName}</span>
<div style={{ display: 'flex', gap: 2 }}>
<Tooltip title={historyOpen ? '收起历史' : '历史会话'}>
<Button
size="small"
type="text"
icon={<HistoryOutlined style={{ fontSize: 13 }} />}
onClick={() => setHistoryOpen(v => !v)}
style={{ color: historyOpen ? '#3b82f6' : '#6b7280' }}
/>
</Tooltip>
<Tooltip title="新对话">
<Button
size="small"
......@@ -419,6 +617,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
/>
</Tooltip>
</div>
</div>
{/* Patient context (doctor only) */}
{role === 'doctor' && patientContext?.patientName && (
......@@ -471,6 +670,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
)}
</div>
</div>
</div>
{/* Blinking cursor CSS */}
<style>{`
......
......@@ -15,6 +15,7 @@ import {
SelectOutlined,
} from '@ant-design/icons';
import { Rnd } from 'react-rnd';
import { usePathname } from 'next/navigation';
import { useUserStore } from '../../store/userStore';
import { useAIAssistStore } from '../../store/aiAssistStore';
import ChatPanel from './ChatPanel';
......@@ -26,6 +27,7 @@ const DRAG_HANDLE_CLASS = 'ai-widget-drag-handle';
const FloatContainer: React.FC = () => {
const { user } = useUserStore();
const pathname = usePathname();
const {
isOpen, isFullscreen, bounds,
patientContext,
......@@ -111,12 +113,12 @@ const FloatContainer: React.FC = () => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, isFullscreen, patientContext, openWidget, closeWidget, minimizeWidget, toggleFullscreen]);
// 直接使用 user.role 判断角色主题
const role: WidgetRole | null = useMemo(() => {
if (!user) return null;
if (user.role === 'patient') return 'patient';
if (user.role === 'doctor') return 'doctor';
if (user.role === 'admin') return 'admin';
return null;
// 检查 user.role 是否有对应主题,否则降级为 patient
if (user.role in ROLE_THEME) return user.role as WidgetRole;
return 'patient';
}, [user]);
const hidden = useMemo(() => {
......
......@@ -45,7 +45,7 @@ const iconMap: Record<string, React.ReactNode> = {
};
const colorMap: Record<string, string> = {
navigate: '#0D9488',
navigate: '#2B6DE8',
chat: '#52c41a',
followup: '#0891B2',
};
......
......@@ -45,7 +45,7 @@ import {
const TOOL_META: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
// 基础查询
query_drug: { label: '药品查询', color: '#52c41a', icon: <MedicineBoxOutlined /> },
query_medical_record: { label: '病历查询', color: '#0D9488', icon: <FileSearchOutlined /> },
query_medical_record: { label: '病历查询', color: '#2B6DE8', icon: <FileSearchOutlined /> },
query_symptom_knowledge: { label: '症状知识', color: '#0891B2', icon: <BulbOutlined /> },
recommend_department: { label: '科室推荐', color: '#13c2c2', icon: <ApartmentOutlined /> },
// 处方安全
......@@ -57,7 +57,7 @@ const TOOL_META: Record<string, { label: string; color: string; icon: React.Reac
write_knowledge: { label: '知识写入', color: '#52c41a', icon: <EditOutlined /> },
list_knowledge_collections:{ label: '知识库列表', color: '#2f54eb', icon: <UnorderedListOutlined /> },
// 导航 & 通知
navigate_page: { label: '页面导航', color: '#0D9488', icon: <CompassOutlined /> },
navigate_page: { label: '页面导航', color: '#2B6DE8', icon: <CompassOutlined /> },
send_notification: { label: '发送通知', color: '#fa541c', icon: <BellOutlined /> },
// Agent & 工作流
call_agent: { label: '调用Agent', color: '#0891B2', icon: <RobotOutlined /> },
......@@ -165,7 +165,7 @@ const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolName, arguments: argsSt
}).join(' ');
const statusIcon = status === 'running'
? <LoadingOutlined spin style={{ color: '#0D9488', fontSize: 12 }} />
? <LoadingOutlined spin style={{ color: '#2B6DE8', fontSize: 12 }} />
: status === 'success'
? <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 12 }} />
: <CloseCircleOutlined style={{ color: '#f5222d', fontSize: 12 }} />;
......
......@@ -25,7 +25,9 @@ import {
WalletOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { Tag } from 'antd';
import { Tag, message } from 'antd';
import { useUserStore } from '@/store/userStore';
import { validateNavigationPermission } from '@/lib/navigation-event';
interface ToolResultCardProps {
toolName: string;
......@@ -120,10 +122,17 @@ const InteractionCard: React.FC<{ data: Record<string, unknown> }> = ({ data })
const NavigateCard: React.FC<{ data: Record<string, unknown> }> = ({ data }) => {
const pageName = String(data.page_name || data.label || data.page || '-');
const route = String(data.route || data.page || '');
const userRole = useUserStore((s) => s.user?.role);
const userMenus = useUserStore((s) => s.menus);
const handleNavigate = () => {
if (route) {
const navPath = route.startsWith('/') ? route : '/' + route;
// 前端二次权限校验:基于用户菜单判断
if (userRole && !validateNavigationPermission(navPath, userRole, userMenus)) {
message.warning('您没有访问该页面的权限');
return;
}
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
}
};
......@@ -143,11 +152,11 @@ const NavigateCard: React.FC<{ data: Record<string, unknown> }> = ({ data }) =>
}
return (
<div style={{ padding: 8, background: '#F0FDFA', border: '1px solid #5EABA3', borderRadius: 6 }}>
<div style={{ padding: 8, background: '#F0F6FF', border: '1px solid #7BA4E8', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CompassOutlined style={{ color: '#0D9488' }} />
<span style={{ fontSize: 12, color: '#0D9488' }}>
<CompassOutlined style={{ color: '#2B6DE8' }} />
<span style={{ fontSize: 12, color: '#2B6DE8' }}>
<strong>{pageName}</strong>
</span>
</div>
......@@ -158,7 +167,7 @@ const NavigateCard: React.FC<{ data: Record<string, unknown> }> = ({ data }) =>
padding: '2px 10px',
fontSize: 11,
color: '#fff',
background: '#0D9488',
background: '#2B6DE8',
border: 'none',
borderRadius: 12,
cursor: 'pointer',
......
......@@ -62,17 +62,17 @@ export const ROLE_AGENT_NAME: Record<WidgetRole, string> = {
export const ROLE_THEME: Record<WidgetRole, { gradient: string; label: string; subtitle: string }> = {
patient: {
gradient: 'linear-gradient(135deg, #22c55e, #06b6d4)',
gradient: 'linear-gradient(135deg, #2B6DE8, #5B8FF9)',
label: 'AI智能助手',
subtitle: '预问诊 · 找医生 · 健康咨询',
},
doctor: {
gradient: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
gradient: 'linear-gradient(135deg, #2B9E6E, #52C41A)',
label: 'AI诊断助手',
subtitle: '辅助诊断 · 处方审核 · 病历生成',
},
admin: {
gradient: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
gradient: 'linear-gradient(135deg, #2B6DE8, #5B8FF9)',
label: 'AI管理助手',
subtitle: '运营分析 · 工作流 · 平台管理',
},
......
......@@ -84,7 +84,7 @@ const AdminLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) =>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space className="cursor-pointer">
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#2B6DE8' }} />
<Text className="text-white/90! text-xs!">{user.real_name || '管理员'}</Text>
</Space>
</Dropdown>
......
......@@ -46,7 +46,7 @@ const PatientLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) =
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, #0F766E 0%, #0D9488 50%, #14B8A6 100%)', borderBottom: 'none', boxShadow: '0 2px 8px rgba(0,21,41,0.12)', padding: '0 16px' }}>
style={{ background: 'linear-gradient(135deg, #1A56C4 0%, #2B6DE8 50%, #5B8FF9 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" />
......@@ -70,7 +70,7 @@ const PatientLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) =
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space className="cursor-pointer">
<Avatar size="small" src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Avatar size="small" src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#2B6DE8' }} />
<Text className="text-white/90! text-xs!">{user.real_name || user.phone}</Text>
</Space>
</Dropdown>
......
......@@ -126,7 +126,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
),
// 链接
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#0D9488', textDecoration: 'none' }}>
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#2B6DE8', textDecoration: 'none' }}>
{children}
</a>
),
......
......@@ -138,7 +138,7 @@ const PrescriptionSafetyCheck: React.FC<PrescriptionSafetyCheckProps> = ({
case 'danger':
return { color: '#f5222d', bg: '#fff1f0', border: '#ffa39e', icon: <CloseCircleOutlined />, label: '危险' };
default:
return { color: '#0D9488', bg: '#F0FDFA', border: '#5EABA3', icon: <SafetyOutlined />, label: '检查中' };
return { color: '#2B6DE8', bg: '#F0F6FF', border: '#5B8FF9', icon: <SafetyOutlined />, label: '检查中' };
}
};
......
......@@ -10,10 +10,10 @@ interface RoleRedirectProps {
/**
* 角色重定向组件
* 根据已登录用户的角色,自动跳转到对应端的首页
* 根据已登录用户的 homePath(由数据库角色表驱动),自动跳转到对应端的首页
*/
const RoleRedirect: React.FC<RoleRedirectProps> = () => {
const { isAuthenticated, user } = useUserStore();
const { isAuthenticated, user, homePath, menus } = useUserStore();
const router = useRouter();
useEffect(() => {
......@@ -22,20 +22,26 @@ const RoleRedirect: React.FC<RoleRedirectProps> = () => {
return;
}
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]);
// 优先使用 homePath,其次取菜单中第一个叶子节点的 path
const target = homePath || getFirstLeafPath(menus) || '/patient/home';
router.replace(target);
}, [isAuthenticated, user, homePath, menus, router]);
return null;
};
/** 从菜单树中取第一个叶子节点的 path */
function getFirstLeafPath(menus: any[]): string | null {
if (!menus || menus.length === 0) return null;
for (const menu of menus) {
if (menu.children && menu.children.length > 0) {
const childPath = getFirstLeafPath(menu.children);
if (childPath) return childPath;
} else if (menu.path) {
return menu.path;
}
}
return null;
}
export default RoleRedirect;
......@@ -46,7 +46,7 @@ interface NodeData {
const NODE_CONFIGS: { type: NodeType; label: string; icon: React.ReactNode; color: string; bgColor: string }[] = [
{ type: 'start', label: '开始', icon: <PlayCircleFilled />, color: '#52c41a', bgColor: '#f6ffed' },
{ type: 'end', label: '结束', icon: <StopFilled />, color: '#ff4d4f', bgColor: '#fff2f0' },
{ type: 'agent', label: 'Agent', icon: <RobotOutlined />, color: '#0D9488', bgColor: '#F0FDFA' },
{ type: 'agent', label: 'Agent', icon: <RobotOutlined />, color: '#2B6DE8', bgColor: '#F0F6FF' },
{ type: 'tool', label: 'Tool', icon: <ToolOutlined />, color: '#0891B2', bgColor: '#ECFEFF' },
{ type: 'condition', label: '条件分支', icon: <BranchesOutlined />, color: '#faad14', bgColor: '#fffbe6' },
{ type: 'human_review', label: '人工审核', icon: <UserOutlined />, color: '#eb2f96', bgColor: '#fff0f6' },
......@@ -83,11 +83,11 @@ const CustomNode = ({ data, selected }: { data: NodeData; selected: boolean }) =
style={{
padding: '12px 20px',
borderRadius: 8,
border: `2px solid ${selected ? '#0D9488' : config?.color || '#d9d9d9'}`,
border: `2px solid ${selected ? '#2B6DE8' : config?.color || '#d9d9d9'}`,
backgroundColor: config?.bgColor || '#fff',
minWidth: 140,
textAlign: 'center',
boxShadow: selected ? '0 0 0 2px rgba(13,148,136,0.2)' : '0 2px 8px rgba(0,0,0,0.08)',
boxShadow: selected ? '0 0 0 2px rgba(43,109,232,0.2)' : '0 2px 8px rgba(0,0,0,0.08)',
cursor: 'pointer',
transition: 'all 0.2s',
}}
......@@ -139,7 +139,7 @@ export default function VisualWorkflowEditor({
{ id: 'end', type: 'custom', position: { x: 250, y: 300 }, data: { label: '结束', nodeType: 'end' } },
];
const defaultEdges: Edge[] = initialEdges || [
{ id: 'e-start-end', source: 'start', target: 'end', animated: true, style: { stroke: '#0D9488' } },
{ id: 'e-start-end', source: 'start', target: 'end', animated: true, style: { stroke: '#2B6DE8' } },
];
const [nodes, setNodes, onNodesChange] = useNodesState(defaultNodes);
......@@ -150,7 +150,7 @@ export default function VisualWorkflowEditor({
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0D9488' } }, eds));
setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#2B6DE8' } }, eds));
},
[setEdges]
);
......@@ -368,7 +368,7 @@ export default function VisualWorkflowEditor({
<MiniMap
nodeColor={(node) => {
const config = NODE_CONFIGS.find(n => n.type === (node.data as NodeData).nodeType);
return config?.color || '#0D9488';
return config?.color || '#2B6DE8';
}}
style={{ backgroundColor: '#f0f0f0' }}
/>
......
......@@ -51,7 +51,7 @@ interface WorkflowEditorProps {
const NODE_TYPES: { type: NodeType; label: string; icon: React.ReactNode; color: string }[] = [
{ type: 'start', label: '开始', icon: <PlayCircleOutlined />, color: '#52c41a' },
{ type: 'end', label: '结束', icon: <DeleteOutlined />, color: '#ff4d4f' },
{ type: 'agent', label: 'Agent', icon: <RobotOutlined />, color: '#0D9488' },
{ type: 'agent', label: 'Agent', icon: <RobotOutlined />, color: '#2B6DE8' },
{ type: 'tool', label: 'Tool', icon: <ToolOutlined />, color: '#0891B2' },
{ type: 'condition', label: '条件分支', icon: <BranchesOutlined />, color: '#faad14' },
{ type: 'human_review', label: '人工审核', icon: <UserOutlined />, color: '#eb2f96' },
......
......@@ -46,36 +46,50 @@ export function emitNavigationEvent(pageCode: string, intent: string, params: Re
}
/**
* 校验导航路由是否在当前用户角色允许范围内
* 校验导航路由是否在当前用户菜单允许范围内
* @param route 目标路由
* @param userRole 用户角色
* @param userRole 用户角色(保留参数兼容性)
* @param userMenus 用户菜单列表(可选,从 userStore 传入)
* @returns 是否允许导航
*/
export function validateNavigationPermission(route: string, userRole: string): boolean {
if (!route || !userRole) return false;
export function validateNavigationPermission(route: string, userRole: string, userMenus?: any[]): boolean {
if (!route) return false;
// admin 可以访问所有页面
if (userRole === 'admin') return true;
// 检查路由前缀是否匹配用户角色
const normalizedRoute = route.startsWith('/') ? route : '/' + route;
if (userRole === 'doctor') {
return normalizedRoute.startsWith('/doctor') || normalizedRoute.startsWith('/public');
}
if (userRole === 'patient') {
return normalizedRoute.startsWith('/patient') || normalizedRoute.startsWith('/public');
// 公共路由始终允许
if (normalizedRoute.startsWith('/public')) return true;
// 如果有菜单数据,基于菜单判断
if (userMenus && userMenus.length > 0) {
const menuPaths = collectMenuPaths(userMenus);
// 检查目标路由是否匹配菜单中任一路径(支持子路径)
return menuPaths.some(mp => normalizedRoute === mp || normalizedRoute.startsWith(mp + '/'));
}
return false;
// 无菜单数据时放行(降级)
return true;
}
/** 从菜单树中收集所有 path */
function collectMenuPaths(menus: any[]): string[] {
const paths: string[] = [];
for (const menu of menus) {
if (menu.path) paths.push(menu.path);
if (menu.children && menu.children.length > 0) {
paths.push(...collectMenuPaths(menu.children));
}
}
return paths;
}
/**
* 执行 Agent 输出的导航指令(带权限二次校验)
* @param action 导航指令
* @param userRole 当前用户角色(可选,用于前端二次校验)
* @param userMenus 用户菜单列表(可选,用于基于菜单的权限校验)
*/
export function executeNavigationAction(action: NavigationAction, userRole?: string): boolean {
export function executeNavigationAction(action: NavigationAction, userRole?: string, userMenus?: any[]): boolean {
if (action.action === 'permission_denied') {
// 显示权限拒绝提示
if (action.message) {
......@@ -101,8 +115,8 @@ export function executeNavigationAction(action: NavigationAction, userRole?: str
if (route) {
if (!route.startsWith('/')) route = '/' + route;
// v16: 前端二次权限校验
if (userRole && !validateNavigationPermission(route, userRole)) {
// v16: 前端二次权限校验(基于用户菜单)
if (userRole && !validateNavigationPermission(route, userRole, userMenus)) {
message.warning(`您没有访问该页面的权限`);
console.warn(`[Navigation] 权限拒绝: 角色 ${userRole} 无法访问 ${route}`);
return false;
......
......@@ -4,13 +4,6 @@ import type { NextRequest } from 'next/server';
// 公开路由(不需要登录)
const publicPaths = ['/login', '/register'];
// 角色路由前缀映射
const rolePaths: Record<string, string[]> = {
patient: ['/patient'],
doctor: ['/doctor'],
admin: ['/admin'],
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
......
......@@ -9,8 +9,10 @@ interface UserState {
refreshToken: string | null;
isAuthenticated: boolean;
menus: Menu[];
setUser: (user: UserInfo, menus?: Menu[]) => void;
homePath: string;
setUser: (user: UserInfo, menus?: Menu[], homePath?: string) => void;
setMenus: (menus: Menu[]) => void;
setHomePath: (homePath: string) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
}
......@@ -23,11 +25,19 @@ export const useUserStore = create<UserState>()(
refreshToken: null,
isAuthenticated: false,
menus: [],
homePath: '',
setUser: (user, menus) => set({ user, isAuthenticated: true, menus: menus || [] }),
setUser: (user, menus, homePath) => set({
user,
isAuthenticated: true,
menus: menus || [],
...(homePath ? { homePath } : {}),
}),
setMenus: (menus) => set({ menus }),
setHomePath: (homePath) => set({ homePath }),
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
......@@ -43,6 +53,7 @@ export const useUserStore = create<UserState>()(
refreshToken: null,
isAuthenticated: false,
menus: [],
homePath: '',
});
},
}),
......@@ -53,7 +64,7 @@ export const useUserStore = create<UserState>()(
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
menus: state.menus,
homePath: state.homePath,
}),
}
)
......
......@@ -4,9 +4,9 @@ import type { ThemeConfig } from 'antd';
export const medicalTheme: ThemeConfig = {
token: {
// 主色调 - 医疗青绿
colorPrimary: '#0D9488',
colorPrimaryHover: '#14B8A6',
colorPrimaryActive: '#0F766E',
colorPrimary: '#2B6DE8',
colorPrimaryHover: '#5B8FF9',
colorPrimaryActive: '#1A56C4',
// 成功色 - 健康绿
colorSuccess: '#52c41a',
......@@ -24,11 +24,11 @@ export const medicalTheme: ThemeConfig = {
colorErrorActive: '#d9363e',
// 信息色
colorInfo: '#0D9488',
colorInfo: '#2B6DE8',
// 背景色
colorBgContainer: '#ffffff',
colorBgLayout: '#F8FAFB',
colorBgLayout: '#F5F7FA',
colorBgElevated: '#ffffff',
// 边框
......@@ -102,9 +102,9 @@ export const medicalTheme: ThemeConfig = {
// 表格
Table: {
borderRadius: 8,
headerBg: '#F0FDFA',
headerBg: '#F0F6FF',
headerColor: '#1d2129',
rowHoverBg: '#F0FDFA',
rowHoverBg: '#F0F6FF',
},
// 标签
Tag: {
......@@ -137,7 +137,7 @@ export const medicalTheme: ThemeConfig = {
},
// 进度条
Progress: {
defaultColor: '#0D9488',
defaultColor: '#2B6DE8',
},
// 徽标
Badge: {
......@@ -173,11 +173,11 @@ export const medicalTheme: ThemeConfig = {
},
// 加载
Spin: {
colorPrimary: '#0D9488',
colorPrimary: '#2B6DE8',
},
// 开关
Switch: {
colorPrimary: '#0D9488',
colorPrimary: '#2B6DE8',
},
// 日期选择器
DatePicker: {
......@@ -193,9 +193,9 @@ export const medicalTheme: ThemeConfig = {
// 医疗色彩系统
export const medicalColors = {
// 主色
primary: '#0D9488',
primaryLight: '#F0FDFA',
primaryDark: '#0F766E',
primary: '#2B6DE8',
primaryLight: '#F0F6FF',
primaryDark: '#1A56C4',
// 功能色
success: '#52c41a',
......@@ -204,8 +204,8 @@ export const medicalColors = {
warningLight: '#fffbe6',
error: '#ff4d4f',
errorLight: '#fff2f0',
info: '#0D9488',
infoLight: '#F0FDFA',
info: '#2B6DE8',
infoLight: '#F0F6FF',
// 中性色
text: '#1d2129',
......@@ -220,9 +220,9 @@ export const medicalColors = {
doctorBlue: '#1890ff',
patientGreen: '#52c41a',
nurseOrange: '#fa8c16',
adminPurple: '#0F766E',
adminPurple: '#1A56C4',
emergencyRed: '#f5222d',
healthCyan: '#0D9488',
healthCyan: '#2B6DE8',
};
// 间距系统
......
import * as Icons from '@ant-design/icons';
import React from 'react';
// 动态图标映射
export const getIconComponent = (iconName: string): React.ReactNode => {
if (!iconName) return null;
const IconComponent = (Icons as any)[iconName];
return IconComponent ? React.createElement(IconComponent) : null;
};
// 可用图标列表(用于菜单管理选择器)
export const AVAILABLE_ICONS = [
'DashboardOutlined',
'UserOutlined',
'TeamOutlined',
'MedicineBoxOutlined',
'ApartmentOutlined',
'SettingOutlined',
'FileSearchOutlined',
'FileTextOutlined',
'RobotOutlined',
'SafetyCertificateOutlined',
'ApiOutlined',
'DeploymentUnitOutlined',
'BookOutlined',
'CheckCircleOutlined',
'SafetyOutlined',
'FundOutlined',
'AppstoreOutlined',
'BellOutlined',
'HomeOutlined',
'VideoCameraOutlined',
'PayCircleOutlined',
'ShoppingCartOutlined',
'FolderOpenOutlined',
'SearchOutlined',
'MessageOutlined',
'CalendarOutlined',
'DollarOutlined',
'HeartOutlined',
];
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