feat(admin): Complete Phase 3.5.1-3.5.4 Prompt Management System (83%)

Summary:
- Implement Prompt management infrastructure and core services
- Build admin portal frontend with light theme
- Integrate CodeMirror 6 editor for non-technical users

Phase 3.5.1: Infrastructure Setup
- Create capability_schema for Prompt storage
- Add prompt_templates and prompt_versions tables
- Add prompt:view/edit/debug/publish permissions
- Migrate RVW prompts to database (RVW_EDITORIAL, RVW_METHODOLOGY)

Phase 3.5.2: PromptService Core
- Implement gray preview logic (DRAFT for debuggers, ACTIVE for users)
- Module-level debug control (setDebugMode)
- Handlebars template rendering
- Variable extraction and validation (extractVariables, validateVariables)
- Three-level disaster recovery (database -> cache -> hardcoded fallback)

Phase 3.5.3: Management API
- 8 RESTful endpoints (/api/admin/prompts/*)
- Permission control (PROMPT_ENGINEER can edit, SUPER_ADMIN can publish)

Phase 3.5.4: Frontend Management UI
- Build admin portal architecture (AdminLayout, OrgLayout)
- Add route system (/admin/*, /org/*)
- Implement PromptListPage (filter, search, debug switch)
- Implement PromptEditor (CodeMirror 6 simplified for clinical users)
- Implement PromptEditorPage (edit, save, publish, test, version history)

Technical Details:
- Backend: 6 files, ~2044 lines (prompt.service.ts 596 lines)
- Frontend: 9 files, ~1735 lines (PromptEditorPage.tsx 399 lines)
- CodeMirror 6: Line numbers, auto-wrap, variable highlight, search, undo/redo
- Chinese-friendly: 15px font, 1.8 line-height, system fonts

Next Step: Phase 3.5.5 - Integrate RVW module with PromptService

Tested: Backend API tests passed (8/8), Frontend pending user testing
Status: Ready for Phase 3.5.5 RVW integration
This commit is contained in:
2026-01-11 21:25:16 +08:00
parent cdfbc9927a
commit 5523ef36ea
297 changed files with 15914 additions and 1266 deletions

View File

@@ -2,10 +2,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './framework/auth'
import { PermissionProvider } from './framework/permission'
import { RouteGuard } from './framework/router'
import MainLayout from './framework/layout/MainLayout'
import AdminLayout from './framework/layout/AdminLayout'
import OrgLayout from './framework/layout/OrgLayout'
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage'
import AdminDashboard from './pages/admin/AdminDashboard'
import OrgDashboard from './pages/org/OrgDashboard'
import PromptListPage from './pages/admin/PromptListPage'
import PromptEditorPage from './pages/admin/PromptEditorPage'
import { MODULES } from './framework/modules/moduleRegistry'
/**
@@ -13,12 +21,17 @@ import { MODULES } from './framework/modules/moduleRegistry'
*
* @description
* - ConfigProvider: Ant Design国际化配置
* - QueryClientProvider: React Query状态管理Week 2 新增)⭐
* - PermissionProvider: 权限管理系统Week 2 Day 7新增
* - RouteGuard: 路由守卫保护Week 2 Day 7新增
* - QueryClientProvider: React Query状态管理
* - AuthProvider: JWT认证管理 🆕
* - PermissionProvider: 权限管理系统
* - RouteGuard: 路由守卫保护
* - BrowserRouter: 前端路由
*
* @version Week 2 Day 1 - 添加React Query支持
* 路由结构:
* - /login - 通用登录页(个人用户)
* - /t/{tenantCode}/login - 租户专属登录页
* - / - 首页(需要认证)
* - /{module}/* - 业务模块(需要认证+权限)
*/
// 创建React Query客户端
@@ -26,9 +39,9 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分钟
gcTime: 1000 * 60 * 10, // 10分钟原cacheTime
retry: 1, // 失败重试1次
refetchOnWindowFocus: false, // 窗口聚焦时不自动重新获取
gcTime: 1000 * 60 * 10, // 10分钟
retry: 1,
refetchOnWindowFocus: false,
},
},
})
@@ -36,39 +49,68 @@ const queryClient = new QueryClient({
function App() {
return (
<ConfigProvider locale={zhCN}>
{/* React Query状态管理 */}
<QueryClientProvider client={queryClient}>
{/* 权限提供者:提供全局权限状态 */}
<PermissionProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainLayout />}>
{/* 首页 */}
<Route index element={<HomePage />} />
{/* 动态加载模块路由 - 应用路由守卫保护 ⭐ */}
{MODULES.map(module => (
<Route
key={module.id}
path={`${module.path}/*`}
element={
// 为每个模块添加路由守卫
<RouteGuard
requiredVersion={module.requiredVersion}
moduleName={module.name}
>
<module.component />
</RouteGuard>
}
/>
))}
{/* 404重定向 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</PermissionProvider>
{/* 认证提供者:JWT Token管理 */}
<AuthProvider>
{/* 权限提供者:模块级权限管理 */}
<PermissionProvider>
<BrowserRouter>
<Routes>
{/* 登录页面(无需认证) */}
<Route path="/login" element={<LoginPage />} />
<Route path="/t/:tenantCode/login" element={<LoginPage />} />
{/* 业务应用端 /app/* */}
<Route path="/" element={<MainLayout />}>
{/* 首页 */}
<Route index element={<HomePage />} />
{/* 动态加载模块路由 */}
{MODULES.map(module => (
<Route
key={module.id}
path={`${module.path}/*`}
element={
<RouteGuard
requiredVersion={module.requiredVersion}
moduleName={module.name}
>
<module.component />
</RouteGuard>
}
/>
))}
</Route>
{/* 运营管理端 /admin/* */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<AdminDashboard />} />
{/* Prompt 管理 */}
<Route path="prompts" element={<PromptListPage />} />
<Route path="prompts/:code" element={<PromptEditorPage />} />
{/* 其他模块(待开发) */}
<Route path="tenants" element={<div className="text-center py-20">🚧 ...</div>} />
<Route path="users" element={<div className="text-center py-20">🚧 ...</div>} />
<Route path="system" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>
{/* 机构管理端 /org/* */}
<Route path="/org" element={<OrgLayout />}>
<Route index element={<Navigate to="/org/dashboard" replace />} />
<Route path="dashboard" element={<OrgDashboard />} />
<Route path="users" element={<div className="text-center py-20">🚧 ...</div>} />
<Route path="departments" element={<div className="text-center py-20">🚧 /...</div>} />
<Route path="usage" element={<div className="text-center py-20">🚧 使...</div>} />
<Route path="audit" element={<div className="text-center py-20">🚧 ...</div>} />
</Route>
{/* 404重定向 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</PermissionProvider>
</AuthProvider>
</QueryClientProvider>
</ConfigProvider>
)

View File

@@ -0,0 +1,206 @@
/**
* 认证上下文
*
* 提供全局认证状态管理
*/
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import type { AuthContextType, AuthUser, UserRole, ChangePasswordRequest } from './types';
import * as authApi from './api';
// 创建上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider Props
interface AuthProviderProps {
children: ReactNode;
}
/**
* 认证Provider
*/
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 初始化:检查本地存储的用户信息
useEffect(() => {
const initAuth = async () => {
try {
const savedUser = authApi.getSavedUser();
const token = authApi.getAccessToken();
if (savedUser && token) {
// 检查Token是否过期
if (authApi.isTokenExpired()) {
// 尝试刷新Token
try {
await authApi.refreshAccessToken();
const freshUser = await authApi.getCurrentUser();
setUser(freshUser);
} catch {
// 刷新失败,清除状态
authApi.clearTokens();
setUser(null);
}
} else {
setUser(savedUser);
}
}
} catch (err) {
console.error('Auth init error:', err);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
/**
* 密码登录
*/
const loginWithPassword = useCallback(async (phone: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const result = await authApi.loginWithPassword({ phone, password });
setUser(result.user);
} catch (err) {
const message = err instanceof Error ? err.message : '登录失败';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 验证码登录
*/
const loginWithCode = useCallback(async (phone: string, code: string) => {
setIsLoading(true);
setError(null);
try {
const result = await authApi.loginWithCode({ phone, code });
setUser(result.user);
} catch (err) {
const message = err instanceof Error ? err.message : '登录失败';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
/**
* 发送验证码
*/
const sendVerificationCode = useCallback(async (
phone: string,
type: 'LOGIN' | 'RESET_PASSWORD' = 'LOGIN'
) => {
setError(null);
try {
await authApi.sendVerificationCode(phone, type);
} catch (err) {
const message = err instanceof Error ? err.message : '发送验证码失败';
setError(message);
throw err;
}
}, []);
/**
* 登出
*/
const logout = useCallback(() => {
authApi.logout();
setUser(null);
setError(null);
}, []);
/**
* 修改密码
*/
const changePassword = useCallback(async (request: ChangePasswordRequest) => {
setError(null);
try {
await authApi.changePassword(request);
// 修改成功后更新用户状态
if (user) {
setUser({ ...user, isDefaultPassword: false });
authApi.saveUser({ ...user, isDefaultPassword: false });
}
} catch (err) {
const message = err instanceof Error ? err.message : '修改密码失败';
setError(message);
throw err;
}
}, [user]);
/**
* 刷新Token
*/
const refreshToken = useCallback(async () => {
try {
await authApi.refreshAccessToken();
} catch (err) {
logout();
throw err;
}
}, [logout]);
/**
* 检查权限
*/
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false;
// SUPER_ADMIN拥有所有权限
if (user.role === 'SUPER_ADMIN') return true;
return user.permissions.includes(permission);
}, [user]);
/**
* 检查角色
*/
const hasRole = useCallback((...roles: UserRole[]): boolean => {
if (!user) return false;
return roles.includes(user.role);
}, [user]);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
error,
loginWithPassword,
loginWithCode,
sendVerificationCode,
logout,
changePassword,
refreshToken,
hasPermission,
hasRole,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* 使用认证上下文的Hook
*/
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export { AuthContext };

View File

@@ -0,0 +1,242 @@
/**
* 认证API模块
*/
import type {
ApiResponse,
LoginResponse,
AuthUser,
TokenInfo,
PasswordLoginRequest,
CodeLoginRequest,
ChangePasswordRequest,
} from './types';
// API基础URL
const API_BASE = '/api/v1/auth';
/**
* 存储Token到localStorage
*/
export function saveTokens(tokens: TokenInfo): void {
localStorage.setItem('accessToken', tokens.accessToken);
localStorage.setItem('refreshToken', tokens.refreshToken);
localStorage.setItem('tokenExpiresAt', String(Date.now() + tokens.expiresIn * 1000));
}
/**
* 从localStorage获取Token
*/
export function getAccessToken(): string | null {
return localStorage.getItem('accessToken');
}
export function getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
/**
* 清除Token
*/
export function clearTokens(): void {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tokenExpiresAt');
localStorage.removeItem('user');
}
/**
* 存储用户信息
*/
export function saveUser(user: AuthUser): void {
localStorage.setItem('user', JSON.stringify(user));
}
/**
* 获取存储的用户信息
*/
export function getSavedUser(): AuthUser | null {
const userStr = localStorage.getItem('user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
/**
* 检查Token是否过期
*/
export function isTokenExpired(): boolean {
const expiresAt = localStorage.getItem('tokenExpiresAt');
if (!expiresAt) return true;
return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期
}
/**
* 创建带认证的fetch
*/
async function authFetch<T>(
url: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const token = getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || '请求失败');
}
return data;
}
/**
* 密码登录
*/
export async function loginWithPassword(request: PasswordLoginRequest): Promise<LoginResponse> {
const response = await authFetch<LoginResponse>(`${API_BASE}/login/password`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success || !response.data) {
throw new Error(response.message || '登录失败');
}
// 保存Token和用户信息
saveTokens(response.data.tokens);
saveUser(response.data.user);
return response.data;
}
/**
* 验证码登录
*/
export async function loginWithCode(request: CodeLoginRequest): Promise<LoginResponse> {
const response = await authFetch<LoginResponse>(`${API_BASE}/login/code`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success || !response.data) {
throw new Error(response.message || '登录失败');
}
// 保存Token和用户信息
saveTokens(response.data.tokens);
saveUser(response.data.user);
return response.data;
}
/**
* 发送验证码
*/
export async function sendVerificationCode(
phone: string,
type: 'LOGIN' | 'RESET_PASSWORD' = 'LOGIN'
): Promise<{ expiresIn: number }> {
const response = await authFetch<{ message: string; expiresIn: number }>(
`${API_BASE}/verification-code`,
{
method: 'POST',
body: JSON.stringify({ phone, type }),
}
);
if (!response.success || !response.data) {
throw new Error(response.message || '发送失败');
}
return { expiresIn: response.data.expiresIn };
}
/**
* 获取当前用户信息
*/
export async function getCurrentUser(): Promise<AuthUser> {
const response = await authFetch<AuthUser>(`${API_BASE}/me`);
if (!response.success || !response.data) {
throw new Error(response.message || '获取用户信息失败');
}
// 更新本地存储
saveUser(response.data);
return response.data;
}
/**
* 修改密码
*/
export async function changePassword(request: ChangePasswordRequest): Promise<void> {
const response = await authFetch<{ message: string }>(`${API_BASE}/change-password`, {
method: 'POST',
body: JSON.stringify(request),
});
if (!response.success) {
throw new Error(response.message || '修改密码失败');
}
}
/**
* 刷新Token
*/
export async function refreshAccessToken(): Promise<TokenInfo> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('无RefreshToken');
}
const response = await fetch(`${API_BASE}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const data = await response.json();
if (!response.ok || !data.success) {
clearTokens();
throw new Error(data.message || '刷新Token失败');
}
// 保存新Token
saveTokens(data.data);
return data.data;
}
/**
* 登出
*/
export async function logout(): Promise<void> {
try {
await authFetch(`${API_BASE}/logout`, { method: 'POST' });
} catch {
// 忽略登出API错误
} finally {
clearTokens();
}
}

View File

@@ -0,0 +1,8 @@
/**
* 认证模块导出
*/
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
export * from './types';
export * from './api';

View File

@@ -0,0 +1,101 @@
/**
* 认证模块类型定义
*/
/** 用户角色 */
export type UserRole =
| 'SUPER_ADMIN'
| 'PROMPT_ENGINEER'
| 'HOSPITAL_ADMIN'
| 'PHARMA_ADMIN'
| 'DEPARTMENT_ADMIN'
| 'USER';
/** 租户类型 */
export type TenantType = 'HOSPITAL' | 'PHARMA' | 'INTERNAL' | 'PUBLIC';
/** 用户信息 */
export interface AuthUser {
id: string;
phone: string;
name: string;
email?: string | null;
role: UserRole;
tenantId: string;
tenantCode?: string;
tenantName?: string;
departmentId?: string | null;
departmentName?: string | null;
isDefaultPassword: boolean;
permissions: string[];
}
/** Token信息 */
export interface TokenInfo {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
/** 登录响应 */
export interface LoginResponse {
user: AuthUser;
tokens: TokenInfo;
}
/** 密码登录请求 */
export interface PasswordLoginRequest {
phone: string;
password: string;
}
/** 验证码登录请求 */
export interface CodeLoginRequest {
phone: string;
code: string;
}
/** 修改密码请求 */
export interface ChangePasswordRequest {
oldPassword?: string;
newPassword: string;
confirmPassword: string;
}
/** API响应 */
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
/** 认证状态 */
export interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
/** 认证上下文类型 */
export interface AuthContextType extends AuthState {
/** 密码登录 */
loginWithPassword: (phone: string, password: string) => Promise<void>;
/** 验证码登录 */
loginWithCode: (phone: string, code: string) => Promise<void>;
/** 发送验证码 */
sendVerificationCode: (phone: string, type?: 'LOGIN' | 'RESET_PASSWORD') => Promise<void>;
/** 登出 */
logout: () => void;
/** 修改密码 */
changePassword: (request: ChangePasswordRequest) => Promise<void>;
/** 刷新Token */
refreshToken: () => Promise<void>;
/** 检查权限 */
hasPermission: (permission: string) => boolean;
/** 检查角色 */
hasRole: (...roles: UserRole[]) => boolean;
}

View File

@@ -0,0 +1,236 @@
import { Suspense, useState } from 'react'
import { Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { Spin, Layout, Menu, Avatar, Dropdown, Badge } from 'antd'
import {
DashboardOutlined,
CodeOutlined,
TeamOutlined,
SettingOutlined,
UserOutlined,
LogoutOutlined,
SwapOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { useAuth } from '../auth'
import ErrorBoundary from '../modules/ErrorBoundary'
const { Header, Sider, Content } = Layout
// 运营管理端主色:翠绿
const PRIMARY_COLOR = '#10b981'
/**
* 运营管理端布局方案A全浅色
*
* @description
* - 白色侧边栏 + 翠绿强调色
* - 浅灰内容区,信息清晰
* - 权限检查SUPER_ADMIN / PROMPT_ENGINEER
*/
const AdminLayout = () => {
const { isAuthenticated, isLoading, user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
// 加载中
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// 权限检查:只有 SUPER_ADMIN 和 PROMPT_ENGINEER 可访问
const allowedRoles = ['SUPER_ADMIN', 'PROMPT_ENGINEER']
if (!allowedRoles.includes(user?.role || '')) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4"> SUPER_ADMIN PROMPT_ENGINEER </p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 text-white rounded hover:opacity-90"
style={{ background: PRIMARY_COLOR }}
>
</button>
</div>
</div>
)
}
// 侧边栏菜单
const menuItems: MenuProps['items'] = [
{
key: '/admin/dashboard',
icon: <DashboardOutlined />,
label: '运营概览',
},
{
key: '/admin/prompts',
icon: <CodeOutlined />,
label: 'Prompt管理',
},
{
key: '/admin/tenants',
icon: <TeamOutlined />,
label: '租户管理',
},
{
key: '/admin/users',
icon: <UserOutlined />,
label: '用户管理',
},
{
key: '/admin/system',
icon: <SettingOutlined />,
label: '系统配置',
},
]
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{
key: 'switch-app',
icon: <SwapOutlined />,
label: '切换到业务端',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
]
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
logout()
navigate('/login')
} else if (key === 'switch-app') {
navigate('/')
}
}
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key)
}
// 获取当前选中的菜单项
const selectedKey = menuItems.find(item =>
location.pathname.startsWith(item?.key as string)
)?.key as string || '/admin/dashboard'
return (
<Layout className="h-screen">
{/* 侧边栏 - 白色 */}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{ background: '#fff' }}
width={220}
className="shadow-sm"
>
{/* Logo */}
<div
className="h-16 flex items-center justify-center cursor-pointer border-b border-gray-100"
onClick={() => navigate('/admin/dashboard')}
>
<span className="text-2xl"></span>
{!collapsed && (
<span className="ml-2 font-bold" style={{ color: PRIMARY_COLOR }}>
</span>
)}
</div>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
style={{
borderRight: 'none',
}}
/>
</Sider>
<Layout>
{/* 顶部栏 - 白色 */}
<Header
className="flex items-center justify-between shadow-sm"
style={{ background: '#fff', padding: '0 24px' }}
>
{/* 折叠按钮 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="text-gray-500 hover:text-gray-700 text-xl"
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
{/* 右侧工具栏 */}
<div className="flex items-center gap-4">
{/* 通知 */}
<Badge count={0} size="small">
<BellOutlined className="text-gray-500 hover:text-gray-700 text-lg cursor-pointer" />
</Badge>
{/* 用户 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-1 rounded hover:bg-gray-50">
<Avatar
size="small"
icon={<UserOutlined />}
style={{ background: PRIMARY_COLOR }}
/>
<div className="flex flex-col">
<span className="text-gray-700 text-sm">{user?.name || '管理员'}</span>
<span className="text-xs" style={{ color: PRIMARY_COLOR }}>{user?.role}</span>
</div>
</div>
</Dropdown>
</div>
</Header>
{/* 主内容区 - 浅灰 */}
<Content
className="overflow-auto p-6"
style={{ background: '#f5f5f5' }}
>
<ErrorBoundary moduleName="运营管理">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Spin size="large" />
</div>
}
>
<Outlet />
</Suspense>
</ErrorBoundary>
</Content>
</Layout>
</Layout>
)
}
export default AdminLayout

View File

@@ -1,27 +1,44 @@
import { Suspense } from 'react'
import { Outlet } from 'react-router-dom'
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { Spin } from 'antd'
import TopNavigation from './TopNavigation'
import ErrorBoundary from '../modules/ErrorBoundary'
import { useAuth } from '../auth'
/**
* 主布局组件
*
* @description
* - 认证检查:未登录重定向到登录页
* - 顶部导航栏
* - 错误边界保护 ⭐ Week 2 Day 7 新增
* - 错误边界保护
* - 懒加载支持Suspense
* - 主内容区Outlet
*
* @version Week 2 Day 7 - 任务17集成错误边界
*/
const MainLayout = () => {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
// 加载中:显示全屏加载
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录:重定向到登录页
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return (
<div className="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* 顶部导航 */}
<TopNavigation />
{/* 主内容区 - 添加错误边界保护 */}
{/* 主内容区 - 添加错误边界保护 */}
<div className="flex-1 flex flex-col overflow-hidden">
<ErrorBoundary moduleName="主应用">
<Suspense

View File

@@ -0,0 +1,259 @@
import { Suspense, useState } from 'react'
import { Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { Spin, Layout, Menu, Avatar, Dropdown, Badge } from 'antd'
import {
DashboardOutlined,
TeamOutlined,
ApartmentOutlined,
BarChartOutlined,
AuditOutlined,
UserOutlined,
LogoutOutlined,
SwapOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { useAuth } from '../auth'
import ErrorBoundary from '../modules/ErrorBoundary'
const { Header, Sider, Content } = Layout
// 机构管理端主色:深蓝
const PRIMARY_COLOR = '#003a8c'
/**
* 机构管理端布局方案A全浅色
*
* @description
* - 白色侧边栏 + 深蓝强调色
* - 浅灰内容区,信息清晰
* - 权限检查HOSPITAL_ADMIN / PHARMA_ADMIN / DEPARTMENT_ADMIN
*/
const OrgLayout = () => {
const { isAuthenticated, isLoading, user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
// 加载中
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<Spin size="large" tip="加载中..." />
</div>
)
}
// 未登录
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// 权限检查:机构管理员
const allowedRoles = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN']
if (!allowedRoles.includes(user?.role || '')) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">🚫</div>
<h2 className="text-xl mb-2 text-gray-800">访</h2>
<p className="text-gray-500 mb-4"></p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 text-white rounded hover:opacity-90"
style={{ background: PRIMARY_COLOR }}
>
</button>
</div>
</div>
)
}
// 获取机构类型
const isHospital = user?.role === 'HOSPITAL_ADMIN' || user?.role === 'DEPARTMENT_ADMIN'
// 侧边栏菜单
const menuItems: MenuProps['items'] = [
{
key: '/org/dashboard',
icon: <DashboardOutlined />,
label: '管理概览',
},
{
key: '/org/users',
icon: <TeamOutlined />,
label: '用户管理',
},
{
key: '/org/departments',
icon: <ApartmentOutlined />,
label: isHospital ? '科室管理' : '部门管理',
},
{
key: '/org/usage',
icon: <BarChartOutlined />,
label: '使用统计',
},
{
key: '/org/audit',
icon: <AuditOutlined />,
label: '审计日志',
},
]
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{
key: 'switch-app',
icon: <SwapOutlined />,
label: '切换到业务端',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
]
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
logout()
navigate('/login')
} else if (key === 'switch-app') {
navigate('/')
}
}
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key)
}
// 获取当前选中的菜单项
const selectedKey = menuItems.find(item =>
location.pathname.startsWith(item?.key as string)
)?.key as string || '/org/dashboard'
// 获取机构名称
const orgName = (user as any)?.tenant?.name || (isHospital ? '医院管理中心' : '企业管理中心')
// 角色显示名称映射
const roleMap: Record<string, string> = {
'HOSPITAL_ADMIN': '医院管理员',
'PHARMA_ADMIN': '企业管理员',
'DEPARTMENT_ADMIN': '科室主任',
'SUPER_ADMIN': '超级管理员',
'PROMPT_ENGINEER': 'Prompt工程师',
'USER': '普通用户',
}
const roleDisplayName = roleMap[user?.role || ''] || user?.role
return (
<Layout className="h-screen">
{/* 侧边栏 - 白色 */}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{ background: '#fff' }}
width={220}
className="shadow-sm"
>
{/* Logo */}
<div
className="h-16 flex items-center justify-center cursor-pointer border-b border-gray-100"
onClick={() => navigate('/org/dashboard')}
>
<span className="text-2xl">{isHospital ? '🏥' : '🏢'}</span>
{!collapsed && (
<span
className="ml-2 font-bold text-sm truncate max-w-[140px]"
style={{ color: PRIMARY_COLOR }}
>
{orgName}
</span>
)}
</div>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
style={{
borderRight: 'none',
}}
/>
</Sider>
<Layout>
{/* 顶部栏 - 白色 */}
<Header
className="flex items-center justify-between shadow-sm"
style={{ background: '#fff', padding: '0 24px' }}
>
{/* 折叠按钮 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="text-gray-500 hover:text-gray-700 text-xl"
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
{/* 右侧工具栏 */}
<div className="flex items-center gap-4">
{/* 通知 */}
<Badge count={0} size="small">
<BellOutlined className="text-gray-500 hover:text-gray-700 text-lg cursor-pointer" />
</Badge>
{/* 用户 */}
<Dropdown
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
placement="bottomRight"
>
<div className="flex items-center gap-2 cursor-pointer px-3 py-1 rounded hover:bg-gray-50">
<Avatar
size="small"
icon={<UserOutlined />}
style={{ background: PRIMARY_COLOR }}
/>
<div className="flex flex-col">
<span className="text-gray-700 text-sm">{user?.name || '管理员'}</span>
<span className="text-xs" style={{ color: PRIMARY_COLOR }}>
{roleDisplayName}
</span>
</div>
</div>
</Dropdown>
</div>
</Header>
{/* 主内容区 - 浅灰 */}
<Content
className="overflow-auto p-6"
style={{ background: '#f5f5f5' }}
>
<ErrorBoundary moduleName="机构管理">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Spin size="large" />
</div>
}
>
<Outlet />
</Suspense>
</ErrorBoundary>
</Content>
</Layout>
</Layout>
)
}
export default OrgLayout

View File

@@ -1,9 +1,17 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { Dropdown, Avatar, Tooltip } from 'antd'
import { UserOutlined, LogoutOutlined, SettingOutlined, LockOutlined } from '@ant-design/icons'
import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
LockOutlined,
ControlOutlined,
BankOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { getAvailableModules } from '../modules/moduleRegistry'
import { usePermission } from '../permission'
import { useAuth } from '../auth'
/**
* 顶部导航栏组件
@@ -18,6 +26,7 @@ import { usePermission } from '../permission'
const TopNavigation = () => {
const navigate = useNavigate()
const location = useLocation()
const { user: authUser, logout: authLogout } = useAuth()
const { user, checkModulePermission, logout } = usePermission()
// 获取用户有权访问的模块列表(权限过滤)⭐ 新增
@@ -28,7 +37,12 @@ const TopNavigation = () => {
location.pathname.startsWith(module.path)
)
// 用户菜单
// 检查用户权限,决定显示哪些切换入口
const userRole = authUser?.role || ''
const canAccessAdmin = ['SUPER_ADMIN', 'PROMPT_ENGINEER'].includes(userRole)
const canAccessOrg = ['HOSPITAL_ADMIN', 'PHARMA_ADMIN', 'DEPARTMENT_ADMIN', 'SUPER_ADMIN'].includes(userRole)
// 用户菜单 - 动态构建
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
@@ -40,6 +54,18 @@ const TopNavigation = () => {
icon: <SettingOutlined />,
label: '设置',
},
// 切换入口 - 根据权限显示
...(canAccessOrg || canAccessAdmin ? [{ type: 'divider' as const }] : []),
...(canAccessOrg ? [{
key: 'switch-org',
icon: <BankOutlined />,
label: '切换到机构管理',
}] : []),
...(canAccessAdmin ? [{
key: 'switch-admin',
icon: <ControlOutlined />,
label: '切换到运营管理',
}] : []),
{
type: 'divider',
},
@@ -54,8 +80,13 @@ const TopNavigation = () => {
// 处理用户菜单点击
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
authLogout()
logout()
navigate('/')
navigate('/login')
} else if (key === 'switch-admin') {
navigate('/admin/dashboard')
} else if (key === 'switch-org') {
navigate('/org/dashboard')
} else {
navigate(`/user/${key}`)
}

View File

@@ -1,34 +1,15 @@
import { createContext, useState, useCallback, ReactNode } from 'react'
import { createContext, useCallback, ReactNode, useMemo } from 'react'
import { useAuth } from '../auth'
import { UserInfo, PermissionContextType, checkVersionLevel, UserVersion } from './types'
/**
* 权限上下文
*
* @description 提供全局权限状态管理
* @version Week 2 Day 7 - 任务17
*
* 注意当前阶段Week 2用户信息为硬编码方便开发测试
* 后续计划Week 2 Day 8-9 对接后端JWT认证
* 已对接 AuthContext从 JWT 认证中获取用户信息
*/
/**
* 模拟用户数据(开发阶段使用)
*
* 🔧 开发说明:
* - 当前硬编码为 premium 权限,可以访问所有模块
* - 方便开发和测试所有功能
* - 后续将从后端 JWT token 中解析真实用户信息
*/
const MOCK_USER: UserInfo = {
id: 'test-user-001',
name: '测试研究员',
email: 'test@example.com',
version: 'premium', // 👈 硬编码为最高权限
avatar: null,
isTrial: false,
trialEndsAt: null,
}
/**
* 创建权限上下文
*/
@@ -42,23 +23,41 @@ interface PermissionProviderProps {
}
export const PermissionProvider = ({ children }: PermissionProviderProps) => {
// 当前用户状态(开发阶段使用模拟数据)
const [user, setUser] = useState<UserInfo | null>(MOCK_USER)
const { user: authUser, isAuthenticated, logout: authLogout } = useAuth()
// 将 AuthUser 转换为 UserInfo兼容原有权限系统
const user: UserInfo | null = useMemo(() => {
if (!authUser) return null
// 根据角色映射到版本等级
// SUPER_ADMIN, PROMPT_ENGINEER → premium
// HOSPITAL_ADMIN, PHARMA_ADMIN → advanced
// USER → professional
let version: UserVersion = 'professional'
if (authUser.role === 'SUPER_ADMIN' || authUser.role === 'PROMPT_ENGINEER') {
version = 'premium'
} else if (authUser.role === 'HOSPITAL_ADMIN' || authUser.role === 'PHARMA_ADMIN' || authUser.role === 'DEPARTMENT_ADMIN') {
version = 'advanced'
}
return {
id: authUser.id,
name: authUser.name,
email: authUser.email || null,
version,
avatar: null,
isTrial: false,
trialEndsAt: null,
}
}, [authUser])
/**
* 检查模块权限
* @param requiredVersion 所需权限等级
* @returns 是否有权限访问
*/
const checkModulePermission = useCallback(
(requiredVersion?: UserVersion): boolean => {
// 未登录用户无权限
if (!user) return false
// 没有权限要求,允许访问
if (!requiredVersion) return true
// 检查权限等级
return checkVersionLevel(user.version, requiredVersion)
},
[user]
@@ -66,19 +65,12 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {
/**
* 检查功能权限
* @param feature 功能标识
* @returns 是否有权限使用该功能
*
* TODO: 后续可以基于功能列表进行更细粒度的权限控制
*/
const checkFeaturePermission = useCallback(
(feature: string): boolean => {
if (!user) return false
// 当前简化实现premium用户拥有所有功能
if (user.version === 'premium') return true
// 后续可以扩展为基于功能配置表的权限检查
// 后续可基于功能配置表进行更细粒度的权限控制
console.log('Feature permission check:', feature)
return true
},
@@ -86,17 +78,22 @@ export const PermissionProvider = ({ children }: PermissionProviderProps) => {
)
/**
* 退出登录
* 退出登录委托给AuthContext
*/
const logout = useCallback(() => {
setUser(null)
// TODO: 清除后端session/token
console.log('User logged out')
authLogout()
}, [authLogout])
/**
* setUser兼容性保留实际不使用
*/
const setUser = useCallback(() => {
console.warn('setUser is deprecated, use AuthContext instead')
}, [])
const value: PermissionContextType = {
user,
isAuthenticated: !!user,
isAuthenticated,
checkModulePermission,
checkFeaturePermission,
setUser,

View File

@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth'
import { usePermission } from '../permission'
import PermissionDenied from './PermissionDenied'
import type { UserVersion } from '../permission'
@@ -9,28 +10,11 @@ import type { UserVersion } from '../permission'
*
* @description
* 保护需要特定权限的路由防止用户通过URL直接访问无权限页面
* 这是权限控制的"第二道防线"第一道是TopNavigation的过滤
*
* @version Week 2 Day 7 - 任务17
*
* @example
* ```tsx
* <Route
* path="/literature/*"
* element={
* <RouteGuard requiredVersion="advanced" moduleName="AI智能文献">
* <ASLModule />
* </RouteGuard>
* }
* />
* ```
*
* 工作原理:
* 1. 用户访问 /literature 路由
* 2. RouteGuard 检查用户权限
* 3. 如果有权限 → 渲染子组件
* 4. 如果无权限 → 显示 PermissionDenied 页面
* 5. 如果未登录 → 重定向到登录页(后续实现)
* 检查顺序:
* 1. 检查是否已登录(未登录→重定向到登录页)
* 2. 检查权限等级无权限→显示PermissionDenied
* 3. 有权限→渲染子组件
*/
interface RouteGuardProps {
@@ -50,21 +34,25 @@ const RouteGuard = ({
moduleName,
redirectToHome = false,
}: RouteGuardProps) => {
const { user, isAuthenticated, checkModulePermission } = usePermission()
const location = useLocation()
const { isAuthenticated, isLoading } = useAuth()
const { user, checkModulePermission } = usePermission()
// 1. 检查是否登录(后续实现真实认证
// 加载中显示空白或可以显示loading
if (isLoading) {
return null
}
// 1. 检查是否登录
if (!isAuthenticated) {
// TODO: 后续实现真实的登录流程
// 当前阶段用户默认已登录MOCK_USER
console.warn('用户未登录,应该重定向到登录页')
// return <Navigate to="/login" replace />
// 保存当前路径,登录后跳转回来
return <Navigate to="/login" state={{ from: location }} replace />
}
// 2. 检查权限等级
const hasPermission = checkModulePermission(requiredVersion)
if (!hasPermission) {
// 记录无权限访问尝试(用于后续分析和转化优化)
console.log('🔒 权限不足:', {
module: moduleName,
requiredVersion,
@@ -73,12 +61,10 @@ const RouteGuard = ({
timestamp: new Date().toISOString(),
})
// 如果配置了重定向,直接返回首页
if (redirectToHome) {
return <Navigate to="/" replace />
}
// 显示无权限页面(推荐,引导用户升级)
return (
<PermissionDenied
moduleName={moduleName}

View File

@@ -558,5 +558,6 @@ export default FulltextDetailDrawer;

View File

@@ -151,5 +151,6 @@ export const useAssets = (activeTab: AssetTabType) => {

View File

@@ -141,5 +141,6 @@ export const useRecentTasks = () => {

View File

@@ -340,5 +340,6 @@ export default DropnaDialog;

View File

@@ -425,5 +425,6 @@ export default MetricTimePanel;

View File

@@ -311,5 +311,6 @@ export default PivotPanel;

View File

@@ -111,5 +111,6 @@ export function useSessionStatus({

View File

@@ -103,5 +103,6 @@ export interface DataStats {

View File

@@ -99,5 +99,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw';

View File

@@ -220,3 +220,4 @@ export const documentSelectionApi = {

View File

@@ -288,3 +288,4 @@ export default KnowledgePage;

View File

@@ -226,3 +226,4 @@ export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({

View File

@@ -43,3 +43,4 @@ export interface BatchTemplate {

View File

@@ -128,3 +128,4 @@ export function formatTime(dateStr: string): string {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
}

View File

@@ -121,3 +121,4 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
);
}

View File

@@ -41,3 +41,4 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti
);
}

View File

@@ -64,3 +64,4 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC
);
}

View File

@@ -54,3 +54,4 @@ export default function Header({ onUpload }: HeaderProps) {
);
}

View File

@@ -108,3 +108,4 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
);
}

View File

@@ -36,3 +36,4 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:
);
}

View File

@@ -71,3 +71,4 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:
);
}

View File

@@ -13,3 +13,4 @@ export { default as MethodologyReport } from './MethodologyReport';
export { default as ReportDetail } from './ReportDetail';
export { default as TaskDetail } from './TaskDetail';

View File

@@ -282,3 +282,4 @@ export default function Dashboard() {
);
}

View File

@@ -231,3 +231,4 @@
}
}

View File

@@ -0,0 +1,367 @@
/**
* 登录页面
*
* 支持两种登录方式:
* 1. 手机号 + 密码
* 2. 手机号 + 验证码
*
* 路由:
* - /login - 通用登录(个人用户)
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
*/
import { useState, useEffect } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../framework/auth';
import type { ChangePasswordRequest } from '../framework/auth';
const { Title, Text, Paragraph } = Typography;
const { TabPane } = Tabs;
// 租户配置类型
interface TenantConfig {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
}
// 默认配置
const DEFAULT_CONFIG: TenantConfig = {
name: 'AI临床研究平台',
primaryColor: '#1890ff',
systemName: 'AI临床研究平台',
};
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { tenantCode } = useParams<{ tenantCode?: string }>();
const {
loginWithPassword,
loginWithCode,
sendVerificationCode,
isLoading,
error,
user,
changePassword,
} = useAuth();
const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState<'password' | 'code'>('password');
const [countdown, setCountdown] = useState(0);
const [tenantConfig, setTenantConfig] = useState<TenantConfig>(DEFAULT_CONFIG);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordForm] = Form.useForm();
// 获取租户配置
useEffect(() => {
if (tenantCode) {
// TODO: 从API获取租户配置
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
.then(res => res.json())
.then(data => {
if (data.success && data.data) {
setTenantConfig(data.data);
}
})
.catch(() => {
// 使用默认配置
});
}
}, [tenantCode]);
// 验证码倒计时
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
// 登录成功后检查是否需要修改密码
useEffect(() => {
if (user && user.isDefaultPassword) {
setShowPasswordModal(true);
} else if (user) {
// 登录成功,跳转
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
}
}, [user, navigate, location]);
// 发送验证码
const handleSendCode = async () => {
try {
const phone = form.getFieldValue('phone');
if (!phone) {
message.error('请输入手机号');
return;
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.error('请输入正确的手机号');
return;
}
await sendVerificationCode(phone, 'LOGIN');
message.success('验证码已发送');
setCountdown(60);
} catch (err) {
message.error(err instanceof Error ? err.message : '发送失败');
}
};
// 提交登录
const handleSubmit = async (values: any) => {
try {
if (activeTab === 'password') {
await loginWithPassword(values.phone, values.password);
} else {
await loginWithCode(values.phone, values.code);
}
message.success('登录成功');
} catch (err) {
message.error(err instanceof Error ? err.message : '登录失败');
}
};
// 修改密码
const handleChangePassword = async (values: ChangePasswordRequest) => {
try {
await changePassword(values);
message.success('密码修改成功');
setShowPasswordModal(false);
// 跳转到首页
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
} catch (err) {
message.error(err instanceof Error ? err.message : '修改密码失败');
}
};
// 跳过修改密码
const handleSkipPassword = () => {
setShowPasswordModal(false);
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${tenantConfig.primaryColor}15 0%, ${tenantConfig.primaryColor}05 100%)`,
padding: '20px',
}}>
<Card
style={{
width: '100%',
maxWidth: 420,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
borderRadius: 16,
}}
bodyStyle={{ padding: '40px 32px' }}
>
{/* Logo和标题 */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
{tenantConfig.logo ? (
<img
src={tenantConfig.logo}
alt={tenantConfig.name}
style={{ height: 48, marginBottom: 16 }}
/>
) : (
<div style={{
width: 64,
height: 64,
borderRadius: 16,
background: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
}}>
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
)}
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
{tenantConfig.systemName}
</Title>
{tenantCode && (
<Text type="secondary">{tenantConfig.name}</Text>
)}
</div>
{/* 错误提示 */}
{error && (
<Alert
message={error}
type="error"
showIcon
style={{ marginBottom: 24 }}
closable
/>
)}
{/* 登录表单 */}
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
size="large"
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as 'password' | 'code')}
centered
>
<TabPane tab="密码登录" key="password" />
<TabPane tab="验证码登录" key="code" />
</Tabs>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="手机号"
maxLength={11}
/>
</Form.Item>
{activeTab === 'password' ? (
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
) : (
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为6位数字' },
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="验证码"
maxLength={6}
style={{ flex: 1 }}
/>
<Button
onClick={handleSendCode}
disabled={countdown > 0}
style={{ width: 120 }}
>
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 16 }}>
<Button
type="primary"
htmlType="submit"
block
loading={isLoading}
style={{
height: 44,
borderRadius: 8,
background: tenantConfig.primaryColor,
}}
>
</Button>
</Form.Item>
</Form>
{/* 底部信息 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
© 2026 · AI临床研究平台
</Text>
</div>
</Card>
{/* 修改默认密码弹窗 */}
<Modal
title="修改默认密码"
open={showPasswordModal}
footer={null}
closable={false}
maskClosable={false}
>
<Paragraph type="warning">
使
</Paragraph>
<Form
form={passwordForm}
onFinish={handleChangePassword}
layout="vertical"
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={handleSkipPassword}>
</Button>
<Button type="primary" htmlType="submit" loading={isLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Card, Row, Col, Statistic, Table, Tag } from 'antd'
import { useNavigate } from 'react-router-dom'
import {
TeamOutlined,
FileTextOutlined,
CloudServerOutlined,
ApiOutlined,
} from '@ant-design/icons'
// 运营管理端主色
const PRIMARY_COLOR = '#10b981'
/**
* 运营管理端 - 概览页(浅色主题)
*/
const AdminDashboard = () => {
const navigate = useNavigate()
// 模拟数据
const stats = [
{ title: '活跃租户', value: 12, icon: <TeamOutlined />, color: PRIMARY_COLOR },
{ title: 'Prompt模板', value: 8, icon: <FileTextOutlined />, color: '#3b82f6' },
{ title: 'API调用/今日', value: 1234, icon: <ApiOutlined />, color: '#f59e0b' },
{ title: '系统状态', value: '正常', icon: <CloudServerOutlined />, color: PRIMARY_COLOR },
]
const recentActivities = [
{ key: 1, time: '10分钟前', action: '发布Prompt', target: 'RVW_EDITORIAL', user: '张工程师' },
{ key: 2, time: '1小时前', action: '新增租户', target: '北京协和医院', user: '李运营' },
{ key: 3, time: '2小时前', action: '用户开通', target: '王医生', user: '系统' },
{ key: 4, time: '3小时前', action: '配额调整', target: '武田制药', user: '李运营' },
]
const columns = [
{ title: '时间', dataIndex: 'time', key: 'time', width: 100 },
{ title: '操作', dataIndex: 'action', key: 'action' },
{ title: '对象', dataIndex: 'target', key: 'target' },
{ title: '操作人', dataIndex: 'user', key: 'user' },
]
return (
<div className="space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-2"></h1>
<p className="text-gray-500"> · AI临床研究平台运营管理中心</p>
</div>
{/* 统计卡片 */}
<Row gutter={16}>
{stats.map((stat, index) => (
<Col span={6} key={index}>
<Card className="hover:shadow-lg transition-shadow">
<Statistic
title={<span className="text-gray-500">{stat.title}</span>}
value={stat.value}
prefix={<span style={{ color: stat.color }}>{stat.icon}</span>}
/>
</Card>
</Col>
))}
</Row>
{/* 快捷操作 */}
<Card title="快捷操作">
<div className="flex gap-4">
<button
className="px-4 py-2 text-white rounded hover:opacity-90 transition"
style={{ background: PRIMARY_COLOR }}
onClick={() => navigate('/admin/prompts')}
>
🔧 Prompt管理
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
onClick={() => navigate('/admin/tenants')}
>
👥
</button>
<button
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 transition"
onClick={() => navigate('/admin/users')}
>
👤
</button>
</div>
</Card>
{/* 最近活动 */}
<Card title="最近活动">
<Table
dataSource={recentActivities}
columns={columns}
pagination={false}
size="small"
/>
</Card>
{/* 系统状态 */}
<Row gutter={16}>
<Col span={12}>
<Card title="服务状态">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">API </span>
<Tag color="success"></Tag>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Tag color="success"></Tag>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">LLM </span>
<Tag color="success">线</Tag>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Tag color="success"></Tag>
</div>
</div>
</Card>
</Col>
<Col span={12}>
<Card title="Prompt 调试状态">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Tag color="default"></Tag>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">稿 Prompt</span>
<span className="text-gray-800 font-medium">0 </span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<span className="text-gray-800 font-medium">0 </span>
</div>
</div>
</Card>
</Col>
</Row>
</div>
)
}
export default AdminDashboard

View File

@@ -0,0 +1,398 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Button,
Space,
Tag,
message,
Modal,
Input,
Descriptions,
Timeline,
Alert,
Spin,
} from 'antd'
import {
ArrowLeftOutlined,
SaveOutlined,
RocketOutlined,
LockOutlined,
} from '@ant-design/icons'
import { useAuth } from '../../framework/auth'
import PromptEditor from './components/PromptEditor'
import {
fetchPromptDetail,
saveDraft,
publishPrompt,
testRender,
type PromptDetail,
} from './api/promptApi'
const { TextArea } = Input
// 运营管理端主色
const PRIMARY_COLOR = '#10b981'
/**
* Prompt 编辑器页面
*/
const PromptEditorPage = () => {
const { code } = useParams<{ code: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
const [prompt, setPrompt] = useState<PromptDetail | null>(null)
const [content, setContent] = useState('')
const [hasChanges, setHasChanges] = useState(false)
const [changelogModalVisible, setChangelogModalVisible] = useState(false)
const [testVariables, setTestVariables] = useState<Record<string, string>>({})
const [testResult, setTestResult] = useState('')
// 权限检查
const canPublish = user?.role === 'SUPER_ADMIN'
// 加载 Prompt 详情
const loadPromptDetail = async () => {
if (!code) return
setLoading(true)
try {
const data = await fetchPromptDetail(code)
setPrompt(data)
// 加载最新版本的内容
const latestVersion = data.versions[0]
if (latestVersion) {
setContent(latestVersion.content)
}
} catch (error: any) {
message.error(error.message || '加载失败')
navigate('/admin/prompts')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPromptDetail()
}, [code])
// 内容变化
const handleContentChange = (newContent: string) => {
setContent(newContent)
setHasChanges(true)
}
// 保存草稿
const handleSaveDraft = async (changelog?: string) => {
if (!code) return
setSaving(true)
try {
await saveDraft(
code,
content,
prompt?.versions[0]?.modelConfig,
changelog
)
message.success('草稿已保存')
setHasChanges(false)
setChangelogModalVisible(false)
// 重新加载
await loadPromptDetail()
} catch (error: any) {
message.error(error.message || '保存失败')
} finally {
setSaving(false)
}
}
// 发布
const handlePublish = async () => {
if (!code) return
if (!canPublish) {
message.warning('需要 SUPER_ADMIN 权限才能发布')
return
}
Modal.confirm({
title: '确认发布',
content: '发布后,新版本将对所有用户生效。是否继续?',
okText: '发布',
okType: 'primary',
cancelText: '取消',
onOk: async () => {
setPublishing(true)
try {
await publishPrompt(code)
message.success('发布成功')
await loadPromptDetail()
} catch (error: any) {
message.error(error.message || '发布失败')
} finally {
setPublishing(false)
}
},
})
}
// 测试渲染
const handleTestRender = async () => {
try {
const result = await testRender(content, testVariables)
setTestResult(result.rendered)
message.success('渲染成功')
} catch (error: any) {
message.error(error.message || '渲染失败')
}
}
if (loading || !prompt) {
return (
<div className="flex items-center justify-center h-96">
<Spin size="large" tip="加载中..." />
</div>
)
}
const latestVersion = prompt.versions[0]
const isDraft = latestVersion?.status === 'DRAFT'
return (
<div className="space-y-6">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/admin/prompts')}
>
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-800">
{prompt.code}
</h1>
<Tag color={isDraft ? 'warning' : 'success'}>
{latestVersion?.status || 'ARCHIVED'}
</Tag>
<Tag>v{latestVersion?.version || 0}</Tag>
</div>
<p className="text-gray-500 mt-1">{prompt.name}</p>
</div>
</div>
<Space>
<Button
icon={<SaveOutlined />}
onClick={() => setChangelogModalVisible(true)}
disabled={!hasChanges}
loading={saving}
>
稿
</Button>
<Button
type="primary"
icon={<RocketOutlined />}
onClick={handlePublish}
disabled={!isDraft || !canPublish}
loading={publishing}
style={{
background: canPublish ? PRIMARY_COLOR : undefined,
borderColor: canPublish ? PRIMARY_COLOR : undefined,
}}
>
{canPublish ? '发布' : <><LockOutlined /> SUPER_ADMIN</>}
</Button>
</Space>
</div>
{/* 未保存提示 */}
{hasChanges && (
<Alert
message="您有未保存的更改"
description="请保存草稿后再离开页面"
type="warning"
showIcon
closable
/>
)}
{/* 主内容区 */}
<div className="grid grid-cols-3 gap-6">
{/* 左侧:编辑器 */}
<div className="col-span-2 space-y-6">
<Card title="📝 Prompt 内容">
<PromptEditor
value={content}
onChange={handleContentChange}
onSave={() => setChangelogModalVisible(true)}
/>
</Card>
{/* 测试渲染 */}
<Card
title="🧪 测试渲染"
extra={
<Button
type="primary"
size="small"
onClick={handleTestRender}
disabled={!prompt.variables || prompt.variables.length === 0}
>
</Button>
}
>
{prompt.variables && prompt.variables.length > 0 ? (
<div className="space-y-4">
{prompt.variables.map(varName => (
<div key={varName}>
<label className="text-sm text-gray-600">{varName}:</label>
<Input
value={testVariables[varName] || ''}
onChange={e => setTestVariables({
...testVariables,
[varName]: e.target.value,
})}
placeholder={`输入 ${varName} 的测试值`}
/>
</div>
))}
{testResult && (
<div className="mt-4">
<div className="text-sm text-gray-600 mb-2">:</div>
<div className="bg-gray-50 p-4 rounded border">
<pre className="whitespace-pre-wrap text-sm">{testResult}</pre>
</div>
</div>
)}
</div>
) : (
<div className="text-center text-gray-400 py-8">
Prompt
</div>
)}
</Card>
</div>
{/* 右侧:信息面板 */}
<div className="col-span-1 space-y-6">
{/* 基本信息 */}
<Card title="⚙️ 配置">
<Descriptions column={1} size="small">
<Descriptions.Item label="模块">
<Tag color="blue">{prompt.module}</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={isDraft ? 'warning' : 'success'}>
{latestVersion?.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="版本">
v{latestVersion?.version || 0}
</Descriptions.Item>
<Descriptions.Item label="描述">
{prompt.description || '-'}
</Descriptions.Item>
</Descriptions>
{latestVersion?.modelConfig && (
<>
<div className="my-3 border-t"></div>
<div className="text-sm font-medium text-gray-700 mb-2">📊 </div>
<Descriptions column={1} size="small">
<Descriptions.Item label="Model">
{latestVersion.modelConfig.model}
</Descriptions.Item>
<Descriptions.Item label="Temperature">
{latestVersion.modelConfig.temperature || 0.3}
</Descriptions.Item>
</Descriptions>
</>
)}
</Card>
{/* 变量列表 */}
<Card title="🔤 变量列表">
{prompt.variables && prompt.variables.length > 0 ? (
<div className="space-y-2">
{prompt.variables.map(varName => (
<div
key={varName}
className="px-3 py-2 bg-blue-50 rounded text-blue-700 font-mono text-sm"
>
{'{{' + varName + '}}'}
</div>
))}
</div>
) : (
<div className="text-center text-gray-400 py-4">
</div>
)}
</Card>
{/* 版本历史 */}
<Card title="📜 版本历史">
<Timeline>
{prompt.versions.map(version => (
<Timeline.Item
key={version.id}
color={
version.status === 'ACTIVE' ? 'green' :
version.status === 'DRAFT' ? 'orange' : 'gray'
}
>
<div className="text-sm">
<div className="font-medium">
v{version.version}
<Tag className="ml-2 text-xs">{version.status}</Tag>
</div>
<div className="text-gray-500 text-xs mt-1">
{new Date(version.createdAt).toLocaleString('zh-CN')}
</div>
{version.changelog && (
<div className="text-gray-600 mt-1">{version.changelog}</div>
)}
</div>
</Timeline.Item>
))}
</Timeline>
</Card>
</div>
</div>
{/* 保存草稿对话框 */}
<Modal
title="保存草稿"
open={changelogModalVisible}
onOk={() => {
const changelog = (document.getElementById('changelog-input') as HTMLTextAreaElement)?.value
handleSaveDraft(changelog)
}}
onCancel={() => setChangelogModalVisible(false)}
confirmLoading={saving}
>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-600">:</label>
<TextArea
id="changelog-input"
rows={4}
placeholder="简要说明本次修改的内容..."
/>
</div>
</div>
</Modal>
</div>
)
}
export default PromptEditorPage

View File

@@ -0,0 +1,253 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Tag, Input, Select, Switch, Space, message } from 'antd'
import { SearchOutlined, ThunderboltOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { fetchPromptList, setDebugMode, getDebugStatus, type PromptTemplate } from './api/promptApi'
const { Search } = Input
const { Option } = Select
// 运营管理端主色
const PRIMARY_COLOR = '#10b981'
/**
* Prompt 列表页
*/
const PromptListPage = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [prompts, setPrompts] = useState<PromptTemplate[]>([])
const [filteredPrompts, setFilteredPrompts] = useState<PromptTemplate[]>([])
const [searchText, setSearchText] = useState('')
const [selectedModule, setSelectedModule] = useState<string>('ALL')
const [debugMode, setDebugModeState] = useState(false)
const [debugModules, setDebugModules] = useState<string[]>([])
// 加载 Prompt 列表
const loadPrompts = async () => {
setLoading(true)
try {
const data = await fetchPromptList()
setPrompts(data)
setFilteredPrompts(data)
} catch (error: any) {
message.error(error.message || '加载失败')
} finally {
setLoading(false)
}
}
// 加载调试状态
const loadDebugStatus = async () => {
try {
const status = await getDebugStatus()
setDebugModeState(status.isDebugging)
setDebugModules(status.modules || [])
} catch (error) {
// 静默失败
}
}
useEffect(() => {
loadPrompts()
loadDebugStatus()
}, [])
// 过滤逻辑
useEffect(() => {
let filtered = prompts
// 模块筛选
if (selectedModule !== 'ALL') {
filtered = filtered.filter(p => p.module === selectedModule)
}
// 搜索
if (searchText) {
filtered = filtered.filter(p =>
p.code.toLowerCase().includes(searchText.toLowerCase()) ||
p.name.includes(searchText)
)
}
setFilteredPrompts(filtered)
}, [selectedModule, searchText, prompts])
// 切换调试模式
const handleDebugToggle = async (checked: boolean) => {
try {
const modules = checked ? (selectedModule === 'ALL' ? ['ALL'] : [selectedModule]) : []
await setDebugMode(modules, checked)
setDebugModeState(checked)
setDebugModules(modules)
message.success(checked ? '调试模式已开启' : '调试模式已关闭')
} catch (error: any) {
message.error(error.message || '操作失败')
}
}
// 获取模块列表
const modules = ['ALL', ...Array.from(new Set(prompts.map(p => p.module)))]
// 表格列定义
const columns: ColumnsType<PromptTemplate> = [
{
title: 'Code',
dataIndex: 'code',
key: 'code',
width: 200,
render: (code: string) => (
<span className="font-mono text-gray-700">{code}</span>
),
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
},
{
title: '模块',
dataIndex: 'module',
key: 'module',
width: 80,
render: (module: string) => (
<Tag color="blue">{module}</Tag>
),
},
{
title: '状态',
key: 'status',
width: 100,
render: (_, record) => {
const status = record.latestVersion?.status || 'ARCHIVED'
const colorMap = {
ACTIVE: 'success',
DRAFT: 'warning',
ARCHIVED: 'default',
}
return (
<Tag color={colorMap[status]}>
{status}
</Tag>
)
},
},
{
title: '版本',
key: 'version',
width: 80,
render: (_, record) => (
<span className="text-gray-600">
v{record.latestVersion?.version || 0}
</span>
),
},
{
title: '变量',
key: 'variables',
width: 80,
render: (_, record) => (
<span className="text-gray-500">
{record.variables?.length || 0}
</span>
),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<a
onClick={() => navigate(`/admin/prompts/${record.code}`)}
style={{ color: PRIMARY_COLOR }}
>
</a>
),
},
]
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Prompt </h1>
<p className="text-gray-500"> LLM Prompt </p>
</div>
{/* 调试开关 */}
<Card className="shadow-sm">
<Space align="center">
<ThunderboltOutlined style={{ color: debugMode ? PRIMARY_COLOR : '#999', fontSize: 18 }} />
<span className="text-gray-700 font-medium"></span>
<Switch
checked={debugMode}
onChange={handleDebugToggle}
style={{
background: debugMode ? PRIMARY_COLOR : undefined
}}
/>
{debugMode && (
<Tag color="orange">
{debugModules.includes('ALL') ? '全部模块' : debugModules.join(', ')}
</Tag>
)}
</Space>
</Card>
</div>
{/* 筛选工具栏 */}
<Card>
<Space size="middle">
<span className="text-gray-600">:</span>
<Select
value={selectedModule}
onChange={setSelectedModule}
style={{ width: 150 }}
>
{modules.map(m => (
<Option key={m} value={m}>{m === 'ALL' ? '全部' : m}</Option>
))}
</Select>
<Search
placeholder="搜索 Code 或名称"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<span className="text-gray-400 text-sm">
{filteredPrompts.length} Prompt
</span>
</Space>
</Card>
{/* Prompt 列表表格 */}
<Card>
<Table
loading={loading}
dataSource={filteredPrompts}
columns={columns}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
onRow={(record) => ({
onClick: () => navigate(`/admin/prompts/${record.code}`),
style: { cursor: 'pointer' },
})}
/>
</Card>
</div>
)
}
export default PromptListPage

View File

@@ -0,0 +1,171 @@
/**
* Prompt 管理 API
*/
const API_BASE = '/api/admin/prompts'
export interface PromptTemplate {
id: number
code: string
name: string
module: string
description?: string
variables?: string[]
latestVersion?: {
version: number
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'
createdAt: string
}
updatedAt: string
}
export interface PromptVersion {
id: number
version: number
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'
content: string
modelConfig?: {
model: string
temperature?: number
maxTokens?: number
}
changelog?: string
createdBy?: string
createdAt: string
}
export interface PromptDetail {
id: number
code: string
name: string
module: string
description?: string
variables?: string[]
versions: PromptVersion[]
createdAt: string
updatedAt: string
}
/**
* 获取 Prompt 列表
*/
export async function fetchPromptList(module?: string): Promise<PromptTemplate[]> {
const url = module ? `${API_BASE}?module=${module}` : API_BASE
const response = await fetch(url)
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to fetch prompts')
}
return data.data
}
/**
* 获取 Prompt 详情
*/
export async function fetchPromptDetail(code: string): Promise<PromptDetail> {
const response = await fetch(`${API_BASE}/${code}`)
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to fetch prompt detail')
}
return data.data
}
/**
* 保存草稿
*/
export async function saveDraft(
code: string,
content: string,
modelConfig?: any,
changelog?: string
): Promise<any> {
const response = await fetch(`${API_BASE}/${code}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, modelConfig, changelog }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to save draft')
}
return data.data
}
/**
* 发布 Prompt
*/
export async function publishPrompt(code: string): Promise<any> {
const response = await fetch(`${API_BASE}/${code}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to publish prompt')
}
return data.data
}
/**
* 设置调试模式
*/
export async function setDebugMode(modules: string[], enabled: boolean): Promise<any> {
const response = await fetch(`${API_BASE}/debug`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modules, enabled }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to set debug mode')
}
return data.data
}
/**
* 获取调试状态
*/
export async function getDebugStatus(): Promise<any> {
const response = await fetch(`${API_BASE}/debug`)
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to get debug status')
}
return data.data
}
/**
* 测试渲染
*/
export async function testRender(content: string, variables: Record<string, any>): Promise<any> {
const response = await fetch(`${API_BASE}/test-render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, variables }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to test render')
}
return data.data
}

View File

@@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from 'react'
import { EditorView } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { lineNumbers, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { history, historyKeymap } from '@codemirror/commands'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { keymap } from '@codemirror/view'
import { Button, Space } from 'antd'
import { UndoOutlined, RedoOutlined, SearchOutlined } from '@ant-design/icons'
interface PromptEditorProps {
value: string
onChange: (value: string) => void
onSave?: () => void
readonly?: boolean
}
// 提取变量 {{xxx}}
function extractVariables(text: string): string[] {
const regex = /\{\{(\w+)\}\}/g
const variables = new Set<string>()
let match
while ((match = regex.exec(text)) !== null) {
variables.add(match[1])
}
return Array.from(variables)
}
// 变量高亮装饰器
const variableHighlight = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view)
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations = []
const regex = /\{\{(\w+)\}\}/g
const text = view.state.doc.toString()
let match
while ((match = regex.exec(text)) !== null) {
const from = match.index
const to = match.index + match[0].length
decorations.push(
Decoration.mark({
class: 'cm-variable-highlight',
}).range(from, to)
)
}
return Decoration.set(decorations)
}
}, {
decorations: v => v.decorations
})
/**
* CodeMirror 6 简化编辑器(非技术用户友好)
*/
const PromptEditor = ({ value, onChange, onSave, readonly = false }: PromptEditorProps) => {
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const [charCount, setCharCount] = useState(0)
const [variables, setVariables] = useState<string[]>([])
useEffect(() => {
if (!editorRef.current) return
// 简化配置
const extensions = [
lineNumbers(), // 行号
EditorView.lineWrapping, // 自动换行
history(), // 撤销/重做
keymap.of([...historyKeymap, ...searchKeymap]), // 快捷键
highlightSelectionMatches(), // 选中高亮
variableHighlight, // 变量高亮
// 自定义主题(大字体、中文友好)
EditorView.theme({
'&': {
fontSize: '15px',
fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
height: '600px',
},
'.cm-content': {
lineHeight: '1.8',
padding: '12px 0',
},
'.cm-gutters': {
backgroundColor: '#fafafa',
color: '#999',
borderRight: '1px solid #e8e8e8',
},
'.cm-line': {
padding: '0 12px',
},
'.cm-variable-highlight': {
background: '#e6f7ff',
color: '#1890ff',
borderRadius: '3px',
padding: '0 4px',
fontWeight: '500',
},
'.cm-scroller': {
overflow: 'auto',
},
}),
// 监听内容变化
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString()
onChange(newValue)
setCharCount(newValue.length)
setVariables(extractVariables(newValue))
}
}),
// 保存快捷键 Ctrl+S
keymap.of([{
key: 'Mod-s',
run: () => {
onSave?.()
return true
}
}]),
// 只读模式
EditorView.editable.of(!readonly),
]
const state = EditorState.create({
doc: value,
extensions,
})
const view = new EditorView({
state,
parent: editorRef.current,
})
viewRef.current = view
// 初始化统计
setCharCount(value.length)
setVariables(extractVariables(value))
return () => {
view.destroy()
}
}, [readonly])
// 更新内容(外部触发)
useEffect(() => {
if (viewRef.current && value !== viewRef.current.state.doc.toString()) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
}
})
}
}, [value])
// 工具栏按钮
const handleUndo = () => {
if (viewRef.current) {
const command = historyKeymap.find(k => k.key === 'Mod-z')
command?.run?.(viewRef.current)
}
}
const handleRedo = () => {
if (viewRef.current) {
const command = historyKeymap.find(k => k.key === 'Mod-y' || k.key === 'Mod-Shift-z')
command?.run?.(viewRef.current)
}
}
const handleSearch = () => {
if (viewRef.current) {
const command = searchKeymap.find(k => k.key === 'Mod-f')
command?.run?.(viewRef.current)
}
}
return (
<div className="space-y-2">
{/* 工具栏 */}
<div className="flex items-center justify-between bg-gray-50 px-4 py-2 rounded-t">
<Space>
<Button
size="small"
icon={<UndoOutlined />}
onClick={handleUndo}
disabled={readonly}
>
</Button>
<Button
size="small"
icon={<RedoOutlined />}
onClick={handleRedo}
disabled={readonly}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
</Space>
<Space className="text-sm text-gray-500">
<span>: {charCount.toLocaleString()}</span>
<span>|</span>
<span>: {variables.length}</span>
</Space>
</div>
{/* 编辑器 */}
<div
ref={editorRef}
className="border border-gray-200 rounded-b overflow-hidden"
/>
</div>
)
}
export default PromptEditor

View File

@@ -0,0 +1,163 @@
import { Card, Row, Col, Statistic, Progress, Table } from 'antd'
import {
TeamOutlined,
FileTextOutlined,
RiseOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'
import { useAuth } from '../../framework/auth'
/**
* 机构管理端 - 概览页
*/
const OrgDashboard = () => {
const { user } = useAuth()
// 判断是医院还是药企
const isHospital = user?.role === 'HOSPITAL_ADMIN' || user?.role === 'DEPARTMENT_ADMIN'
// 模拟数据
const stats = [
{
title: isHospital ? '科室成员' : '部门成员',
value: 28,
icon: <TeamOutlined />,
color: '#1890ff'
},
{
title: '本月使用量',
value: 156,
icon: <FileTextOutlined />,
color: '#52c41a',
suffix: '次'
},
{
title: '环比增长',
value: 23.5,
icon: <RiseOutlined />,
color: '#faad14',
suffix: '%'
},
{
title: '配额剩余',
value: 844,
icon: <ClockCircleOutlined />,
color: '#722ed1'
},
]
const recentUsers = [
{ key: 1, name: '张三', department: '心内科', usage: 45, lastActive: '10分钟前' },
{ key: 2, name: '李四', department: '神经外科', usage: 32, lastActive: '1小时前' },
{ key: 3, name: '王五', department: '放射科', usage: 28, lastActive: '2小时前' },
{ key: 4, name: '赵六', department: '急诊科', usage: 21, lastActive: '3小时前' },
]
const columns = [
{ title: '用户', dataIndex: 'name', key: 'name' },
{ title: isHospital ? '科室' : '部门', dataIndex: 'department', key: 'department' },
{ title: '本月使用', dataIndex: 'usage', key: 'usage', render: (v: number) => `${v}` },
{ title: '最近活跃', dataIndex: 'lastActive', key: 'lastActive' },
]
return (
<div className="space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-2"></h1>
<p className="text-gray-500">
{(user as any)?.tenant?.name || (isHospital ? '医院' : '企业')} ·
</p>
</div>
{/* 统计卡片 */}
<Row gutter={16}>
{stats.map((stat, index) => (
<Col span={6} key={index}>
<Card className="hover:shadow-lg transition-shadow">
<Statistic
title={stat.title}
value={stat.value}
prefix={<span style={{ color: stat.color }}>{stat.icon}</span>}
suffix={stat.suffix}
/>
</Card>
</Col>
))}
</Row>
{/* 配额使用情况 */}
<Card title="配额使用情况">
<Row gutter={32}>
<Col span={8}>
<div className="text-center">
<Progress
type="circle"
percent={15.6}
format={() => '156/1000'}
strokeColor="#1890ff"
/>
<p className="mt-2 text-gray-500">使</p>
</div>
</Col>
<Col span={16}>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span>稿 (RVW)</span>
<span>78/500</span>
</div>
<Progress percent={15.6} strokeColor="#1890ff" />
</div>
<div>
<div className="flex justify-between mb-1">
<span> (PKB)</span>
<span>45/300</span>
</div>
<Progress percent={15} strokeColor="#52c41a" />
</div>
<div>
<div className="flex justify-between mb-1">
<span> (ASL)</span>
<span>33/200</span>
</div>
<Progress percent={16.5} strokeColor="#faad14" />
</div>
</div>
</Col>
</Row>
</Card>
{/* 活跃用户 */}
<Card
title="活跃用户"
extra={<a href="/org/users"></a>}
>
<Table
dataSource={recentUsers}
columns={columns}
pagination={false}
size="small"
/>
</Card>
{/* 快捷操作 */}
<Card title="快捷操作">
<div className="flex gap-4">
<button className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition">
</button>
<button className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition">
📊
</button>
<button className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 transition">
</button>
</div>
</Card>
</div>
)
}
export default OrgDashboard

View File

@@ -54,5 +54,6 @@ export { default as Placeholder } from './Placeholder';

View File

@@ -34,5 +34,6 @@ interface ImportMeta {