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:
@@ -88,5 +88,6 @@ vite.config.*.timestamp-*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -55,5 +55,6 @@ exec nginx -g 'daemon off;'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -211,5 +211,6 @@ http {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
150
frontend-v2/package-lock.json
generated
150
frontend-v2/package-lock.json
generated
@@ -12,12 +12,18 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/x": "^2.1.0",
|
||||
"@ant-design/x-sdk": "^2.1.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/view": "^6.39.9",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ag-grid-community": "^34.3.1",
|
||||
"ag-grid-react": "^34.3.1",
|
||||
"antd": "^6.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dexie": "^4.2.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -1084,6 +1090,87 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
|
||||
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.1.tgz",
|
||||
"integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.1",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.1.tgz",
|
||||
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",
|
||||
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.5.11",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.5.11.tgz",
|
||||
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
||||
"integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.39.9",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.39.9.tgz",
|
||||
"integrity": "sha512-miGSIfBOKC1s2oHoa80dp+BjtsL8sXsrgGlQnQuOcfvaedcQUtqddTmKbJSDkLl4mkgPvZyXuKic2HDNYcJLYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
|
||||
@@ -1837,6 +1924,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.0.tgz",
|
||||
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.7.tgz",
|
||||
"integrity": "sha512-wNIFWdSUfX9Jc6ePMzxSPVgTVB4EOfDIwLQLWASyiUdHKaMsiilj9bYiGkGQCKVodd0x6bgQCV207PILGFCF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@naoak/workerize-transferable": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@naoak/workerize-transferable/-/workerize-transferable-0.1.0.tgz",
|
||||
@@ -4307,6 +4424,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||
@@ -4443,6 +4575,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7875,6 +8013,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styled-components": {
|
||||
"version": "6.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/styled-components/-/styled-components-6.1.19.tgz",
|
||||
@@ -8466,6 +8610,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.4.tgz",
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/x": "^2.1.0",
|
||||
"@ant-design/x-sdk": "^2.1.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/view": "^6.39.9",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ag-grid-community": "^34.3.1",
|
||||
"ag-grid-react": "^34.3.1",
|
||||
"antd": "^6.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dexie": "^4.2.1",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
206
frontend-v2/src/framework/auth/AuthContext.tsx
Normal file
206
frontend-v2/src/framework/auth/AuthContext.tsx
Normal 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 };
|
||||
|
||||
242
frontend-v2/src/framework/auth/api.ts
Normal file
242
frontend-v2/src/framework/auth/api.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
8
frontend-v2/src/framework/auth/index.ts
Normal file
8
frontend-v2/src/framework/auth/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 认证模块导出
|
||||
*/
|
||||
|
||||
export { AuthProvider, useAuth, AuthContext } from './AuthContext';
|
||||
export * from './types';
|
||||
export * from './api';
|
||||
|
||||
101
frontend-v2/src/framework/auth/types.ts
Normal file
101
frontend-v2/src/framework/auth/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
236
frontend-v2/src/framework/layout/AdminLayout.tsx
Normal file
236
frontend-v2/src/framework/layout/AdminLayout.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
259
frontend-v2/src/framework/layout/OrgLayout.tsx
Normal file
259
frontend-v2/src/framework/layout/OrgLayout.tsx
Normal 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
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -558,5 +558,6 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -151,5 +151,6 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -141,5 +141,6 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -340,5 +340,6 @@ export default DropnaDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -425,5 +425,6 @@ export default MetricTimePanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -311,5 +311,6 @@ export default PivotPanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -111,5 +111,6 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,5 +103,6 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,5 +99,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -220,3 +220,4 @@ export const documentSelectionApi = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -288,3 +288,4 @@ export default KnowledgePage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -226,3 +226,4 @@ export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,3 +43,4 @@ export interface BatchTemplate {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -128,3 +128,4 @@ export function formatTime(dateStr: string): string {
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -121,3 +121,4 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,3 +41,4 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -64,3 +64,4 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,3 +54,4 @@ export default function Header({ onUpload }: HeaderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -108,3 +108,4 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,3 +36,4 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }:
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,4 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }:
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ export { default as MethodologyReport } from './MethodologyReport';
|
||||
export { default as ReportDetail } from './ReportDetail';
|
||||
export { default as TaskDetail } from './TaskDetail';
|
||||
|
||||
|
||||
|
||||
@@ -282,3 +282,4 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -231,3 +231,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
367
frontend-v2/src/pages/LoginPage.tsx
Normal file
367
frontend-v2/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
146
frontend-v2/src/pages/admin/AdminDashboard.tsx
Normal file
146
frontend-v2/src/pages/admin/AdminDashboard.tsx
Normal 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
|
||||
398
frontend-v2/src/pages/admin/PromptEditorPage.tsx
Normal file
398
frontend-v2/src/pages/admin/PromptEditorPage.tsx
Normal 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
|
||||
|
||||
253
frontend-v2/src/pages/admin/PromptListPage.tsx
Normal file
253
frontend-v2/src/pages/admin/PromptListPage.tsx
Normal 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
|
||||
|
||||
171
frontend-v2/src/pages/admin/api/promptApi.ts
Normal file
171
frontend-v2/src/pages/admin/api/promptApi.ts
Normal 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
|
||||
}
|
||||
|
||||
244
frontend-v2/src/pages/admin/components/PromptEditor.tsx
Normal file
244
frontend-v2/src/pages/admin/components/PromptEditor.tsx
Normal 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
|
||||
|
||||
163
frontend-v2/src/pages/org/OrgDashboard.tsx
Normal file
163
frontend-v2/src/pages/org/OrgDashboard.tsx
Normal 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
|
||||
|
||||
@@ -54,5 +54,6 @@ export { default as Placeholder } from './Placeholder';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
frontend-v2/src/vite-env.d.ts
vendored
1
frontend-v2/src/vite-env.d.ts
vendored
@@ -34,5 +34,6 @@ interface ImportMeta {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user