Commit 859fb6ac authored by yuguo's avatar yuguo

fix

parent 6e652bf1
......@@ -37,7 +37,9 @@
"Bash(bash:*)",
"Bash(make run:*)",
"Bash(rm:*)",
"Bash(gh pr:*)"
"Bash(gh pr:*)",
"Bash(claude mcp:*)",
"WebSearch"
]
}
}
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@anthropic-ai/claude-code-mcp-playwright@latest"
],
"env": {}
},
"antd-components": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"antd-components-mcp@latest"
],
"env": {}
},
"figma": {
"type": "http",
"url": "https://mcp.figma.com/mcp"
},
"context7": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp@latest"
],
"env": {}
},
"21st-magic": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@21st-dev/magic@latest"
],
"env": {}
}
}
}
......@@ -14,14 +14,14 @@ func defaultAgentDefinitions() []model.AgentDefinition {
"query_symptom_knowledge", "recommend_department",
"search_medical_knowledge", "query_drug",
"query_medical_record", "generate_follow_up_plan",
"send_notification",
"send_notification", "navigate_page",
})
// 医生通用智能体 — 合并 diagnosis + prescription + follow_up 能力
doctorTools, _ := json.Marshal([]string{
"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge",
"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage",
"generate_follow_up_plan", "send_notification",
"generate_follow_up_plan", "send_notification", "navigate_page",
})
// 管理员通用智能体 — 合并 admin_assistant + general 管理能力
......@@ -29,7 +29,7 @@ func defaultAgentDefinitions() []model.AgentDefinition {
"eval_expression", "query_workflow_status", "call_agent",
"trigger_workflow", "request_human_review",
"list_knowledge_collections", "send_notification",
"query_drug", "search_medical_knowledge",
"query_drug", "search_medical_knowledge", "navigate_page",
})
return []model.AgentDefinition{
......@@ -52,7 +52,11 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 主动使用工具获取真实数据,不要凭空回答
- 不做确定性诊断,只提供参考建议
- 关注患者的用药依从性和健康状况变化
- 所有医疗建议仅供参考,请以专业医生判断为准`,
- 所有医疗建议仅供参考,请以专业医生判断为准
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如找医生、我的问诊、处方、健康档案等
- 当用户想查看某个页面时,直接调用 navigate_page 工具导航到对应页面`,
Tools: string(patientTools),
Config: "{}",
MaxIterations: 10,
......@@ -80,7 +84,11 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 基于循证医学原则提供建议
- 主动使用工具获取真实数据
- 对存在风险的处方要明确指出
- 所有建议仅供医生参考,请结合临床实际情况`,
- 所有建议仅供医生参考,请结合临床实际情况
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如工作台、问诊大厅、患者档案、排班管理等
- 当医生想查看某个页面时,直接调用 navigate_page 工具导航到对应页面`,
Tools: string(doctorTools),
Config: "{}",
MaxIterations: 10,
......@@ -106,7 +114,12 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 以简洁专业的方式回答管理员的问题
- 主动使用工具获取真实数据
- 提供可操作的建议和方案
- 用中文回答`,
- 用中文回答
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如医生管理、患者管理、科室管理、问诊管理等
- 当管理员想查看或操作某个页面时,直接调用 navigate_page 工具导航到对应页面
- 支持 open_add 操作自动打开新增弹窗(如新增医生、新增科室等)`,
Tools: string(adminTools),
Config: "{}",
MaxIterations: 10,
......
......@@ -150,11 +150,7 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf
route = fmt.Sprintf("%s/edit/%s", basePath, id)
}
case "open_add":
parentID := id
if parentID == "" {
parentID = "0"
}
route = fmt.Sprintf("%s/add/%s", basePath, parentID)
route = fmt.Sprintf("%s?action=add", basePath)
}
// 返回标准化导航指令
......
......@@ -333,7 +333,7 @@ export const adminApi = {
post<null>(`/admin/departments/${id}/delete`),
// === 系统日志 ===
getSystemLogs: (params: { page?: number; page_size?: number }) =>
getSystemLogs: (params: { keyword?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) =>
get<PaginatedResponse<SystemLog>>('/admin/logs', { params }),
// === 问诊管理 ===
......
......@@ -281,7 +281,7 @@ export const workflowApi = {
update: (id: number, data: Partial<WorkflowCreateParams>) =>
put<unknown>(`/admin/workflows/${id}`, data),
delete: (id: number) => del<null>(`/admin/workflows/${id}`),
delete: (workflowId: string | number) => del<null>(`/workflow/${workflowId}`),
publish: (id: number) => put<null>(`/admin/workflows/${id}/publish`, {}),
......
import { get, post, put, del } from './request';
import type { Consultation, ConsultMessage } from './consult';
import type { ToolCall } from './agent';
import type { Prescription } from './prescription';
// ==================== 医生端 API ====================
......@@ -190,7 +191,21 @@ export const doctorPortalApi = {
getPatientDetail: (patientId: string) =>
get<PatientDetail>(`/doctor-portal/patient/${patientId}/detail`),
// === 处方安全审核 ===
// === 处方管理 ===
createPrescription: (data: {
consult_id: string;
patient_id: string;
diagnosis: string;
remark?: string;
items: { medicine_name: string; specification: string; dosage: string; usage: string; frequency: string; days: number; quantity: number; unit: string; amount: number }[];
}) => post<Prescription>('/doctor-portal/prescription/create', data),
getDoctorPrescriptions: (params?: { page?: number; page_size?: number }) =>
get<{ list: Prescription[]; total: number }>('/doctor-portal/prescriptions', { params }),
getPrescriptionDetail: (id: string) =>
get<Prescription>(`/doctor-portal/prescription/${id}`),
checkPrescriptionSafety: (params: { patient_id: string; drugs: string[] }) =>
post<{
report: string;
......
import request from './request';
import { get, put } from './request';
export interface Notification {
id: string;
......@@ -13,14 +13,14 @@ export interface Notification {
export const notificationApi = {
list: (params?: { page?: number; page_size?: number }) =>
request.get<{ list: Notification[]; total: number }>('/notifications', { params }),
get<{ list: Notification[]; total: number }>('/notifications', { params }),
markRead: (id: string) =>
request.put(`/notifications/${id}/read`, {}),
put(`/notifications/${id}/read`, {}),
markAllRead: () =>
request.put('/notifications/read-all', {}),
put('/notifications/read-all', {}),
getUnreadCount: () =>
request.get<{ count: number }>('/notifications/unread-count'),
get<{ count: number }>('/notifications/unread-count'),
};
// 支付相关API
import request from './request';
import { get, post } from './request';
export interface PaymentOrder {
id: string;
......@@ -60,57 +60,57 @@ export interface WithdrawalRecord {
export const paymentApi = {
// 创建支付订单
createOrder: (data: { order_type: string; related_id: string; amount: number }) =>
request.post<PaymentOrder>('/payment/order/create', data),
post<PaymentOrder>('/payment/order/create', data),
// 获取订单详情
getOrderDetail: (orderId: string) =>
request.get<PaymentOrder>(`/payment/order/${orderId}`),
get<PaymentOrder>(`/payment/order/${orderId}`),
// 获取订单列表
getOrderList: (params?: { status?: string; page?: number; page_size?: number }) =>
request.get<{ list: PaymentOrder[]; total: number }>('/payment/orders', { params }),
get<{ list: PaymentOrder[]; total: number }>('/payment/orders', { params }),
// 支付订单
payOrder: (orderId: string, paymentMethod: string) =>
request.post<{ order_id: string; status: string; transaction_id: string; paid_at: string }>(
post<{ order_id: string; status: string; transaction_id: string; paid_at: string }>(
`/payment/order/${orderId}/pay`,
{ payment_method: paymentMethod }
),
// 查询支付状态
getPaymentStatus: (orderId: string) =>
request.get<{ status: string }>(`/payment/order/${orderId}/status`),
get<{ status: string }>(`/payment/order/${orderId}/status`),
// 取消订单
cancelOrder: (orderId: string) =>
request.post(`/payment/order/${orderId}/cancel`),
post(`/payment/order/${orderId}/cancel`),
// 确认收货
confirmDelivery: (orderId: string) =>
request.post(`/payment/order/${orderId}/confirm-delivery`, {}),
post(`/payment/order/${orderId}/confirm-delivery`, {}),
};
// 医生端收入API
export const incomeApi = {
// 获取收入统计
getStats: () =>
request.get<IncomeStats>('/doctor/income/stats'),
get<IncomeStats>('/doctor/income/stats'),
// 获取收入记录
getRecords: (params?: { start_date?: string; end_date?: string; page?: number; page_size?: number }) =>
request.get<{ list: IncomeRecord[]; total: number }>('/doctor/income/records', { params }),
get<{ list: IncomeRecord[]; total: number }>('/doctor/income/records', { params }),
// 获取月度账单
getMonthlyBills: () =>
request.get<MonthlyBill[]>('/doctor/income/monthly'),
get<MonthlyBill[]>('/doctor/income/monthly'),
// 申请提现
requestWithdraw: (data: { amount: number; bank_account?: string; bank_name?: string }) =>
request.post<WithdrawalRecord>('/doctor/income/withdraw', data),
post<WithdrawalRecord>('/doctor/income/withdraw', data),
// 获取提现记录
getWithdrawals: (params?: { page?: number; page_size?: number }) =>
request.get<{ list: WithdrawalRecord[]; total: number }>('/doctor/income/withdrawals', { params }),
get<{ list: WithdrawalRecord[]; total: number }>('/doctor/income/withdrawals', { params }),
};
export default paymentApi;
'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag, Spin, Popover, List } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
......@@ -91,8 +91,18 @@ function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f6fa' }} />}>
<AdminLayoutInner>{children}</AdminLayoutInner>
</Suspense>
);
}
function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [dynamicMenus, setDynamicMenus] = useState<MenuType[]>([]);
......@@ -119,24 +129,31 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
notificationApi.markAllRead().then(() => { setUnreadCount(0); setNotifications(n => n.map(i => ({ ...i, is_read: true }))); }).catch(() => {});
};
// 从 API 获取动态菜单
// 从 API 获取动态菜单(依赖 user,确保登录后才加载)
useEffect(() => {
if (!user) {
setMenuLoading(false);
return;
}
let cancelled = false;
setMenuLoading(true);
myMenuApi.getMenus()
.then(res => {
if (!cancelled) setDynamicMenus(res.data || []);
})
.catch(() => { /* 静默失败,使用空菜单 */ })
.catch((err) => {
console.error('加载菜单失败:', err);
})
.finally(() => { if (!cancelled) setMenuLoading(false); });
return () => { cancelled = true; };
}, []);
}, [user]);
// 转换为 Ant Design menu items
const menuItems = useMemo(() => convertMenuTree(dynamicMenus), [dynamicMenus]);
// 监听 AI 助手导航事件
// 监听 AI 助手导航事件(embed 模式下跳过,避免 iframe 内拦截)
useEffect(() => {
if (isEmbed) return;
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
......@@ -147,7 +164,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router]);
}, [router, isEmbed]);
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
......@@ -178,6 +195,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return findOpenKeys(dynamicMenus, currentPath);
}, [dynamicMenus, currentPath, collapsed]);
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f6fa' }}>{children}</div>;
}
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
......@@ -219,14 +240,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
/>
{menuLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px 0' }}>
<Spin size="small" />
</div>
) : (
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
/>
)}
</Sider>
<Layout style={{ marginLeft: collapsed ? 64 : 220, transition: 'margin-left 0.2s' }}>
......
......@@ -138,7 +138,7 @@ export default function SafetyPage() {
{
title: '敏感词/正则',
dataIndex: 'word',
render: (v, r) => (
render: (v: string, r: SafetyRule) => (
<Space>
<Text code>{v}</Text>
{r.is_regex && <Tag color="purple">正则</Tag>}
......@@ -149,7 +149,7 @@ export default function SafetyPage() {
title: '分类',
dataIndex: 'category',
width: 100,
render: (v) => {
render: (v: string) => {
const map: Record<string, string> = {
injection: 'red', medical_claim: 'orange', drug_promotion: 'yellow',
privacy: 'blue', toxicity: 'red',
......@@ -165,7 +165,7 @@ export default function SafetyPage() {
title: '级别',
dataIndex: 'level',
width: 80,
render: (v) => {
render: (v: string) => {
const map: Record<string, string> = { block: 'red', warn: 'orange', replace: 'blue' };
const labels: Record<string, string> = { block: '拦截', warn: '警告', replace: '替换' };
return <Tag color={map[v] ?? 'default'}>{labels[v] ?? v}</Tag>;
......@@ -175,7 +175,7 @@ export default function SafetyPage() {
title: '方向',
dataIndex: 'direction',
width: 90,
render: (v) => {
render: (v: string) => {
const labels: Record<string, string> = { input: '输入', output: '输出', both: '双向' };
return <Tag>{labels[v] ?? v}</Tag>;
},
......@@ -184,12 +184,12 @@ export default function SafetyPage() {
title: '状态',
dataIndex: 'status',
width: 80,
render: (v) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '启用' : '禁用'} />,
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '启用' : '禁用'} />,
},
{
title: '操作',
width: 120,
render: (_, record) => (
render: (_: unknown, record: SafetyRule) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openModal(record)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(record.id)}>
......@@ -201,21 +201,21 @@ export default function SafetyPage() {
];
const logColumns: ColumnsType<SafetyLog> = [
{ title: 'TraceID', dataIndex: 'trace_id', width: 180, render: (v) => <Text code style={{ fontSize: 11 }}>{v?.slice(0, 16)}...</Text> },
{ title: '方向', dataIndex: 'direction', width: 80, render: (v) => <Tag>{v === 'input' ? '输入' : '输出'}</Tag> },
{ title: 'TraceID', dataIndex: 'trace_id', width: 180, render: (v: string) => <Text code style={{ fontSize: 11 }}>{v?.slice(0, 16)}...</Text> },
{ title: '方向', dataIndex: 'direction', width: 80, render: (v: string) => <Tag>{v === 'input' ? '输入' : '输出'}</Tag> },
{
title: '动作',
dataIndex: 'action',
width: 80,
render: (v) => {
render: (v: string) => {
const map: Record<string, string> = { blocked: 'red', replaced: 'blue', warned: 'orange', passed: 'green' };
const labels: Record<string, string> = { blocked: '拦截', replaced: '替换', warned: '警告', passed: '通过' };
return <Tag color={map[v] ?? 'default'}>{labels[v] ?? v}</Tag>;
},
},
{ title: '原始内容', dataIndex: 'original_text', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 140, render: (v) => dayjs(v).format('MM-DD HH:mm:ss') },
];
{ title: '时间', dataIndex: 'created_at', width: 140, render: (v: string) => dayjs(v).format('MM-DD HH:mm:ss') },
] as ColumnsType<SafetyLog>;
return (
<div style={{ padding: 24 }}>
......@@ -270,9 +270,9 @@ export default function SafetyPage() {
key: 'rules',
label: '规则管理',
children: (
<Table
<Table<SafetyRule>
columns={ruleColumns}
dataSource={rulesData?.list ?? []}
dataSource={(rulesData?.list ?? []) as SafetyRule[]}
rowKey="id"
pagination={{
current: rulesPage,
......@@ -288,9 +288,9 @@ export default function SafetyPage() {
key: 'logs',
label: '过滤日志',
children: (
<Table
<Table<SafetyLog>
columns={logColumns}
dataSource={logsData?.list ?? []}
dataSource={(logsData?.list ?? []) as SafetyLog[]}
rowKey="id"
pagination={{
current: logsPage,
......
......@@ -17,7 +17,7 @@ import SkillsTab from './SkillsTab';
const { Text } = Typography;
export const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
knowledge: { color: 'blue', label: '知识库', icon: <BookOutlined /> },
recommendation: { color: 'cyan', label: '智能推荐', icon: <ThunderboltOutlined /> },
medical: { color: 'purple', label: '病历管理', icon: <HeartOutlined /> },
......
......@@ -142,7 +142,7 @@ export default function WorkflowsPage() {
}}>激活</Button>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.id);
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
fetchWorkflows();
}}>
......
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import React, { Suspense, useState, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag, Popover, List } from 'antd';
import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
......@@ -28,8 +28,18 @@ const menuItems = [
];
export default function DoctorLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f8ff' }} />}>
<DoctorLayoutInner>{children}</DoctorLayoutInner>
</Suspense>
);
}
function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const { user, logout } = useUserStore();
const [isOnline, setIsOnline] = useState(false);
const [collapsed, setCollapsed] = useState(false);
......@@ -89,6 +99,10 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
return match ? [match.key] : [];
};
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f8ff' }}>{children}</div>;
}
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
......
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import GlobalAIFloat from '@/components/GlobalAIFloat';
function AIFloatGuard() {
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
if (isEmbed) return null;
return <GlobalAIFloat />;
}
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="compact-ui">
{children}
<GlobalAIFloat />
<Suspense fallback={null}>
<AIFloatGuard />
</Suspense>
</div>
);
}
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import React, { Suspense, useState, useEffect } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag, Popover, List } from 'antd';
import {
HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined,
......@@ -50,8 +50,18 @@ const allRouteKeys = [
];
export default function PatientLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f8ff' }} />}>
<PatientLayoutInner>{children}</PatientLayoutInner>
</Suspense>
);
}
function PatientLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams.get('embed') === '1';
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
......@@ -112,6 +122,10 @@ export default function PatientLayout({ children }: { children: React.ReactNode
return keys;
};
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f8ff' }}>{children}</div>;
}
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
......
'use client';
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Tooltip } from 'antd';
import { Tooltip, Spin } from 'antd';
import {
RobotOutlined,
CloseOutlined,
......@@ -18,6 +18,7 @@ import { useAIAssistStore } from '../../store/aiAssistStore';
import ChatPanel from './ChatPanel';
import type { WidgetRole } from './types';
import { ROLE_THEME } from './types';
import { getRouteByPath } from '../../config/routes';
const DRAG_HANDLE_CLASS = 'ai-widget-drag-handle';
......@@ -31,6 +32,7 @@ const FloatContainer: React.FC = () => {
const [mounted, setMounted] = useState(false);
// 嵌入的业务页面URL(分屏模式)
const [embeddedUrl, setEmbeddedUrl] = useState<string | null>(null);
const [iframeLoading, setIframeLoading] = useState(false);
useEffect(() => { setMounted(true); }, []);
......@@ -41,8 +43,10 @@ const FloatContainer: React.FC = () => {
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
let path = detail.page;
if (!path.startsWith('/')) path = '/' + path;
// 设置嵌入URL并进入全屏分屏模式
setEmbeddedUrl(path);
// 设置嵌入URL并进入全屏分屏模式,追加 embed=1
const embedPath = path + (path.includes('?') ? '&' : '?') + 'embed=1';
setEmbeddedUrl(embedPath);
setIframeLoading(true);
if (!isFullscreen) {
toggleFullscreen();
}
......@@ -52,9 +56,19 @@ const FloatContainer: React.FC = () => {
return () => window.removeEventListener('ai-action', handleAIAction);
}, [isFullscreen, toggleFullscreen]);
// 解析嵌入页面的中文标题
const embeddedPageTitle = useMemo(() => {
if (!embeddedUrl) return '';
// 去掉 embed=1 等 query 参数来匹配路由
const pathOnly = embeddedUrl.split('?')[0];
const route = getRouteByPath(pathOnly);
return route?.name || pathOnly;
}, [embeddedUrl]);
// 关闭嵌入页面
const closeEmbedded = useCallback(() => {
setEmbeddedUrl(null);
setIframeLoading(false);
}, []);
// 键盘快捷键: Ctrl+K 打开/关闭 AI 助手
......@@ -239,7 +253,7 @@ const FloatContainer: React.FC = () => {
justifyContent: 'space-between',
}}>
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>
{embeddedUrl}
{embeddedPageTitle}
</span>
<div
onClick={closeEmbedded}
......@@ -261,16 +275,28 @@ const FloatContainer: React.FC = () => {
</div>
</div>
{/* iframe 嵌入业务系统 */}
<iframe
src={embeddedUrl}
style={{
flex: 1,
width: '100%',
border: 'none',
background: '#fff',
}}
title="业务页面"
/>
<div style={{ flex: 1, position: 'relative' }}>
{iframeLoading && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
background: '#f5f6fa', zIndex: 1,
}}>
<Spin tip="页面加载中..." />
</div>
)}
<iframe
src={embeddedUrl}
onLoad={() => setIframeLoading(false)}
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
}}
title={embeddedPageTitle || '业务页面'}
/>
</div>
</div>
)}
</div>
......
......@@ -59,12 +59,6 @@ const SuggestedActions: React.FC<SuggestedActionsProps> = ({ actions, onNavigate
if (path && onNavigate) {
onNavigate(path);
}
// 同时触发 ai-action 自定义事件(供 Layout 监听)
if (path) {
window.dispatchEvent(new CustomEvent('ai-action', {
detail: { action: 'navigate', page: path },
}));
}
} else if (action.type === 'chat' || action.type === 'followup') {
const prompt = action.prompt || action.label; // 如果没有 prompt,使用 label 作为提问内容
if (prompt && onSend) {
......
export * from './useAuth';
export * from './useVideoCall';
export * from './useAIChat';
export * from './useEmbedMode';
'use client';
import { useSearchParams } from 'next/navigation';
/** Detect if the page is loaded inside an iframe with ?embed=1 */
export function useEmbedMode(): boolean {
const searchParams = useSearchParams();
return searchParams.get('embed') === '1';
}
'use client';
import type { AppProps } from 'next/app';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App as AntdApp, ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
/**
* Pages Router _app wrapper.
* The src/pages/ directory contains React components that are imported by App Router pages.
* Next.js also treats them as Pages Router pages, so we need to provide necessary context.
*/
export default function PagesApp({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN}>
<AntdApp>
<Component {...pageProps} />
</AntdApp>
</ConfigProvider>
</QueryClientProvider>
);
}
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="zh-CN">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
......@@ -51,7 +51,13 @@ const AdminCompliancePage: React.FC = () => {
const fetchLogs = async (page = 1) => {
setLogsLoading(true);
try {
const res = await adminApi.getSystemLogs({ page, page_size: 10 });
const res = await adminApi.getSystemLogs({
page,
page_size: 10,
keyword: searchKeyword || undefined,
start_date: searchDateRange?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: searchDateRange?.[1]?.format('YYYY-MM-DD') || undefined,
});
setLogs(res.data.list || []);
setLogsTotal(res.data.total || 0);
} catch {
......
'use client';
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import {
Card, Table, Typography, Space, Button, Modal, Form, Input, InputNumber, message
} from 'antd';
......@@ -12,6 +13,7 @@ import type { Department } from '../../../api/doctor';
const { Text } = Typography;
const AdminDepartmentsPage: React.FC = () => {
const searchParams = useSearchParams();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [form] = Form.useForm();
......@@ -35,6 +37,13 @@ const AdminDepartmentsPage: React.FC = () => {
fetchDepartments();
}, []);
// ?action=add 自动打开添加弹窗
useEffect(() => {
if (searchParams.get('action') === 'add') {
handleAdd();
}
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAdd = () => {
setEditingDept(null);
form.resetFields();
......
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import {
Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message,
Input, Select, Form, Descriptions, Row, Col,
......@@ -23,6 +24,7 @@ const statusMap: Record<string, { color: string; text: string }> = {
};
const AdminDoctorsPage: React.FC = () => {
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [doctors, setDoctors] = useState<DoctorItem[]>([]);
const [total, setTotal] = useState(0);
......@@ -65,6 +67,13 @@ const AdminDoctorsPage: React.FC = () => {
fetchDoctors();
}, [fetchDoctors]);
// ?action=add 自动打开添加弹窗
useEffect(() => {
if (searchParams.get('action') === 'add') {
setAddModalVisible(true);
}
}, [searchParams]);
const handleSearch = () => {
setPage(1);
fetchDoctors();
......
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import {
Card, Table, Input, Select, Button, Space, Tag, Avatar, Modal, message,
Typography, Form, InputNumber, Row, Col,
......@@ -15,6 +16,7 @@ import type { UserInfo } from '../../../api/user';
const { Text } = Typography;
const AdminPatientsPage: React.FC = () => {
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [patients, setPatients] = useState<UserInfo[]>([]);
const [total, setTotal] = useState(0);
......@@ -45,6 +47,13 @@ const AdminPatientsPage: React.FC = () => {
fetchPatients();
}, [fetchPatients]);
// ?action=add 自动打开添加弹窗
useEffect(() => {
if (searchParams.get('action') === 'add') {
handleAdd();
}
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
setPage(1);
fetchPatients();
......
......@@ -82,8 +82,8 @@ const DoctorCertificationPage: React.FC = () => {
const res = await request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
file.url = res.data.url;
onSuccess(res.data);
file.url = res.data.data?.url || res.data.url;
onSuccess(res.data.data || res.data);
} catch (err) {
onError(err);
}
......
......@@ -191,6 +191,7 @@ const PatientList: React.FC<PatientListProps> = ({
// 筛选
const filtered = useMemo(() => {
if (!patients) return [];
if (!searchText.trim()) return patients;
const keyword = searchText.trim().toLowerCase();
return patients.filter(p =>
......
......@@ -25,9 +25,9 @@ const formatWaitingTime = (seconds: number) => {
};
const WaitingQueue: React.FC<WaitingQueueProps> = ({
waitingList,
inProgressList,
completedList,
waitingList = [],
inProgressList = [],
completedList = [],
activeConsultId,
onAccept,
onReject,
......
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space, Spin, message, Modal, Form, Input, InputNumber } from 'antd';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space, Spin, message, Modal, Form, Input, InputNumber, Select } from 'antd';
import {
UserOutlined,
EditOutlined,
......@@ -163,7 +163,7 @@ const DoctorProfilePage: React.FC = () => {
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="introduction" label="个人简介"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="specialties" label="擅长领域"><Input /></Form.Item>
<Form.Item name="specialties" label="擅长领域"><Select mode="tags" placeholder="输入后回车添加" /></Form.Item>
<Form.Item name="price" label="问诊价格(元)"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
......
......@@ -67,10 +67,11 @@ const PatientHomePage: React.FC = () => {
useEffect(() => {
// Try to get real platform stats from a public endpoint
request.get('/admin/dashboard/stats').then((res: any) => {
if (res.data) {
const stats = res.data?.data || res.data;
if (stats) {
setPlatformStats({
doctors: `${res.data.total_doctors || 0}`,
patients: `${res.data.total_users || 0}`,
doctors: `${stats.total_doctors || 0}`,
patients: `${stats.total_users || 0}`,
response: '< 5 分钟',
});
}
......
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