Initial commit

This commit is contained in:
2026-04-07 09:03:48 +08:00
commit c9ffb52b7f
713 changed files with 111641 additions and 0 deletions

198
web-admin/src/App.tsx Normal file
View File

@@ -0,0 +1,198 @@
import { useEffect, useMemo, useState } from 'react'
import { HashRouter, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { Alert, Result, Spin } from 'antd'
import { authApi, menuApi } from '@/lib/api'
import { buildFullMenus, findDefaultRoute, flattenMenus, isExternalMenu } from '@/lib/menu'
import { useAuthStore } from '@/store/auth'
import { LoginPage } from '@/features/auth/LoginPage'
import { AdminShell } from '@/features/layout/AdminShell'
import { DashboardPage } from '@/features/dashboard/DashboardPage'
import { ModuleLandingPage } from '@/features/discovery/ModuleLandingPage'
import { ProfilePage } from '@/features/person/ProfilePage'
import { ServerStatePage } from '@/features/server/ServerStatePage'
import { UserManagementPage } from '@/features/users/UserManagementPage'
import { RoleManagementPage } from '@/features/roles/RoleManagementPage'
import { MenuManagementPage } from '@/features/menus/MenuManagementPage'
import { ApiManagementPage } from '@/features/apis/ApiManagementPage'
import { DictionaryManagementPage } from '@/features/dictionaries/DictionaryManagementPage'
import { ParamsManagementPage } from '@/features/params/ParamsManagementPage'
import { LoginLogPage } from '@/features/logs/LoginLogPage'
import { OperationLogPage } from '@/features/logs/OperationLogPage'
import { SystemConfigPage } from '@/features/system/SystemConfigPage'
import { ApiTokenPage } from '@/features/tokens/ApiTokenPage'
import { ErrorLogPage } from '@/features/errors/ErrorLogPage'
type AppRoute = {
path: string
menuName: string
element: React.ReactNode
}
const appRoutes: AppRoute[] = [
{ path: 'dashboard', menuName: 'dashboard', element: <DashboardPage /> },
{ path: 'about', menuName: 'about', element: <ModuleLandingPage moduleName="about" /> },
{ path: 'admin', menuName: 'superAdmin', element: <ModuleLandingPage moduleName="superAdmin" /> },
{ path: 'admin/authority', menuName: 'authority', element: <RoleManagementPage /> },
{ path: 'admin/menu', menuName: 'menu', element: <MenuManagementPage /> },
{ path: 'admin/api', menuName: 'api', element: <ApiManagementPage /> },
{ path: 'admin/user', menuName: 'user', element: <UserManagementPage /> },
{ path: 'admin/dictionary', menuName: 'dictionary', element: <DictionaryManagementPage /> },
{ path: 'admin/operation', menuName: 'operation', element: <OperationLogPage /> },
{ path: 'admin/sysParams', menuName: 'sysParams', element: <ParamsManagementPage /> },
{ path: 'admin/system', menuName: 'system', element: <SystemConfigPage /> },
{ path: 'admin/apiToken', menuName: 'apiToken', element: <ApiTokenPage /> },
{ path: 'admin/loginLog', menuName: 'loginLog', element: <LoginLogPage /> },
{ path: 'admin/sysVersion', menuName: 'sysVersion', element: <ModuleLandingPage moduleName="sysVersion" /> },
{ path: 'admin/sysError', menuName: 'sysError', element: <ErrorLogPage /> },
{ path: 'common', menuName: 'common', element: <ModuleLandingPage moduleName="common" /> },
{ path: 'common/upload', menuName: 'upload', element: <ModuleLandingPage moduleName="upload" /> },
{ path: 'example', menuName: 'example', element: <ModuleLandingPage moduleName="example" /> },
{ path: 'example/breakpoint', menuName: 'breakpoint', element: <ModuleLandingPage moduleName="breakpoint" /> },
{ path: 'example/customer', menuName: 'customer', element: <ModuleLandingPage moduleName="customer" /> },
{ path: 'systemTools', menuName: 'systemTools', element: <ModuleLandingPage moduleName="systemTools" /> },
{ path: 'systemTools/autoPkg', menuName: 'autoPkg', element: <ModuleLandingPage moduleName="autoPkg" /> },
{ path: 'systemTools/autoCode', menuName: 'autoCode', element: <ModuleLandingPage moduleName="autoCode" /> },
{ path: 'systemTools/autoCodeAdmin', menuName: 'autoCodeAdmin', element: <ModuleLandingPage moduleName="autoCodeAdmin" /> },
{ path: 'systemTools/formCreate', menuName: 'formCreate', element: <ModuleLandingPage moduleName="formCreate" /> },
{ path: 'systemTools/aiWorkflow', menuName: 'aiWorkflow', element: <ModuleLandingPage moduleName="aiWorkflow" /> },
{ path: 'systemTools/exportTemplate', menuName: 'exportTemplate', element: <ModuleLandingPage moduleName="exportTemplate" /> },
{ path: 'systemTools/mcpTest', menuName: 'mcpTest', element: <ModuleLandingPage moduleName="mcpTest" /> },
{ path: 'systemTools/mcpTool', menuName: 'mcpTool', element: <ModuleLandingPage moduleName="mcpTool" /> },
{ path: 'systemTools/skills', menuName: 'skills', element: <ModuleLandingPage moduleName="skills" /> },
{ path: 'systemTools/picture', menuName: 'picture', element: <ModuleLandingPage moduleName="picture" /> },
{ path: 'systemTools/autoCodeEdit/:id', menuName: 'autoCodeEdit', element: <ModuleLandingPage moduleName="autoCodeEdit" /> },
{ path: 'person', menuName: 'person', element: <ProfilePage /> },
{ path: 'state', menuName: 'state', element: <ServerStatePage /> },
]
function BootstrapGate() {
const navigate = useNavigate()
const location = useLocation()
const token = useAuthStore((state) => state.token)
const menus = useAuthStore((state) => state.menus)
const setUser = useAuthStore((state) => state.setUser)
const setMenus = useAuthStore((state) => state.setMenus)
const clearSession = useAuthStore((state) => state.clearSession)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
let ignore = false
const bootstrap = async () => {
if (!token) {
navigate('/login', { replace: true, state: { redirectTo: location.pathname } })
return
}
try {
setLoading(true)
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
if (ignore) {
return
}
setUser(userInfo.data.userInfo)
setMenus(buildFullMenus(menuRes.data.menus))
setLoadError(null)
} catch (error) {
if (ignore) {
return
}
const message = error instanceof Error ? error.message : '会话恢复失败'
clearSession()
setLoadError(message)
navigate('/login', { replace: true })
} finally {
if (!ignore) {
setLoading(false)
}
}
}
bootstrap()
return () => {
ignore = true
}
}, [clearSession, location.pathname, navigate, setMenus, setUser, token])
if (!token) {
return null
}
if (loading && menus.length === 0) {
return (
<div className="fullscreen-status">
<Spin size="large" />
<span>...</span>
</div>
)
}
if (loadError) {
return (
<div className="fullscreen-status">
<Alert type="error" message="会话恢复失败" description={loadError} showIcon />
</div>
)
}
return <Outlet />
}
function RouteGuard({ route }: { route: AppRoute }) {
const menus = useAuthStore((state) => state.menus)
const allowedPaths = useMemo(
() =>
new Set(
flattenMenus(menus)
.filter((item) => !isExternalMenu(item))
.map((item) => item.fullPath),
),
[menus],
)
if (!allowedPaths.has(`/${route.path}`)) {
return (
<Result
status="403"
title="当前角色无权访问此页面"
subTitle="这个路由仍然存在于系统内,但当前角色没有对应菜单授权。"
/>
)
}
return <>{route.element}</>
}
function LayoutFrame() {
const menus = useAuthStore((state) => state.menus)
const user = useAuthStore((state) => state.user)
const defaultPath = findDefaultRoute(menus, user?.authority?.defaultRouter)
return (
<AdminShell>
<Routes>
<Route index element={<Navigate replace to={defaultPath || '/dashboard'} />} />
{appRoutes.map((route) => (
<Route key={route.path} path={route.path} element={<RouteGuard route={route} />} />
))}
<Route path="*" element={<Result status="404" title="页面不存在" subTitle="请从左侧导航重新进入页面。" />} />
</Routes>
</AdminShell>
)
}
export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<BootstrapGate />}>
<Route path="/*" element={<LayoutFrame />} />
</Route>
</Routes>
</HashRouter>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,318 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Col,
Drawer,
Form,
Input,
Modal,
Row,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { apiRegistryApi, authorityApi } from '@/lib/api'
import { flattenAuthorities } from '@/lib/tree'
import type { ApiRecord, Authority } from '@/types/system'
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE']
export function ApiManagementPage() {
const [searchForm] = Form.useForm()
const [editForm] = Form.useForm()
const [apis, setApis] = useState<ApiRecord[]>([])
const [roles, setRoles] = useState<Authority[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [editingApi, setEditingApi] = useState<ApiRecord | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [activeApi, setActiveApi] = useState<ApiRecord | null>(null)
const [selectedRoles, setSelectedRoles] = useState<number[]>([])
const [savingRoles, setSavingRoles] = useState(false)
const roleOptions = useMemo(
() =>
flattenAuthorities(roles).map((item) => ({
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
})),
[roles],
)
const apiGroupOptions = useMemo(
() =>
Array.from(new Set(apis.map((item) => item.apiGroup)))
.filter(Boolean)
.map((group) => ({
label: group,
value: group,
})),
[apis],
)
const reloadApis = useCallback(async () => {
setLoading(true)
try {
const [apiRes, roleRes] = await Promise.all([
apiRegistryApi.getApiList({
page,
pageSize,
...searchForm.getFieldsValue(),
}),
authorityApi.getAuthorityList(),
])
setApis(apiRes.data.list)
setTotal(apiRes.data.total)
setRoles(roleRes.data)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
useEffect(() => {
reloadApis()
}, [reloadApis])
const openCreate = () => {
setEditingApi(null)
editForm.resetFields()
editForm.setFieldsValue({ method: 'POST' })
setModalOpen(true)
}
const openEdit = async (record: ApiRecord) => {
const response = await apiRegistryApi.getApiById(record.ID)
setEditingApi(response.data.api)
editForm.setFieldsValue(response.data.api)
setModalOpen(true)
}
const saveApi = async () => {
const values = await editForm.validateFields()
setSaving(true)
try {
if (editingApi) {
await apiRegistryApi.updateApi({
ID: editingApi.ID,
...values,
})
message.success('接口已更新')
} else {
await apiRegistryApi.createApi(values)
message.success('接口已创建')
}
setModalOpen(false)
reloadApis()
} finally {
setSaving(false)
}
}
const deleteApi = (record: ApiRecord) => {
Modal.confirm({
title: `删除接口 ${record.path}`,
okButtonProps: { danger: true },
onOk: async () => {
await apiRegistryApi.deleteApi({ ID: record.ID })
message.success('接口已删除')
reloadApis()
},
})
}
const openRoleDrawer = async (record: ApiRecord) => {
const response = await apiRegistryApi.getApiRoles(record.path, record.method)
setActiveApi(record)
setSelectedRoles(response.data)
setDrawerOpen(true)
}
const saveRoles = async () => {
if (!activeApi) {
return
}
setSavingRoles(true)
try {
await apiRegistryApi.setApiRoles({
path: activeApi.path,
method: activeApi.method,
authorityIds: selectedRoles,
})
message.success('接口角色关系已更新')
setDrawerOpen(false)
} finally {
setSavingRoles(false)
}
}
const columns: ColumnsType<ApiRecord> = [
{ title: 'ID', dataIndex: 'ID', width: 80 },
{ title: '路径', dataIndex: 'path', width: 260 },
{ title: '分组', dataIndex: 'apiGroup', width: 160 },
{ title: '描述', dataIndex: 'description', width: 240 },
{
title: '方法',
dataIndex: 'method',
width: 100,
render: (value: string) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : value === 'PUT' ? 'orange' : 'red'}>{value}</Tag>,
},
{
title: '操作',
key: 'actions',
width: 220,
fixed: 'right',
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button type="link" onClick={() => openRoleDrawer(record)}>
</Button>
<Button danger type="link" onClick={() => deleteApi(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
API
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</div>
<Space>
<Button
onClick={async () => {
await apiRegistryApi.freshCasbin()
message.success('Casbin 缓存已刷新')
}}
>
Casbin
</Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</div>
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadApis() }}>
<Row gutter={[16, 0]}>
<Col xs={24} md={8}>
<Form.Item name="path" label="路径">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="description" label="描述">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item name="apiGroup" label="分组">
<Select allowClear options={apiGroupOptions} />
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item name="method" label="方法">
<Select allowClear options={methodOptions.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
searchForm.resetFields()
setPage(1)
reloadApis()
}}
>
</Button>
</Space>
</Form>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={apis}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Modal
open={modalOpen}
title={editingApi ? '编辑接口' : '新建接口'}
onCancel={() => setModalOpen(false)}
onOk={saveApi}
confirmLoading={saving}
>
<Form form={editForm} layout="vertical">
<Form.Item name="path" label="路径" rules={[{ required: true, message: '请输入路径' }]}>
<Input placeholder="/api/example" />
</Form.Item>
<Form.Item name="description" label="描述" rules={[{ required: true, message: '请输入描述' }]}>
<Input />
</Form.Item>
<Form.Item name="apiGroup" label="分组" rules={[{ required: true, message: '请输入分组' }]}>
<Input />
</Form.Item>
<Form.Item name="method" label="方法" rules={[{ required: true, message: '请选择方法' }]}>
<Select options={methodOptions.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Form>
</Modal>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={activeApi ? `接口角色分配 · ${activeApi.path}` : '接口角色分配'}
width={520}
extra={
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
</Button>
}
>
<Select
mode="multiple"
style={{ width: '100%' }}
value={selectedRoles}
options={roleOptions}
onChange={setSelectedRoles}
/>
</Drawer>
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Alert, Button, Card, Form, Input, Typography, message } from 'antd'
import { authApi, menuApi } from '@/lib/api'
import { buildFullMenus, findDefaultRoute } from '@/lib/menu'
import { useAuthStore } from '@/store/auth'
import type { CaptchaInfo } from '@/types/system'
type LoginForm = {
username: string
password: string
captcha?: string
}
function normalizeCaptchaSrc(picPath?: string) {
if (!picPath) {
return ''
}
if (picPath.startsWith('data:image')) {
return picPath
}
return `data:image/png;base64,${picPath}`
}
export function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const applySession = useAuthStore((state) => state.applySession)
const setMenus = useAuthStore((state) => state.setMenus)
const setUser = useAuthStore((state) => state.setUser)
const [form] = Form.useForm<LoginForm>()
const [captcha, setCaptcha] = useState<CaptchaInfo | null>(null)
const [submitting, setSubmitting] = useState(false)
const [loadCaptchaError, setLoadCaptchaError] = useState<string | null>(null)
const redirectTarget = useMemo(() => {
const state = location.state as { redirectTo?: string } | null
return state?.redirectTo
}, [location.state])
const fetchCaptcha = async () => {
try {
const response = await authApi.getCaptcha()
setCaptcha(response.data)
setLoadCaptchaError(null)
} catch (error) {
const messageText = error instanceof Error ? error.message : '验证码加载失败'
setLoadCaptchaError(messageText)
}
}
useEffect(() => {
fetchCaptcha()
}, [])
const submit = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
const loginResponse = await authApi.login({
username: values.username,
password: values.password,
captcha: values.captcha,
captchaId: captcha?.captchaId,
})
applySession({
token: loginResponse.data.token,
user: loginResponse.data.user,
})
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
const menus = buildFullMenus(menuRes.data.menus)
setUser(userInfo.data.userInfo)
setMenus(menus)
message.success('登录成功')
const fallback = findDefaultRoute(menus, userInfo.data.userInfo.authority?.defaultRouter) || '/dashboard'
navigate(redirectTarget || fallback, { replace: true })
} catch {
form.setFieldValue('captcha', '')
fetchCaptcha()
} finally {
setSubmitting(false)
}
}
return (
<div className="login-shell">
<section className="login-hero">
<span className="capsule">Gin-Vue-Admin · React </span>
<div style={{ maxWidth: 620, marginTop: 72 }}>
<Typography.Title style={{ color: 'rgba(255,255,255,0.96)', fontSize: 52, marginBottom: 18 }}>
<br />
</Typography.Title>
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.78)', fontSize: 18, lineHeight: 1.8 }}>
JWT 访
</Typography.Paragraph>
</div>
</section>
<section className="login-form-wrap">
<Card className="glass-panel login-card" bordered={false}>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted">
沿
</Typography.Paragraph>
{loadCaptchaError ? (
<Alert
type="warning"
showIcon
message="验证码加载失败"
description={loadCaptchaError}
action={
<Button size="small" onClick={fetchCaptcha}>
</Button>
}
style={{ marginBottom: 16 }}
/>
) : null}
<Form
form={form}
layout="vertical"
initialValues={{
username: 'admin',
}}
onFinish={submit}
>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, message: '请输入用户名' }]}>
<Input size="large" placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '请输入至少 6 位密码' }]}>
<Input.Password size="large" placeholder="请输入密码" />
</Form.Item>
{captcha?.openCaptcha ? (
<Form.Item
name="captcha"
label={`验证码(${captcha.captchaLength} 位)`}
rules={[{ required: true, len: captcha.captchaLength, message: `请输入 ${captcha.captchaLength} 位验证码` }]}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
<Input
size="large"
placeholder="请输入验证码"
style={{ flex: 1 }}
/>
<div
onClick={fetchCaptcha}
style={{
width: 128,
height: 40,
borderRadius: 12,
overflow: 'hidden',
border: '1px solid rgba(16, 37, 66, 0.12)',
background: '#f4efe8',
cursor: 'pointer',
flexShrink: 0,
}}
>
<img
alt="captcha"
src={normalizeCaptchaSrc(captcha.picPath)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
</div>
</Form.Item>
) : null}
<Button block size="large" type="primary" htmlType="submit" loading={submitting}>
</Button>
</Form>
</Card>
</section>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { Card, Col, List, Progress, Row, Statistic, Tag, Typography } from 'antd'
import { moduleList } from '@/features/discovery/systemInventory'
import { flattenMenus } from '@/lib/menu'
import { useAuthStore } from '@/store/auth'
export function DashboardPage() {
const menus = useAuthStore((state) => state.menus)
const user = useAuthStore((state) => state.user)
const flatMenus = flattenMenus(menus)
const implementedCount = moduleList.filter((item) => item.status === 'ready').length
const partialCount = moduleList.filter((item) => item.status === 'partial').length
const allowedModules = flatMenus.length
const allEndpoints = moduleList.reduce((total, item) => total + item.endpoints.length, 0)
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="hero-grid">
<div>
<Tag color="processing" style={{ marginBottom: 12 }}>
React
</Tag>
<Typography.Title level={2} style={{ marginBottom: 12 }}>
{user?.nickName || user?.userName || '管理员'}
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ maxWidth: 720, marginBottom: 20 }}>
</Typography.Paragraph>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card bordered={false} style={{ background: 'var(--accent-soft)' }}>
<Statistic title="当前角色默认首页" value={user?.authority?.defaultRouter || 'dashboard'} />
</Card>
</Col>
<Col span={12}>
<Card bordered={false} style={{ background: 'var(--success-soft)' }}>
<Statistic title="可访问内部菜单" value={allowedModules} />
</Card>
</Col>
</Row>
</div>
<Card bordered={false} style={{ background: 'rgba(16, 37, 66, 0.04)' }}>
<Typography.Title level={4}></Typography.Title>
<Progress
percent={Math.round((implementedCount / moduleList.length) * 100)}
strokeColor="#d16f3f"
/>
<List
size="small"
style={{ marginTop: 16 }}
dataSource={[
`已接入模块 ${implementedCount}`,
`待继续迁移 ${partialCount}`,
`模块登记总数 ${moduleList.length}`,
`清点出的接口触点 ${allEndpoints}`,
]}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Card>
</div>
</Card>
<div className="metric-grid">
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{moduleList.length}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{implementedCount}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{partialCount}
</Typography.Title>
</div>
<div className="metric-card">
<Typography.Text className="text-muted"></Typography.Text>
<Typography.Title level={2} style={{ margin: '8px 0 0' }}>
{allEndpoints}
</Typography.Title>
</div>
</div>
<Card className="glass-panel page-panel" title="模块清单">
<div className="catalog-grid">
{moduleList.map((item) => (
<div className="catalog-card" key={item.name}>
<div className="section-heading" style={{ marginBottom: 8 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{item.title}
</Typography.Title>
<span className={`status-dot ${item.status}`}></span>
</div>
<Typography.Paragraph className="text-muted" style={{ minHeight: 66 }}>
{item.summary}
</Typography.Paragraph>
<Tag>{item.group}</Tag>
<Tag bordered={false}>{item.endpoints.length} </Tag>
</div>
))}
</div>
</Card>
<Card className="glass-panel page-panel" title="当前角色菜单">
<List
dataSource={flatMenus.filter((item) => !item.hidden)}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
title={item.meta.title}
description={`${item.name} · ${item.fullPath}`}
/>
</List.Item>
)}
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,334 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Table,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { dictionaryApi } from '@/lib/api'
import type { Dictionary, DictionaryDetail } from '@/types/system'
export function DictionaryManagementPage() {
const [dictForm] = Form.useForm()
const [detailForm] = Form.useForm()
const [dictionaries, setDictionaries] = useState<Dictionary[]>([])
const [activeDictionary, setActiveDictionary] = useState<Dictionary | null>(null)
const [details, setDetails] = useState<DictionaryDetail[]>([])
const [loading, setLoading] = useState(false)
const [dictModalOpen, setDictModalOpen] = useState(false)
const [detailModalOpen, setDetailModalOpen] = useState(false)
const [editingDictionary, setEditingDictionary] = useState<Dictionary | null>(null)
const [editingDetail, setEditingDetail] = useState<DictionaryDetail | null>(null)
const reloadDictionaries = useCallback(async () => {
setLoading(true)
try {
const response = await dictionaryApi.getDictionaryList()
const list = response.data
setDictionaries(list)
if (!activeDictionary && list.length) {
setActiveDictionary(list[0])
}
} finally {
setLoading(false)
}
}, [activeDictionary])
const reloadDetails = useCallback(async (dictionary?: Dictionary | null) => {
const target = dictionary || activeDictionary
if (!target) {
setDetails([])
return
}
const response = await dictionaryApi.getDictionaryTree(target.ID)
setDetails(response.data.list)
}, [activeDictionary])
useEffect(() => {
reloadDictionaries()
}, [reloadDictionaries])
useEffect(() => {
reloadDetails(activeDictionary)
}, [activeDictionary, reloadDetails])
const openCreateDictionary = () => {
setEditingDictionary(null)
dictForm.resetFields()
dictForm.setFieldsValue({ status: true })
setDictModalOpen(true)
}
const openEditDictionary = (record: Dictionary) => {
setEditingDictionary(record)
dictForm.setFieldsValue({
name: record.name,
type: record.type,
desc: record.desc,
status: record.status ?? true,
parentID: record.parentID,
})
setDictModalOpen(true)
}
const saveDictionary = async () => {
const values = await dictForm.validateFields()
const payload = {
ID: editingDictionary?.ID,
...values,
}
if (editingDictionary) {
await dictionaryApi.updateDictionary(payload)
message.success('字典已更新')
} else {
await dictionaryApi.createDictionary(payload)
message.success('字典已创建')
}
setDictModalOpen(false)
reloadDictionaries()
}
const deleteDictionary = (record: Dictionary) => {
Modal.confirm({
title: `删除字典 ${record.name}`,
okButtonProps: { danger: true },
onOk: async () => {
await dictionaryApi.deleteDictionary({ ID: record.ID })
message.success('字典已删除')
if (activeDictionary?.ID === record.ID) {
setActiveDictionary(null)
}
reloadDictionaries()
},
})
}
const openCreateDetail = () => {
if (!activeDictionary) {
message.warning('请先选择字典')
return
}
setEditingDetail(null)
detailForm.resetFields()
detailForm.setFieldsValue({
sysDictionaryID: activeDictionary.ID,
status: true,
sort: 1,
})
setDetailModalOpen(true)
}
const openEditDetail = (record: DictionaryDetail) => {
setEditingDetail(record)
detailForm.setFieldsValue({
label: record.label,
value: record.value,
extend: record.extend,
status: record.status ?? true,
sort: record.sort,
parentID: record.parentID,
})
setDetailModalOpen(true)
}
const saveDetail = async () => {
if (!activeDictionary) {
return
}
const values = await detailForm.validateFields()
const payload = {
ID: editingDetail?.ID,
sysDictionaryID: activeDictionary.ID,
...values,
}
if (editingDetail) {
await dictionaryApi.updateDictionaryDetail(payload)
message.success('字典项已更新')
} else {
await dictionaryApi.createDictionaryDetail(payload)
message.success('字典项已创建')
}
setDetailModalOpen(false)
reloadDetails()
}
const deleteDetail = (record: DictionaryDetail) => {
Modal.confirm({
title: `删除字典项 ${record.label}`,
okButtonProps: { danger: true },
onOk: async () => {
await dictionaryApi.deleteDictionaryDetail({ ID: record.ID })
message.success('字典项已删除')
reloadDetails()
},
})
}
const detailColumns: ColumnsType<DictionaryDetail> = [
{ title: '展示值', dataIndex: 'label', width: 180 },
{ title: '字典值', dataIndex: 'value', width: 180 },
{ title: '扩展值', dataIndex: 'extend', width: 180 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{
title: '状态',
width: 100,
render: (_, record) => (record.status ? '启用' : '停用'),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openEditDetail(record)}>
</Button>
<Button danger type="link" onClick={() => deleteDetail(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</div>
<Space>
<Button onClick={openCreateDetail} disabled={!activeDictionary}>
</Button>
<Button type="primary" onClick={openCreateDictionary}>
</Button>
</Space>
</div>
<Row gutter={20}>
<Col xs={24} xl={8}>
<Card title="字典列表" bordered={false}>
<Space direction="vertical" style={{ width: '100%' }}>
{dictionaries.map((item) => (
<Card
key={item.ID}
size="small"
hoverable
onClick={() => setActiveDictionary(item)}
style={{
borderColor: activeDictionary?.ID === item.ID ? '#d16f3f' : undefined,
}}
extra={
<Space size={4}>
<Button type="link" size="small" onClick={(event) => { event.stopPropagation(); openEditDictionary(item) }}>
</Button>
<Button danger type="link" size="small" onClick={(event) => { event.stopPropagation(); deleteDictionary(item) }}>
</Button>
</Space>
}
>
<Typography.Text strong>{item.name}</Typography.Text>
<div className="text-muted">{item.type}</div>
</Card>
))}
</Space>
</Card>
</Col>
<Col xs={24} xl={16}>
<Card
title={activeDictionary ? `字典项 · ${activeDictionary.name}` : '字典项'}
bordered={false}
loading={loading}
>
<Table
rowKey="ID"
columns={detailColumns}
dataSource={details}
pagination={false}
expandable={{ defaultExpandAllRows: true }}
/>
</Card>
</Col>
</Row>
</Card>
<Modal
open={dictModalOpen}
title={editingDictionary ? '编辑字典' : '新建字典'}
onCancel={() => setDictModalOpen(false)}
onOk={saveDictionary}
>
<Form form={dictForm} layout="vertical">
<Form.Item name="parentID" label="父级字典">
<Select allowClear options={dictionaries.map((item) => ({ label: `${item.name} (${item.type})`, value: item.ID }))} />
</Form.Item>
<Form.Item name="name" label="字典名(中)" rules={[{ required: true, message: '请输入字典名' }]}>
<Input />
</Form.Item>
<Form.Item name="type" label="字典名(英)" rules={[{ required: true, message: '请输入字典类型' }]}>
<Input />
</Form.Item>
<Form.Item name="desc" label="描述">
<Input />
</Form.Item>
<Form.Item name="status" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Modal
open={detailModalOpen}
title={editingDetail ? '编辑字典项' : '新建字典项'}
onCancel={() => setDetailModalOpen(false)}
onOk={saveDetail}
>
<Form form={detailForm} layout="vertical">
<Form.Item name="parentID" label="父级字典项">
<Select
allowClear
options={details.map((item) => ({ label: item.label, value: item.ID }))}
/>
</Form.Item>
<Form.Item name="label" label="展示值" rules={[{ required: true, message: '请输入展示值' }]}>
<Input />
</Form.Item>
<Form.Item name="value" label="字典值" rules={[{ required: true, message: '请输入字典值' }]}>
<Input />
</Form.Item>
<Form.Item name="extend" label="扩展值">
<Input />
</Form.Item>
<Form.Item name="sort" label="排序">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="status" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { Card, List, Tag, Typography } from 'antd'
import { moduleCatalog } from './systemInventory'
const statusLabel = {
ready: '已接入',
partial: '待继续',
planned: '待迁移',
}
type Props = {
moduleName: string
}
export function ModuleLandingPage({ moduleName }: Props) {
const moduleInfo = moduleCatalog[moduleName]
if (!moduleInfo) {
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<Typography.Title level={3}></Typography.Title>
<Typography.Paragraph className="text-muted">
</Typography.Paragraph>
</Card>
</div>
)
}
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
{moduleInfo.title}
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
{moduleInfo.summary}
</Typography.Paragraph>
</div>
<Tag color={moduleInfo.status === 'ready' ? 'green' : moduleInfo.status === 'partial' ? 'orange' : 'default'}>
{statusLabel[moduleInfo.status]}
</Tag>
</div>
</Card>
<div className="hero-grid">
<Card className="glass-panel page-panel" title="模块职责">
<List
dataSource={moduleInfo.features}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Card>
<Card className="glass-panel page-panel" title="关联接口">
<List
size="small"
dataSource={moduleInfo.endpoints}
renderItem={(item) => (
<List.Item>
<code>{item}</code>
</List.Item>
)}
locale={{ emptyText: '当前页面没有直接绑定接口。' }}
/>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,303 @@
import type { ModuleDescriptor } from '@/types/system'
export const moduleCatalog: Record<string, ModuleDescriptor> = {
dashboard: {
name: 'dashboard',
title: '仪表盘',
group: '总览',
status: 'ready',
summary: '展示系统结构、路由覆盖情况和后端模块分布。',
features: ['菜单覆盖总览', 'API 分组概览', '已重构模块状态', '快捷入口'],
endpoints: ['/menu/getMenu', '/user/getUserInfo', '/system/getServerInfo'],
},
about: {
name: 'about',
title: '关于系统',
group: '总览',
status: 'partial',
summary: '用于展示项目来源、版本背景和运维提示。',
features: ['项目说明', '技术栈梳理', '外部链接入口'],
endpoints: [],
},
superAdmin: {
name: 'superAdmin',
title: '超级管理员',
group: '平台治理',
status: 'partial',
summary: '聚合用户、角色、菜单、接口、字典和参数等平台治理功能。',
features: ['权限治理', '配置管理', '审计日志', 'Token 管理'],
endpoints: ['/authority/getAuthorityList', '/menu/getBaseMenuTree', '/api/getApiList'],
},
authority: {
name: 'authority',
title: '角色管理',
group: '平台治理',
status: 'ready',
summary: '管理角色树、默认首页、菜单权限、API 权限和角色用户关系。',
features: ['角色树', '菜单授权', 'API 授权', '角色分配用户'],
endpoints: ['/authority/getAuthorityList', '/menu/addMenuAuthority', '/casbin/updateCasbin', '/authority/setRoleUsers'],
},
menu: {
name: 'menu',
title: '菜单管理',
group: '平台治理',
status: 'ready',
summary: '维护后台菜单树、路由属性、按钮定义和角色关联。',
features: ['菜单树编辑', '路由路径维护', '角色分配', '按钮权限入口'],
endpoints: ['/menu/getBaseMenuTree', '/menu/addBaseMenu', '/menu/updateBaseMenu', '/menu/setMenuRoles'],
},
api: {
name: 'api',
title: 'API 管理',
group: '平台治理',
status: 'ready',
summary: '管理接口目录、接口角色授权和 Casbin 刷新。',
features: ['接口列表', '接口增删改', '角色授权', '接口分组'],
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/setApiRoles'],
},
user: {
name: 'user',
title: '用户管理',
group: '平台治理',
status: 'ready',
summary: '管理账号、启停状态、角色集合、密码和头像等资料。',
features: ['用户列表', '新建用户', '重置密码', '角色切换'],
endpoints: ['/user/getUserList', '/user/admin_register', '/user/setUserInfo', '/user/resetPassword'],
},
dictionary: {
name: 'dictionary',
title: '字典管理',
group: '平台治理',
status: 'ready',
summary: '管理字典主表与树形字典项,支持导入导出。',
features: ['字典列表', '树形详情', '导入导出', '状态控制'],
endpoints: ['/sysDictionary/getSysDictionaryList', '/sysDictionary/exportSysDictionary', '/sysDictionaryDetail/getDictionaryTreeList'],
},
operation: {
name: 'operation',
title: '操作历史',
group: '审计',
status: 'ready',
summary: '查看系统请求日志、响应体和异常信息。',
features: ['操作审计', '请求明细', '批量删除'],
endpoints: ['/sysOperationRecord/getSysOperationRecordList', '/sysOperationRecord/deleteSysOperationRecordByIds'],
},
sysParams: {
name: 'sysParams',
title: '参数管理',
group: '平台治理',
status: 'ready',
summary: '管理运行期参数键值,供前后端按键读取。',
features: ['参数列表', '键值维护', '说明管理'],
endpoints: ['/sysParams/getSysParamsList', '/sysParams/createSysParams', '/sysParams/updateSysParams'],
},
system: {
name: 'system',
title: '系统配置',
group: '运维',
status: 'ready',
summary: '编辑服务配置文件并触发系统重载。',
features: ['配置读取', '配置保存', '服务重载'],
endpoints: ['/system/getSystemConfig', '/system/setSystemConfig', '/system/reloadSystem'],
},
apiToken: {
name: 'apiToken',
title: 'API Token',
group: '运维',
status: 'ready',
summary: '为用户签发临时 JWT并追踪失效状态和过期时间。',
features: ['Token 列表', '签发 Token', '作废 Token'],
endpoints: ['/sysApiToken/getApiTokenList', '/sysApiToken/createApiToken', '/sysApiToken/deleteApiToken'],
},
loginLog: {
name: 'loginLog',
title: '登录日志',
group: '审计',
status: 'ready',
summary: '审计登录成功与失败事件,定位登录问题。',
features: ['登录结果', '失败原因', '设备信息'],
endpoints: ['/sysLoginLog/getLoginLogList', '/sysLoginLog/deleteLoginLogByIds'],
},
sysVersion: {
name: 'sysVersion',
title: '版本管理',
group: '运维',
status: 'partial',
summary: '围绕版本导出、导入和回滚的发布管理模块。',
features: ['版本列表', '版本导出', '版本同步'],
endpoints: ['/sysVersion/getSysVersionList', '/sysVersion/exportVersion', '/sysVersion/importVersion'],
},
sysError: {
name: 'sysError',
title: '错误日志',
group: '审计',
status: 'ready',
summary: '管理系统错误记录和 AI 处理状态。',
features: ['错误列表', '状态流转', 'AI 处理触发'],
endpoints: ['/sysError/getSysErrorList', '/sysError/getSysErrorSolution', '/sysError/updateSysError'],
},
common: {
name: 'common',
title: '公共能力',
group: '公共模块',
status: 'partial',
summary: '承载多端复用的公共功能,不再挂在示例目录下。',
features: ['公共上传能力', '后续 app 复用入口'],
endpoints: ['/fileUploadAndDownload/getFileList', '/attachmentCategory/getCategoryList'],
},
example: {
name: 'example',
title: '示例文件',
group: '业务示例',
status: 'partial',
summary: '保留和业务演示直接相关的示例页面。',
features: ['断点续传', '客户资源示例'],
endpoints: ['/customer/customerList'],
},
upload: {
name: 'upload',
title: '媒体库',
group: '公共模块',
status: 'partial',
summary: '管理上传文件和资源分类,后续可直接复用到 app 模块。',
features: ['文件列表', '文件上传', '分类管理'],
endpoints: ['/fileUploadAndDownload/getFileList', '/attachmentCategory/getCategoryList'],
},
breakpoint: {
name: 'breakpoint',
title: '断点续传',
group: '业务示例',
status: 'partial',
summary: '展示大文件切片上传流程。',
features: ['文件校验', '分片上传', '合并完成'],
endpoints: ['/fileUploadAndDownload/findFile', '/fileUploadAndDownload/breakpointContinue'],
},
customer: {
name: 'customer',
title: '客户列表',
group: '业务示例',
status: 'partial',
summary: '示例业务资源表,用于展示普通 CRUD 模式。',
features: ['客户列表', '增删改查'],
endpoints: ['/customer/customerList', '/customer/customer'],
},
systemTools: {
name: 'systemTools',
title: '编程辅助',
group: '研发辅助',
status: 'partial',
summary: '聚合代码生成、模板配置、技能管理和 AI 工作流。',
features: ['代码生成器', '模板配置', 'Skills 管理', 'AI 页面绘制'],
endpoints: ['/autoCode/getDB', '/skills/getSkillList'],
},
autoPkg: {
name: 'autoPkg',
title: '模板配置',
group: '研发辅助',
status: 'partial',
summary: '维护自动代码生成模板。',
features: ['模板列表', '模板文件'],
endpoints: ['/autoCode/createPackage', '/autoCode/getPackage', '/autoCode/getTemplates'],
},
autoCode: {
name: 'autoCode',
title: '代码生成器',
group: '研发辅助',
status: 'partial',
summary: '自动生成表单、接口、服务和路由代码。',
features: ['库表发现', '代码预览', '代码生成'],
endpoints: ['/autoCode/getDB', '/autoCode/getTables', '/autoCode/preview', '/autoCode/createTemp'],
},
autoCodeAdmin: {
name: 'autoCodeAdmin',
title: '自动化代码管理',
group: '研发辅助',
status: 'partial',
summary: '管理自动化代码任务和函数模板。',
features: ['任务管理', '模板方法'],
endpoints: ['/autoCode/getMeta', '/autoCode/addFunc'],
},
formCreate: {
name: 'formCreate',
title: '表单生成器',
group: '研发辅助',
status: 'partial',
summary: '拖拽式表单构建入口。',
features: ['表单设计'],
endpoints: [],
},
aiWorkflow: {
name: 'aiWorkflow',
title: 'AI 需求工作流',
group: '研发辅助',
status: 'partial',
summary: '管理 AI 会话和需求工作流结果。',
features: ['会话列表', '会话详情', 'Markdown 落盘'],
endpoints: ['/autoCode/getAIWorkflowSessionList', '/autoCode/getAIWorkflowSessionDetail'],
},
exportTemplate: {
name: 'exportTemplate',
title: '导出模板',
group: '研发辅助',
status: 'partial',
summary: '维护 Excel 导入导出模板。',
features: ['模板管理', 'SQL 预览'],
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/sysExportTemplate/previewSQL'],
},
mcpTest: {
name: 'mcpTest',
title: 'Mcp Tools 管理',
group: '研发辅助',
status: 'partial',
summary: '围绕 MCP Tool 的状态、测试和服务控制。',
features: ['服务状态', '测试调用'],
endpoints: ['/autoCode/mcpStatus', '/autoCode/mcpTest', '/autoCode/mcpList'],
},
mcpTool: {
name: 'mcpTool',
title: 'Mcp Tools 模板',
group: '研发辅助',
status: 'partial',
summary: '生成 MCP Tool 模板代码。',
features: ['模板生成'],
endpoints: ['/autoCode/mcp'],
},
skills: {
name: 'skills',
title: 'Skills 管理',
group: '研发辅助',
status: 'partial',
summary: '管理技能定义、脚本、资源、模板和全局约束。',
features: ['技能列表', '脚本资源管理', '技能打包'],
endpoints: ['/skills/getSkillList', '/skills/saveSkill', '/skills/packageSkill'],
},
picture: {
name: 'picture',
title: 'AI 页面绘制',
group: '研发辅助',
status: 'partial',
summary: '基于 AI 描述生成页面草图。',
features: ['图像工作台'],
endpoints: ['/autoCode/llmAuto'],
},
person: {
name: 'person',
title: '个人中心',
group: '个人',
status: 'ready',
summary: '维护当前登录人的资料与密码。',
features: ['资料更新', '密码修改'],
endpoints: ['/user/setSelfInfo', '/user/changePassword'],
},
state: {
name: 'state',
title: '服务器状态',
group: '运维',
status: 'ready',
summary: '读取后端运行状态与资源信息。',
features: ['原始状态树', '运行信息'],
endpoints: ['/system/getServerInfo'],
},
}
export const moduleList = Object.values(moduleCatalog)

View File

@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Card,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { sysErrorApi } from '@/lib/api'
import { formatDate } from '@/lib/date'
import type { SysErrorRecord } from '@/types/system'
export function ErrorLogPage() {
const [editForm] = Form.useForm()
const [rows, setRows] = useState<SysErrorRecord[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [editingRow, setEditingRow] = useState<SysErrorRecord | null>(null)
const reloadRows = useCallback(async () => {
setLoading(true)
try {
const response = await sysErrorApi.getErrorList({
page,
pageSize,
})
setRows(response.data.list)
setTotal(response.data.total)
} finally {
setLoading(false)
}
}, [page, pageSize])
useEffect(() => {
reloadRows()
}, [reloadRows])
const saveRow = async () => {
const values = await editForm.validateFields()
if (!editingRow) {
return
}
await sysErrorApi.updateError({
...editingRow,
...values,
})
message.success('错误日志已更新')
setEditingRow(null)
reloadRows()
}
const columns: ColumnsType<SysErrorRecord> = [
{
title: '时间',
width: 180,
render: (_, record) => formatDate(record.CreatedAt),
},
{
title: '来源',
dataIndex: 'form',
width: 180,
},
{
title: '等级',
dataIndex: 'level',
width: 100,
},
{
title: '状态',
width: 120,
render: (_, record) => <Tag>{record.status || '未处理'}</Tag>,
},
{
title: '错误内容',
dataIndex: 'info',
},
{
title: '解决方案',
dataIndex: 'solution',
},
{
title: '操作',
width: 220,
render: (_, record) => (
<Space>
<Button
type="link"
onClick={() => {
setEditingRow(record)
editForm.setFieldsValue({
status: record.status,
solution: record.solution,
})
}}
>
</Button>
<Button
type="link"
onClick={async () => {
await sysErrorApi.handleError(record.ID)
message.success('AI 处理任务已触发')
reloadRows()
}}
>
AI
</Button>
<Button
danger
type="link"
onClick={async () => {
await sysErrorApi.deleteError(record.ID)
message.success('错误日志已删除')
reloadRows()
}}
>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
AI
</Typography.Paragraph>
</div>
</div>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={rows}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Modal open={Boolean(editingRow)} title="编辑错误日志" onCancel={() => setEditingRow(null)} onOk={saveRow}>
<Form form={editForm} layout="vertical">
<Form.Item name="status" label="处理状态">
<Select
options={[
{ label: '未处理', value: '未处理' },
{ label: '处理中', value: '处理中' },
{ label: '处理完成', value: '处理完成' },
]}
/>
</Form.Item>
<Form.Item name="solution" label="解决方案">
<Input.TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Dropdown, Menu, Select, Space, Typography, message } from 'antd'
import type { ItemType } from 'antd/es/menu/interface'
import {
ApiOutlined,
AppstoreOutlined,
BookOutlined,
CodeOutlined,
DashboardOutlined,
LockOutlined,
PartitionOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import { authApi, menuApi } from '@/lib/api'
import { buildFullMenus, findDefaultRoute, flattenMenus, isExternalMenu } from '@/lib/menu'
import { useAuthStore } from '@/store/auth'
import type { AppMenu } from '@/types/system'
const iconMap: Record<string, React.ReactNode> = {
dashboard: <DashboardOutlined />,
authority: <LockOutlined />,
menu: <PartitionOutlined />,
api: <ApiOutlined />,
user: <UserOutlined />,
dictionary: <BookOutlined />,
sysParams: <SettingOutlined />,
system: <SettingOutlined />,
apiToken: <LockOutlined />,
loginLog: <AppstoreOutlined />,
systemTools: <CodeOutlined />,
common: <BookOutlined />,
}
function buildMenuItems(menus: AppMenu[]): ItemType[] {
return menus
.filter((menu) => !menu.hidden)
.map((menu) => {
const icon = iconMap[menu.name] || <AppstoreOutlined />
if (menu.children?.length) {
return {
key: menu.fullPathKey || menu.name,
icon,
label: menu.meta.title,
children: buildMenuItems(menu.children),
} satisfies ItemType
}
return {
key: menu.fullPathKey || menu.name,
icon,
label: menu.meta.title,
} satisfies ItemType
})
}
type Props = {
children: React.ReactNode
}
export function AdminShell({ children }: Props) {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore((state) => state.user)
const menus = useAuthStore((state) => state.menus)
const clearSession = useAuthStore((state) => state.clearSession)
const setUser = useAuthStore((state) => state.setUser)
const setMenus = useAuthStore((state) => state.setMenus)
const selectedKey = location.pathname.replace(/^\//, '')
const breadcrumb = useMemo(() => {
const target = flattenMenus(menus).find((menu) => menu.fullPath === location.pathname)
if (!target) {
return []
}
const chain: string[] = []
if (target.parentName) {
const parent = flattenMenus(menus).find((menu) => menu.name === target.parentName)
if (parent) {
chain.push(parent.meta.title)
}
}
chain.push(target.meta.title)
return chain
}, [location.pathname, menus])
const menuItems = useMemo(() => buildMenuItems(menus), [menus])
const handleMenuClick = ({ key }: { key: string }) => {
const target = flattenMenus(menus).find((menu) => menu.fullPathKey === key)
if (!target) {
return
}
if (isExternalMenu(target)) {
window.open(target.path, '_blank', 'noopener,noreferrer')
return
}
navigate(target.fullPath)
}
const handleRoleSwitch = async (value: number) => {
try {
await authApi.changeAuthority(value)
const [userInfo, menuRes] = await Promise.all([authApi.getUserInfo(), menuApi.getMenu()])
const mappedMenus = buildFullMenus(menuRes.data.menus)
setUser(userInfo.data.userInfo)
setMenus(mappedMenus)
message.success('角色切换成功')
navigate(findDefaultRoute(mappedMenus, userInfo.data.userInfo.authority?.defaultRouter) || '/dashboard')
} catch {
message.error('角色切换失败')
}
}
const handleLogout = async () => {
try {
await authApi.logout()
} catch {
// 忽略退出失败,统一清会话。
} finally {
clearSession()
navigate('/login', { replace: true })
}
}
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-brand">
<Typography.Text style={{ color: 'rgba(255,255,255,0.65)' }}>React Admin</Typography.Text>
<Typography.Title level={3} style={{ color: 'rgba(255,255,255,0.96)', margin: '8px 0 6px' }}>
RMK Control Deck
</Typography.Title>
<Typography.Paragraph style={{ color: 'rgba(255,255,255,0.72)', marginBottom: 0 }}>
Gin
</Typography.Paragraph>
</div>
<Menu
mode="inline"
theme="dark"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={handleMenuClick}
style={{ background: 'transparent', borderInlineEnd: 'none' }}
/>
</aside>
<main className="admin-main">
<div className="page-stack">
<div className="glass-panel page-panel" style={{ padding: 20 }}>
<div className="section-heading" style={{ marginBottom: 4 }}>
<div>
<Typography.Text className="text-muted">
{breadcrumb.length ? breadcrumb.join(' / ') : '后台总览'}
</Typography.Text>
<Typography.Title level={3} style={{ margin: '8px 0 0' }}>
{breadcrumb.at(-1) || '管理后台'}
</Typography.Title>
</div>
<Space size="middle" wrap>
{user?.authorities?.length ? (
<Select
value={user.authority?.authorityId}
options={user.authorities.map((item) => ({
label: `${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
}))}
style={{ minWidth: 220 }}
onChange={handleRoleSwitch}
/>
) : null}
<Dropdown
menu={{
items: [
{
key: 'profile',
label: '个人中心',
onClick: () => navigate('/person'),
},
{
key: 'logout',
label: '退出登录',
danger: true,
onClick: handleLogout,
},
],
}}
>
<Button type="text" style={{ height: 'auto', padding: 0 }}>
<Space>
<Avatar src={user?.headerImg} icon={<UserOutlined />} />
<span>{user?.nickName || user?.userName || '未登录'}</span>
</Space>
</Button>
</Dropdown>
</Space>
</div>
</div>
{children}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Card,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { authorityApi, menuApi } from '@/lib/api'
import { flattenAuthorities, flattenMenusForOptions } from '@/lib/tree'
import type { Authority, MenuNode } from '@/types/system'
export function MenuManagementPage() {
const [form] = Form.useForm()
const [menus, setMenus] = useState<MenuNode[]>([])
const [roles, setRoles] = useState<Authority[]>([])
const [loading, setLoading] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [editingMenu, setEditingMenu] = useState<MenuNode | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [activeMenu, setActiveMenu] = useState<MenuNode | null>(null)
const [selectedRoles, setSelectedRoles] = useState<number[]>([])
const [defaultRouterRoles, setDefaultRouterRoles] = useState<number[]>([])
const [savingRoles, setSavingRoles] = useState(false)
const roleOptions = useMemo(
() =>
flattenAuthorities(roles).map((item) => ({
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
})),
[roles],
)
const menuOptions = useMemo(
() =>
flattenMenusForOptions(menus).map((item) => ({
label: `${' '.repeat(item.depth)}${item.meta.title}`,
value: item.ID,
})),
[menus],
)
const reloadMenus = async () => {
setLoading(true)
try {
const [menuRes, roleRes] = await Promise.all([menuApi.getBaseMenuTree(), authorityApi.getAuthorityList()])
setMenus(menuRes.data.menus)
setRoles(roleRes.data)
} finally {
setLoading(false)
}
}
useEffect(() => {
reloadMenus()
}, [])
const openCreate = (parentId = 0) => {
setEditingMenu(null)
form.resetFields()
form.setFieldsValue({
parentId,
sort: 1,
hidden: false,
keepAlive: false,
closeTab: false,
})
setModalOpen(true)
}
const openEdit = async (record: MenuNode) => {
const response = await menuApi.getBaseMenuById(record.ID)
const menu = response.data.menu
setEditingMenu(menu)
form.setFieldsValue({
parentId: menu.parentId,
title: menu.meta.title,
name: menu.name,
path: menu.path,
component: menu.component,
icon: menu.meta.icon,
sort: menu.sort,
hidden: menu.hidden,
keepAlive: menu.meta.keepAlive,
closeTab: menu.meta.closeTab,
})
setModalOpen(true)
}
const saveMenu = async () => {
const values = await form.validateFields()
const payload = {
ID: editingMenu?.ID,
parentId: values.parentId || 0,
name: values.name,
path: values.path,
component: values.component,
sort: values.sort,
hidden: Boolean(values.hidden),
meta: {
title: values.title,
icon: values.icon,
keepAlive: Boolean(values.keepAlive),
closeTab: Boolean(values.closeTab),
},
}
setSaving(true)
try {
if (editingMenu) {
await menuApi.updateBaseMenu(payload)
message.success('菜单已更新')
} else {
await menuApi.addBaseMenu(payload)
message.success('菜单已创建')
}
setModalOpen(false)
reloadMenus()
} finally {
setSaving(false)
}
}
const deleteMenu = (record: MenuNode) => {
Modal.confirm({
title: `删除菜单 ${record.meta.title}`,
content: '如果该菜单已被角色使用,删除前请先调整菜单授权。',
okButtonProps: { danger: true },
onOk: async () => {
await menuApi.deleteBaseMenu(record.ID)
message.success('菜单已删除')
reloadMenus()
},
})
}
const openRoleDrawer = async (record: MenuNode) => {
const response = await menuApi.getMenuRoles(record.ID)
setActiveMenu(record)
setSelectedRoles(response.data.authorityIds)
setDefaultRouterRoles(response.data.defaultRouterAuthorityIds)
setDrawerOpen(true)
}
const saveRoles = async () => {
if (!activeMenu) {
return
}
setSavingRoles(true)
try {
await menuApi.setMenuRoles({
menuId: activeMenu.ID,
authorityIds: selectedRoles,
})
message.success('菜单角色关系已更新')
setDrawerOpen(false)
} finally {
setSavingRoles(false)
}
}
const columns: ColumnsType<MenuNode> = [
{ title: 'ID', dataIndex: 'ID', width: 80 },
{
title: '展示名称',
width: 180,
render: (_, record) => record.meta.title,
},
{ title: '路由 Name', dataIndex: 'name', width: 140 },
{ title: '路由 Path', dataIndex: 'path', width: 180 },
{ title: '组件路径', dataIndex: 'component', width: 280 },
{
title: '状态',
width: 180,
render: (_, record) => (
<Space wrap>
<Tag color={record.hidden ? 'default' : 'green'}>{record.hidden ? '隐藏' : '显示'}</Tag>
{record.meta.keepAlive ? <Tag color="blue">KeepAlive</Tag> : null}
</Space>
),
},
{
title: '操作',
key: 'actions',
width: 260,
fixed: 'right',
render: (_, record) => (
<Space wrap>
<Button type="link" onClick={() => openCreate(record.ID)}>
</Button>
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button type="link" onClick={() => openRoleDrawer(record)}>
</Button>
<Button danger type="link" onClick={() => deleteMenu(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
React
</Typography.Paragraph>
</div>
<Button type="primary" onClick={() => openCreate()}>
</Button>
</div>
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={menus}
expandable={{ defaultExpandAllRows: true }}
pagination={false}
scroll={{ x: 1300 }}
/>
</Card>
<Modal
open={modalOpen}
title={editingMenu ? '编辑菜单' : '新建菜单'}
onCancel={() => setModalOpen(false)}
onOk={saveMenu}
confirmLoading={saving}
width={760}
>
<Form form={form} layout="vertical">
<Form.Item name="parentId" label="父菜单">
<Select options={[{ label: '根菜单', value: 0 }, ...menuOptions]} />
</Form.Item>
<Form.Item name="title" label="展示名称" rules={[{ required: true, message: '请输入展示名称' }]}>
<Input />
</Form.Item>
<Space.Compact block>
<Form.Item
name="name"
label="路由 Name"
rules={[{ required: true, message: '请输入路由 Name' }]}
style={{ width: '50%' }}
>
<Input />
</Form.Item>
<Form.Item
name="path"
label="路由 Path"
rules={[{ required: true, message: '请输入路由 Path' }]}
style={{ width: '50%' }}
>
<Input />
</Form.Item>
</Space.Compact>
<Form.Item
name="component"
label="组件路径"
rules={[{ required: true, message: '请输入组件路径' }]}
>
<Input placeholder="例如view/superAdmin/user/user.vue" />
</Form.Item>
<Space.Compact block>
<Form.Item name="icon" label="图标名" style={{ width: '60%' }}>
<Input />
</Form.Item>
<Form.Item name="sort" label="排序" style={{ width: '40%' }}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Space.Compact>
<Space size="large">
<Form.Item name="hidden" label="隐藏" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="keepAlive" label="KeepAlive" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="closeTab" label="自动关闭 Tab" valuePropName="checked">
<Switch />
</Form.Item>
</Space>
</Form>
</Modal>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={activeMenu ? `菜单角色分配 · ${activeMenu.meta.title}` : '菜单角色分配'}
width={520}
extra={
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
</Button>
}
>
{defaultRouterRoles.length ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`${defaultRouterRoles.length} 个角色将当前菜单设为默认首页,调整前请确认首页策略。`}
/>
) : null}
<Select
mode="multiple"
style={{ width: '100%' }}
value={selectedRoles}
options={roleOptions}
onChange={setSelectedRoles}
/>
</Drawer>
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Card,
Col,
Form,
Input,
Modal,
Row,
Space,
Table,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { sysParamsApi } from '@/lib/api'
import { formatDate } from '@/lib/date'
import type { SysParam } from '@/types/system'
export function ParamsManagementPage() {
const [searchForm] = Form.useForm()
const [editForm] = Form.useForm()
const [params, setParams] = useState<SysParam[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [modalOpen, setModalOpen] = useState(false)
const [editingParam, setEditingParam] = useState<SysParam | null>(null)
const reloadParams = useCallback(async () => {
setLoading(true)
try {
const response = await sysParamsApi.getParamsList({
page,
pageSize,
...searchForm.getFieldsValue(),
})
setParams(response.data.list)
setTotal(response.data.total)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
useEffect(() => {
reloadParams()
}, [reloadParams])
const openCreate = () => {
setEditingParam(null)
editForm.resetFields()
setModalOpen(true)
}
const openEdit = (record: SysParam) => {
setEditingParam(record)
editForm.setFieldsValue(record)
setModalOpen(true)
}
const saveParam = async () => {
const values = await editForm.validateFields()
if (editingParam) {
await sysParamsApi.updateParam({ ID: editingParam.ID, ...values })
message.success('参数已更新')
} else {
await sysParamsApi.createParam(values)
message.success('参数已创建')
}
setModalOpen(false)
reloadParams()
}
const deleteParam = (record: SysParam) => {
Modal.confirm({
title: `删除参数 ${record.key}`,
okButtonProps: { danger: true },
onOk: async () => {
await sysParamsApi.deleteParam(record.ID)
message.success('参数已删除')
reloadParams()
},
})
}
const columns: ColumnsType<SysParam> = [
{
title: '创建时间',
width: 180,
render: (_, record) => formatDate(record.CreatedAt),
},
{ title: '参数名称', dataIndex: 'name', width: 160 },
{ title: '参数键', dataIndex: 'key', width: 180 },
{ title: '参数值', dataIndex: 'value' },
{ title: '说明', dataIndex: 'desc', width: 200 },
{
title: '操作',
key: 'actions',
width: 180,
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button danger type="link" onClick={() => deleteParam(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</div>
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadParams() }}>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="name" label="参数名称">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="key" label="参数键">
<Input />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
searchForm.resetFields()
setPage(1)
reloadParams()
}}
>
</Button>
</Space>
</Form>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={params}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Modal
open={modalOpen}
title={editingParam ? '编辑参数' : '新建参数'}
onCancel={() => setModalOpen(false)}
onOk={saveParam}
>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="参数名称" rules={[{ required: true, message: '请输入参数名称' }]}>
<Input />
</Form.Item>
<Form.Item name="key" label="参数键" rules={[{ required: true, message: '请输入参数键' }]}>
<Input />
</Form.Item>
<Form.Item name="value" label="参数值" rules={[{ required: true, message: '请输入参数值' }]}>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="desc" label="说明">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Avatar, Button, Card, Col, Form, Input, Row, Typography, message } from 'antd'
import { authApi } from '@/lib/api'
import { useAuthStore } from '@/store/auth'
export function ProfilePage() {
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm()
const [savingProfile, setSavingProfile] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const submitProfile = async () => {
const values = await profileForm.validateFields()
setSavingProfile(true)
try {
await authApi.setSelfInfo({
ID: user?.ID,
nickName: values.nickName,
phone: values.phone,
email: values.email,
headerImg: values.headerImg,
})
setUser({
...(user || {}),
nickName: values.nickName,
phone: values.phone,
email: values.email,
headerImg: values.headerImg,
} as typeof user)
message.success('个人资料已更新')
} finally {
setSavingProfile(false)
}
}
const submitPassword = async () => {
const values = await passwordForm.validateFields()
setSavingPassword(true)
try {
await authApi.changePassword({
password: values.password,
newPassword: values.newPassword,
})
passwordForm.resetFields()
message.success('密码已修改')
} finally {
setSavingPassword(false)
}
}
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
使
</Typography.Paragraph>
</div>
<Avatar src={user?.headerImg} size={72}>
{user?.nickName?.[0] || user?.userName?.[0]}
</Avatar>
</div>
</Card>
<Row gutter={[20, 20]}>
<Col xs={24} xl={14}>
<Card className="glass-panel page-panel" title="资料维护">
<Form
layout="vertical"
form={profileForm}
initialValues={{
userName: user?.userName,
nickName: user?.nickName,
phone: user?.phone,
email: user?.email,
headerImg: user?.headerImg,
}}
>
<Form.Item label="用户名" name="userName">
<Input disabled />
</Form.Item>
<Form.Item label="昵称" name="nickName" rules={[{ required: true, message: '请输入昵称' }]}>
<Input />
</Form.Item>
<Form.Item label="手机号" name="phone">
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email">
<Input />
</Form.Item>
<Form.Item label="头像 URL" name="headerImg">
<Input />
</Form.Item>
<Button type="primary" loading={savingProfile} onClick={submitProfile}>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} xl={10}>
<Card className="glass-panel page-panel" title="修改密码">
<Form layout="vertical" form={passwordForm}>
<Form.Item label="原密码" name="password" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password />
</Form.Item>
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
<Input.Password />
</Form.Item>
<Button type="primary" loading={savingPassword} onClick={submitPassword}>
</Button>
</Form>
</Card>
</Col>
</Row>
</div>
)
}

View File

@@ -0,0 +1,397 @@
import { useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Tree,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { DataNode } from 'antd/es/tree'
import { apiRegistryApi, authorityApi, casbinApi, menuApi, userApi } from '@/lib/api'
import { collectCheckedLeafMenus, flattenAuthorities } from '@/lib/tree'
import type { ApiRecord, Authority, MenuNode, UserInfo } from '@/types/system'
type PermissionTab = 'menus' | 'apis' | 'users'
function mapMenusToTree(menus: MenuNode[]): DataNode[] {
return menus.map((menu) => ({
key: menu.ID,
title: menu.meta.title,
children: mapMenusToTree(menu.children || []),
}))
}
function mapApisToTree(apis: ApiRecord[]): DataNode[] {
const grouped = apis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
if (!accumulator[api.apiGroup]) {
accumulator[api.apiGroup] = []
}
accumulator[api.apiGroup].push(api)
return accumulator
}, {})
return Object.entries(grouped).map(([group, items]) => ({
key: `group:${group}`,
title: `${group}`,
children: items.map((item) => ({
key: `${item.path}::${item.method}`,
title: `${item.method} ${item.path} · ${item.description}`,
})),
}))
}
export function RoleManagementPage() {
const [form] = Form.useForm()
const [roles, setRoles] = useState<Authority[]>([])
const [menuTree, setMenuTree] = useState<MenuNode[]>([])
const [apis, setApis] = useState<ApiRecord[]>([])
const [users, setUsers] = useState<UserInfo[]>([])
const [roleModalOpen, setRoleModalOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [savingRole, setSavingRole] = useState(false)
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<PermissionTab>('menus')
const [editingRole, setEditingRole] = useState<Authority | null>(null)
const [copySource, setCopySource] = useState<Authority | null>(null)
const [activeRole, setActiveRole] = useState<Authority | null>(null)
const [menuChecked, setMenuChecked] = useState<number[]>([])
const [defaultRouter, setDefaultRouter] = useState<string>()
const [apiChecked, setApiChecked] = useState<string[]>([])
const [userIds, setUserIds] = useState<number[]>([])
const [savingPermission, setSavingPermission] = useState(false)
const roleOptions = useMemo(
() =>
flattenAuthorities(roles).map((item) => ({
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
})),
[roles],
)
const reloadRoles = async () => {
setLoading(true)
try {
const response = await authorityApi.getAuthorityList()
setRoles(response.data)
} finally {
setLoading(false)
}
}
useEffect(() => {
reloadRoles()
}, [])
const openCreate = (parentId?: number) => {
setEditingRole(null)
setCopySource(null)
form.resetFields()
form.setFieldsValue({ parentId: parentId ?? 0 })
setRoleModalOpen(true)
}
const openEdit = (record: Authority) => {
setEditingRole(record)
setCopySource(null)
form.setFieldsValue({
authorityId: record.authorityId,
authorityName: record.authorityName,
parentId: record.parentId ?? 0,
})
setRoleModalOpen(true)
}
const openCopy = (record: Authority) => {
setEditingRole(null)
setCopySource(record)
form.setFieldsValue({
authorityId: undefined,
authorityName: `${record.authorityName}-副本`,
parentId: record.parentId ?? 0,
})
setRoleModalOpen(true)
}
const saveRole = async () => {
const values = await form.validateFields()
setSavingRole(true)
try {
if (copySource) {
await authorityApi.copyAuthority({
oldAuthorityId: copySource.authorityId,
authority: {
authorityId: values.authorityId,
authorityName: values.authorityName,
parentId: values.parentId,
},
})
message.success('角色已复制')
} else if (editingRole) {
await authorityApi.updateAuthority({
authorityId: editingRole.authorityId,
authorityName: values.authorityName,
parentId: values.parentId,
defaultRouter: editingRole.defaultRouter,
})
message.success('角色已更新')
} else {
await authorityApi.createAuthority({
authorityId: values.authorityId,
authorityName: values.authorityName,
parentId: values.parentId,
})
message.success('角色已创建')
}
setRoleModalOpen(false)
reloadRoles()
} finally {
setSavingRole(false)
}
}
const deleteRole = (record: Authority) => {
Modal.confirm({
title: `删除角色 ${record.authorityName}`,
content: '删除前请确认没有用户正在依赖该角色。',
okButtonProps: { danger: true },
onOk: async () => {
await authorityApi.deleteAuthority(record.authorityId)
message.success('角色已删除')
reloadRoles()
},
})
}
const openPermissionDrawer = async (record: Authority) => {
setActiveRole(record)
setDrawerOpen(true)
const [menuRes, checkedMenusRes, apiRes, policyRes, userRes, roleUserRes] = await Promise.all([
menuApi.getBaseMenuTree(),
menuApi.getMenuAuthority(record.authorityId),
apiRegistryApi.getAllApis(),
casbinApi.getPolicyPathByAuthorityId(record.authorityId),
userApi.getUserList({ page: 1, pageSize: 999 }),
authorityApi.getUsersByAuthorityId(record.authorityId),
])
setMenuTree(menuRes.data.menus)
setMenuChecked(
checkedMenusRes.data.menus.map((item) => Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID)),
)
setDefaultRouter(record.defaultRouter)
setApis(apiRes.data.apis)
setApiChecked(policyRes.data.paths.map((item) => `${item.path}::${item.method}`))
setUsers(userRes.data.list)
setUserIds(roleUserRes.data)
}
const saveCurrentPermission = async () => {
if (!activeRole) {
return
}
setSavingPermission(true)
try {
if (activeTab === 'menus') {
const checkedMenus = collectCheckedLeafMenus(menuTree, new Set(menuChecked))
await menuApi.addMenuAuthority({
authorityId: activeRole.authorityId,
menus: checkedMenus,
})
await authorityApi.updateAuthority({
authorityId: activeRole.authorityId,
authorityName: activeRole.authorityName,
parentId: activeRole.parentId,
defaultRouter,
})
message.success('菜单权限已保存')
} else if (activeTab === 'apis') {
const selectedApis = apis
.filter((item) => apiChecked.includes(`${item.path}::${item.method}`))
.map((item) => ({ path: item.path, method: item.method }))
await casbinApi.updateCasbin({
authorityId: activeRole.authorityId,
casbinInfos: selectedApis,
})
message.success('API 权限已保存')
} else {
await authorityApi.setRoleUsers({
authorityId: activeRole.authorityId,
userIds,
})
message.success('角色用户关系已保存')
}
reloadRoles()
} finally {
setSavingPermission(false)
}
}
const menuOptions = useMemo(
() =>
collectCheckedLeafMenus(menuTree, new Set(menuChecked)).map((menu) => ({
label: menu.meta.title,
value: menu.name,
})),
[menuChecked, menuTree],
)
const columns: ColumnsType<Authority> = [
{
title: '角色 ID',
dataIndex: 'authorityId',
width: 120,
},
{
title: '角色名称',
dataIndex: 'authorityName',
width: 200,
},
{
title: '默认首页',
dataIndex: 'defaultRouter',
width: 140,
render: (value: string | undefined) => value || '-',
},
{
title: '操作',
key: 'actions',
width: 320,
render: (_, record) => (
<Space wrap>
<Button type="link" onClick={() => openPermissionDrawer(record)}>
</Button>
<Button type="link" onClick={() => openCreate(record.authorityId)}>
</Button>
<Button type="link" onClick={() => openCopy(record)}>
</Button>
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button danger type="link" onClick={() => deleteRole(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
API
</Typography.Paragraph>
</div>
<Button type="primary" onClick={() => openCreate()}>
</Button>
</div>
<Table
rowKey="authorityId"
loading={loading}
columns={columns}
dataSource={roles}
expandable={{ defaultExpandAllRows: true }}
pagination={false}
/>
</Card>
<Modal
open={roleModalOpen}
title={copySource ? `复制角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新建角色'}
onCancel={() => setRoleModalOpen(false)}
onOk={saveRole}
confirmLoading={savingRole}
>
<Form form={form} layout="vertical">
<Form.Item name="parentId" label="父角色">
<Select options={[{ label: '根角色', value: 0 }, ...roleOptions]} />
</Form.Item>
<Form.Item name="authorityId" label="角色 ID" rules={[{ required: true, message: '请输入角色 ID' }]}>
<Input />
</Form.Item>
<Form.Item name="authorityName" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input />
</Form.Item>
</Form>
</Modal>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={900}
title={activeRole ? `角色权限 · ${activeRole.authorityName}` : '角色权限'}
extra={
<Button type="primary" loading={savingPermission} onClick={saveCurrentPermission}>
</Button>
}
>
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as PermissionTab)}>
<Tabs.TabPane tab="菜单权限" key="menus">
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Select
value={defaultRouter}
options={menuOptions}
allowClear
placeholder="请选择默认首页"
onChange={(value) => setDefaultRouter(value)}
/>
<Tree
checkable
defaultExpandAll
checkedKeys={menuChecked}
treeData={mapMenusToTree(menuTree)}
onCheck={(checkedKeys) => setMenuChecked((checkedKeys as number[]).map((item) => Number(item)))}
/>
</Space>
</Tabs.TabPane>
<Tabs.TabPane tab="API 权限" key="apis">
<Tree
checkable
defaultExpandAll
checkedKeys={apiChecked}
treeData={mapApisToTree(apis)}
onCheck={(checkedKeys) => setApiChecked((checkedKeys as string[]).filter((item) => !item.startsWith('group:')))}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="角色用户" key="users">
<Select
mode="multiple"
style={{ width: '100%' }}
value={userIds}
options={users.map((user) => ({
label: `${user.nickName} (${user.userName})`,
value: user.ID,
}))}
onChange={(value) => setUserIds(value)}
/>
<div style={{ marginTop: 12 }}>
<Tag>{userIds.length} </Tag>
</div>
</Tabs.TabPane>
</Tabs>
</Drawer>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { Card, Descriptions, Skeleton, Typography } from 'antd'
import { inventoryApi } from '@/lib/api'
function renderValue(value: unknown) {
if (value === null || value === undefined) {
return '-'
}
if (typeof value === 'object') {
return (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(value, null, 2)}
</pre>
)
}
return String(value)
}
export function ServerStatePage() {
const [server, setServer] = useState<Record<string, unknown> | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let ignore = false
inventoryApi
.getServerInfo()
.then((response) => {
if (!ignore) {
setServer(response.data.server)
}
})
.finally(() => {
if (!ignore) {
setLoading(false)
}
})
return () => {
ignore = true
}
}, [])
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
`/system/getServerInfo`
</Typography.Paragraph>
</Card>
<Card className="glass-panel page-panel">
{loading ? (
<Skeleton active />
) : (
<Descriptions bordered column={1} size="middle">
{Object.entries(server || {}).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{renderValue(value)}
</Descriptions.Item>
))}
</Descriptions>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Col, Row, Space, Statistic, Tabs, Typography, message } from 'antd'
import { systemApi } from '@/lib/api'
export function SystemConfigPage() {
const [config, setConfig] = useState<Record<string, unknown>>({})
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const summary = useMemo(() => {
const system = (config.system || {}) as Record<string, unknown>
return {
addr: system.addr || '-',
dbType: system['db-type'] || '-',
routerPrefix: system['router-prefix'] || '/',
strictAuth: String(system['use-strict-auth'] ?? false),
}
}, [config])
const reloadConfig = async () => {
setLoading(true)
try {
const response = await systemApi.getSystemConfig()
setConfig(response.data.config)
setText(JSON.stringify(response.data.config, null, 2))
} finally {
setLoading(false)
}
}
useEffect(() => {
reloadConfig()
}, [])
const saveConfig = async () => {
const parsed = JSON.parse(text) as Record<string, unknown>
await systemApi.setSystemConfig(parsed)
message.success('配置已保存')
setConfig(parsed)
}
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
JSON
</Typography.Paragraph>
</div>
<Space>
<Button onClick={reloadConfig} loading={loading}>
</Button>
<Button
onClick={async () => {
await systemApi.reloadSystem()
message.success('系统重载命令已发送')
}}
>
</Button>
<Button type="primary" onClick={saveConfig}>
</Button>
</Space>
</div>
<Row gutter={16}>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="服务端口" value={String(summary.addr)} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="数据库类型" value={String(summary.dbType)} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="路由前缀" value={String(summary.routerPrefix)} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="严格权限模式" value={String(summary.strictAuth)} />
</Card>
</Col>
</Row>
</Card>
<Card className="glass-panel page-panel" loading={loading}>
<Tabs
items={[
{
key: 'editor',
label: 'JSON 编辑器',
children: (
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
style={{
width: '100%',
minHeight: 540,
borderRadius: 16,
border: '1px solid rgba(16, 37, 66, 0.12)',
padding: 16,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 14,
}}
/>
),
},
{
key: 'preview',
label: '结构预览',
children: (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(config, null, 2)}
</pre>
),
},
]}
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Card,
Col,
Form,
Input,
Modal,
Row,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { apiTokenApi, userApi } from '@/lib/api'
import { formatDate } from '@/lib/date'
import type { ApiTokenRecord, UserInfo } from '@/types/system'
export function ApiTokenPage() {
const [searchForm] = Form.useForm()
const [issueForm] = Form.useForm()
const [rows, setRows] = useState<ApiTokenRecord[]>([])
const [users, setUsers] = useState<UserInfo[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [modalOpen, setModalOpen] = useState(false)
const [issuedToken, setIssuedToken] = useState<string>('')
const selectedUser = useMemo(
() => users.find((item) => item.ID === issueForm.getFieldValue('userId')),
[issueForm, users],
)
const reloadRows = useCallback(async () => {
setLoading(true)
try {
const response = await apiTokenApi.getTokenList({
page,
pageSize,
...searchForm.getFieldsValue(),
})
setRows(response.data.list)
setTotal(response.data.total)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
const loadUsers = useCallback(async () => {
const response = await userApi.getUserList({ page: 1, pageSize: 999 })
setUsers(response.data.list)
}, [])
useEffect(() => {
reloadRows()
loadUsers()
}, [loadUsers, reloadRows])
const issueToken = async () => {
const values = await issueForm.validateFields()
const response = await apiTokenApi.createToken(values)
setIssuedToken(response.data.token)
setModalOpen(false)
issueForm.resetFields()
message.success('Token 已签发')
reloadRows()
}
const columns: ColumnsType<ApiTokenRecord> = [
{ title: 'ID', dataIndex: 'ID', width: 80 },
{
title: '用户',
width: 220,
render: (_, record) => `${record.user?.nickName || '-'} (${record.user?.userName || '-'})`,
},
{ title: '角色 ID', dataIndex: 'authorityId', width: 100 },
{
title: '状态',
width: 100,
render: (_, record) => <Tag color={record.status ? 'green' : 'red'}>{record.status ? '有效' : '已作废'}</Tag>,
},
{
title: '过期时间',
width: 180,
render: (_, record) => formatDate(record.expiresAt),
},
{ title: '备注', dataIndex: 'remark' },
{
title: '操作',
width: 120,
render: (_, record) => (
<Button
danger
type="link"
disabled={!record.status}
onClick={async () => {
await apiTokenApi.deleteToken(record.ID)
message.success('Token 已作废')
reloadRows()
}}
>
</Button>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
API Token
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
访 JWT
</Typography.Paragraph>
</div>
<Button type="primary" onClick={() => setModalOpen(true)}>
Token
</Button>
</div>
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); reloadRows() }}>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="userId" label="用户 ID">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="status" label="状态">
<Select allowClear options={[{ label: '有效', value: true }, { label: '无效', value: false }]} />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
searchForm.resetFields()
setPage(1)
reloadRows()
}}
>
</Button>
</Space>
</Form>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={rows}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Modal open={modalOpen} title="签发 API Token" onCancel={() => setModalOpen(false)} onOk={issueToken}>
<Form form={issueForm} layout="vertical" initialValues={{ days: 30 }}>
<Form.Item name="userId" label="用户" rules={[{ required: true, message: '请选择用户' }]}>
<Select
options={users.map((user) => ({
label: `${user.nickName} (${user.userName})`,
value: user.ID,
}))}
/>
</Form.Item>
<Form.Item name="authorityId" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select
options={(selectedUser?.authorities || []).map((authority) => ({
label: `${authority.authorityName} (${authority.authorityId})`,
value: authority.authorityId,
}))}
/>
</Form.Item>
<Form.Item name="days" label="有效期">
<Select
options={[
{ label: '1 天', value: 1 },
{ label: '7 天', value: 7 },
{ label: '30 天', value: 30 },
{ label: '90 天', value: 90 },
{ label: '永久', value: -1 },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Modal open={Boolean(issuedToken)} title="签发结果" footer={null} onCancel={() => setIssuedToken('')}>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="请立即复制保存。关闭后列表中不会再显示完整 Token。"
/>
<Input.TextArea value={issuedToken} rows={6} readOnly />
<Space style={{ marginTop: 12 }}>
<Button
onClick={async () => {
await navigator.clipboard.writeText(issuedToken)
message.success('Token 已复制')
}}
>
</Button>
<Button type="primary" onClick={() => setIssuedToken('')}>
</Button>
</Space>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,400 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Col,
Form,
Input,
Modal,
Row,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { authorityApi, userApi } from '@/lib/api'
import { flattenAuthorities } from '@/lib/tree'
import type { Authority, UserInfo } from '@/types/system'
type UserSearch = {
username?: string
nickName?: string
phone?: string
email?: string
}
export function UserManagementPage() {
const [searchForm] = Form.useForm<UserSearch>()
const [editForm] = Form.useForm()
const [passwordForm] = Form.useForm()
const [users, setUsers] = useState<UserInfo[]>([])
const [authorities, setAuthorities] = useState<Authority[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [modalOpen, setModalOpen] = useState(false)
const [passwordOpen, setPasswordOpen] = useState(false)
const [editingUser, setEditingUser] = useState<UserInfo | null>(null)
const [saving, setSaving] = useState(false)
const [passwordSaving, setPasswordSaving] = useState(false)
const authorityOptions = useMemo(
() =>
flattenAuthorities(authorities).map((item) => ({
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
value: item.authorityId,
})),
[authorities],
)
const loadAuthorities = useCallback(async () => {
const response = await authorityApi.getAuthorityList()
setAuthorities(response.data)
}, [])
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const values = searchForm.getFieldsValue()
const response = await userApi.getUserList({
page,
pageSize,
...values,
})
setUsers(response.data.list)
setTotal(response.data.total)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
useEffect(() => {
loadAuthorities()
}, [loadAuthorities])
useEffect(() => {
loadUsers()
}, [loadUsers])
const openCreate = () => {
setEditingUser(null)
editForm.resetFields()
editForm.setFieldsValue({
enable: true,
authorityIds: [],
})
setModalOpen(true)
}
const openEdit = (record: UserInfo) => {
setEditingUser(record)
editForm.setFieldsValue({
userName: record.userName,
nickName: record.nickName,
phone: record.phone,
email: record.email,
headerImg: record.headerImg,
authorityIds:
record.authorities?.map((item) => item.authorityId) ||
(record.authorityId ? [record.authorityId] : []),
enable: (record.enable || 1) === 1,
})
setModalOpen(true)
}
const saveUser = async () => {
const values = await editForm.validateFields()
const authorityIds: number[] = values.authorityIds || []
if (!authorityIds.length) {
message.warning('至少选择一个角色')
return
}
setSaving(true)
try {
const payload = {
ID: editingUser?.ID,
userName: values.userName,
password: values.password,
nickName: values.nickName,
phone: values.phone,
email: values.email,
headerImg: values.headerImg,
authorityId: authorityIds[0],
authorityIds,
enable: values.enable ? 1 : 2,
}
if (editingUser) {
await userApi.updateUser(payload)
message.success('用户信息已更新')
} else {
await userApi.register(payload)
message.success('用户已创建')
}
setModalOpen(false)
loadUsers()
} finally {
setSaving(false)
}
}
const deleteUser = (record: UserInfo) => {
Modal.confirm({
title: `删除用户 ${record.userName}`,
content: '删除后无法恢复。',
okText: '删除',
okButtonProps: { danger: true },
onOk: async () => {
await userApi.deleteUser(record.ID)
message.success('用户已删除')
loadUsers()
},
})
}
const openResetPassword = (record: UserInfo) => {
setEditingUser(record)
passwordForm.resetFields()
setPasswordOpen(true)
}
const savePassword = async () => {
const values = await passwordForm.validateFields()
if (!editingUser) {
return
}
setPasswordSaving(true)
try {
await userApi.resetPassword({
ID: editingUser.ID,
password: values.password,
})
message.success('密码已重置')
setPasswordOpen(false)
} finally {
setPasswordSaving(false)
}
}
const columns: ColumnsType<UserInfo> = [
{
title: 'ID',
dataIndex: 'ID',
width: 80,
},
{
title: '用户名',
dataIndex: 'userName',
width: 140,
},
{
title: '昵称',
dataIndex: 'nickName',
width: 140,
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
},
{
title: '邮箱',
dataIndex: 'email',
width: 220,
},
{
title: '角色',
width: 260,
render: (_, record) => (
<Space wrap>
{(record.authorities?.length ? record.authorities : record.authority ? [record.authority] : []).map((item) => (
<Tag key={item.authorityId}>{item.authorityName}</Tag>
))}
</Space>
),
},
{
title: '状态',
width: 100,
render: (_, record) => (
<Tag color={(record.enable || 1) === 1 ? 'green' : 'red'}>
{(record.enable || 1) === 1 ? '启用' : '停用'}
</Tag>
),
},
{
title: '操作',
key: 'actions',
width: 220,
fixed: 'right',
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openEdit(record)}>
</Button>
<Button type="link" onClick={() => openResetPassword(record)}>
</Button>
<Button danger type="link" onClick={() => deleteUser(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
`/user/*` `/authority/getAuthorityList`
</Typography.Paragraph>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</div>
<Form form={searchForm} layout="vertical" onFinish={() => { setPage(1); loadUsers() }}>
<Row gutter={[16, 0]}>
<Col xs={24} md={6}>
<Form.Item name="username" label="用户名">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="nickName" label="昵称">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="phone" label="手机号">
<Input />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
searchForm.resetFields()
setPage(1)
loadUsers()
}}
>
</Button>
</Space>
</Form>
</Card>
<Card className="glass-panel page-panel">
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={users}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
<Modal
open={modalOpen}
title={editingUser ? '编辑用户' : '新建用户'}
onCancel={() => setModalOpen(false)}
onOk={saveUser}
confirmLoading={saving}
width={720}
>
<Form form={editForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="userName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input disabled={Boolean(editingUser)} />
</Form.Item>
</Col>
{!editingUser ? (
<Col span={12}>
<Form.Item name="password" label="登录密码" rules={[{ required: true, min: 6, message: '至少 6 位' }]}>
<Input.Password />
</Form.Item>
</Col>
) : null}
<Col span={12}>
<Form.Item name="nickName" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="phone" label="手机号">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="headerImg" label="头像 URL">
<Input />
</Form.Item>
</Col>
<Col span={18}>
<Form.Item name="authorityIds" label="角色集合" rules={[{ required: true, message: '请选择角色' }]}>
<Select mode="multiple" options={authorityOptions} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="enable" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
open={passwordOpen}
title={`重置密码${editingUser ? ` · ${editingUser.userName}` : ''}`}
onCancel={() => setPasswordOpen(false)}
onOk={savePassword}
confirmLoading={passwordSaving}
>
<Form form={passwordForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
)
}

250
web-admin/src/index.css Normal file
View File

@@ -0,0 +1,250 @@
:root {
color: #102542;
background:
radial-gradient(circle at top left, rgba(209, 111, 63, 0.18), transparent 30%),
radial-gradient(circle at top right, rgba(17, 138, 178, 0.18), transparent 28%),
linear-gradient(180deg, #f8f4ef 0%, #f2ede7 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--surface: rgba(255, 255, 255, 0.78);
--surface-strong: rgba(255, 255, 255, 0.92);
--line: rgba(16, 37, 66, 0.08);
--text-main: #102542;
--text-secondary: #536277;
--text-light: rgba(255, 255, 255, 0.86);
--accent: #d16f3f;
--accent-soft: rgba(209, 111, 63, 0.12);
--success-soft: rgba(31, 157, 120, 0.14);
--shadow-lg: 0 24px 64px rgba(16, 37, 66, 0.12);
--shadow-md: 0 12px 32px rgba(16, 37, 66, 0.08);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 320px;
min-height: 100%;
margin: 0;
}
body {
font-family:
"Avenir Next",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
color: var(--text-main);
}
a {
color: inherit;
text-decoration: none;
}
img {
display: block;
max-width: 100%;
}
.fullscreen-status {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: center;
}
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 312px 1fr;
}
.admin-sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 24px 18px;
background:
linear-gradient(180deg, rgba(16, 37, 66, 0.96) 0%, rgba(19, 51, 84, 0.94) 100%);
color: var(--text-light);
overflow-y: auto;
}
.admin-brand {
padding: 18px;
border-radius: 24px;
background:
linear-gradient(135deg, rgba(209, 111, 63, 0.22), rgba(255, 255, 255, 0.06)),
rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
margin-bottom: 18px;
}
.admin-main {
padding: 24px;
}
.glass-panel {
background: var(--surface);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.48);
box-shadow: var(--shadow-md);
}
.page-panel {
border-radius: 28px;
padding: 24px;
}
.page-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.hero-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 20px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric-card {
border-radius: 22px;
padding: 20px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.login-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 1.2fr 0.9fr;
}
.login-hero {
padding: 56px;
background:
radial-gradient(circle at 20% 20%, rgba(209, 111, 63, 0.28), transparent 28%),
radial-gradient(circle at 80% 15%, rgba(60, 135, 180, 0.22), transparent 30%),
linear-gradient(160deg, #0f2746 0%, #16365d 48%, #214a73 100%);
color: rgba(255, 255, 255, 0.92);
position: relative;
overflow: hidden;
}
.login-form-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.login-card {
width: min(460px, 100%);
border-radius: 32px;
padding: 28px;
}
.capsule {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.92);
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.catalog-card {
border-radius: 22px;
padding: 18px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--line);
transition: transform 160ms ease, box-shadow 160ms ease;
}
.catalog-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.text-muted {
color: var(--text-secondary);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.status-dot.ready {
background: #1f9d78;
}
.status-dot.partial {
background: #d29b2f;
}
.status-dot.planned {
background: #8896a8;
}
@media (max-width: 1280px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalog-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.admin-shell,
.login-shell,
.hero-grid {
grid-template-columns: 1fr;
}
.admin-sidebar {
position: static;
height: auto;
}
.metric-grid,
.catalog-grid {
grid-template-columns: 1fr;
}
}

275
web-admin/src/lib/api.ts Normal file
View File

@@ -0,0 +1,275 @@
import { http } from './http'
import type {
ApiRecord,
ApiTokenRecord,
Authority,
CaptchaInfo,
Dictionary,
DictionaryDetail,
LoginLog,
LoginResult,
MenuNode,
OperationRecord,
PagePayload,
SysErrorRecord,
SysParam,
UserInfo,
} from '@/types/system'
export const authApi = {
getCaptcha() {
return http.post<CaptchaInfo>('/base/captcha')
},
login(payload: { username: string; password: string; captcha?: string; captchaId?: string }) {
return http.post<LoginResult>('/base/login', payload)
},
getUserInfo() {
return http.get<{ userInfo: UserInfo }>('/user/getUserInfo')
},
logout() {
return http.post<Record<string, never>>('/jwt/jsonInBlacklist')
},
changeAuthority(authorityId: number) {
return http.post<Record<string, never>>('/user/setUserAuthority', { authorityId })
},
setSelfInfo(payload: Partial<UserInfo>) {
return http.put<Record<string, never>>('/user/setSelfInfo', payload)
},
changePassword(payload: { password: string; newPassword: string }) {
return http.post<Record<string, never>>('/user/changePassword', payload)
},
}
export const menuApi = {
getMenu() {
return http.post<{ menus: MenuNode[] }>('/menu/getMenu')
},
getBaseMenuTree() {
return http.post<{ menus: MenuNode[] }>('/menu/getBaseMenuTree')
},
getMenuList() {
return http.post<MenuNode[]>('/menu/getMenuList')
},
addBaseMenu(payload: Partial<MenuNode>) {
return http.post<Record<string, never>>('/menu/addBaseMenu', payload)
},
updateBaseMenu(payload: Partial<MenuNode>) {
return http.post<Record<string, never>>('/menu/updateBaseMenu', payload)
},
deleteBaseMenu(id: number) {
return http.post<Record<string, never>>('/menu/deleteBaseMenu', { ID: id })
},
getBaseMenuById(id: number) {
return http.post<{ menu: MenuNode }>('/menu/getBaseMenuById', { ID: id })
},
getMenuAuthority(authorityId: number) {
return http.post<{ menus: MenuNode[] }>('/menu/getMenuAuthority', { authorityId })
},
addMenuAuthority(payload: { authorityId: number; menus: MenuNode[] }) {
return http.post<Record<string, never>>('/menu/addMenuAuthority', payload)
},
getMenuRoles(menuId: number) {
return http.get<{ authorityIds: number[]; defaultRouterAuthorityIds: number[] }>('/menu/getMenuRoles', {
params: { menuId },
})
},
setMenuRoles(payload: { menuId: number; authorityIds: number[] }) {
return http.post<Record<string, never>>('/menu/setMenuRoles', payload)
},
}
export const authorityApi = {
getAuthorityList() {
return http.post<Authority[]>('/authority/getAuthorityList', {})
},
createAuthority(payload: Partial<Authority>) {
return http.post<Record<string, never>>('/authority/createAuthority', payload)
},
updateAuthority(payload: Partial<Authority>) {
return http.put<{ authority: Authority }>('/authority/updateAuthority', payload)
},
deleteAuthority(authorityId: number) {
return http.post<Record<string, never>>('/authority/deleteAuthority', { authorityId })
},
copyAuthority(payload: { oldAuthorityId: number; authority: Partial<Authority> }) {
return http.post<Record<string, never>>('/authority/copyAuthority', payload)
},
getUsersByAuthorityId(authorityId: number) {
return http.get<number[]>('/authority/getUsersByAuthority', { params: { authorityId } })
},
setRoleUsers(payload: { authorityId: number; userIds: number[] }) {
return http.post<Record<string, never>>('/authority/setRoleUsers', payload)
},
}
export const userApi = {
getUserList(payload: Record<string, unknown>) {
return http.post<PagePayload<UserInfo>>('/user/getUserList', payload)
},
register(payload: Record<string, unknown>) {
return http.post<Record<string, never>>('/user/admin_register', payload)
},
updateUser(payload: Record<string, unknown>) {
return http.put<Record<string, never>>('/user/setUserInfo', payload)
},
deleteUser(id: number) {
return http.delete<Record<string, never>>('/user/deleteUser', { data: { ID: id } })
},
resetPassword(payload: { ID: number; password: string }) {
return http.post<Record<string, never>>('/user/resetPassword', payload)
},
}
export const casbinApi = {
getPolicyPathByAuthorityId(authorityId: number) {
return http.post<{ paths: Array<{ path: string; method: string }> }>('/casbin/getPolicyPathByAuthorityId', {
authorityId,
})
},
updateCasbin(payload: { authorityId: number; casbinInfos: Array<{ path: string; method: string }> }) {
return http.post<Record<string, never>>('/casbin/updateCasbin', payload)
},
}
export const systemApi = {
getSystemConfig() {
return http.post<{ config: Record<string, unknown> }>('/system/getSystemConfig')
},
setSystemConfig(payload: Record<string, unknown>) {
return http.post<Record<string, never>>('/system/setSystemConfig', payload)
},
reloadSystem() {
return http.post<Record<string, never>>('/system/reloadSystem')
},
}
export const apiRegistryApi = {
getApiList(payload: Record<string, unknown>) {
return http.post<PagePayload<ApiRecord>>('/api/getApiList', payload)
},
getAllApis() {
return http.post<{ apis: ApiRecord[] }>('/api/getAllApis', {})
},
createApi(payload: Partial<ApiRecord>) {
return http.post<Record<string, never>>('/api/createApi', payload)
},
updateApi(payload: Partial<ApiRecord>) {
return http.post<Record<string, never>>('/api/updateApi', payload)
},
deleteApi(payload: { ID: number }) {
return http.post<Record<string, never>>('/api/deleteApi', payload)
},
getApiById(id: number) {
return http.post<{ api: ApiRecord }>('/api/getApiById', { ID: id })
},
freshCasbin() {
return http.get<Record<string, never>>('/api/freshCasbin')
},
getApiRoles(path: string, method: string) {
return http.get<number[]>('/api/getApiRoles', { params: { path, method } })
},
setApiRoles(payload: { path: string; method: string; authorityIds: number[] }) {
return http.post<Record<string, never>>('/api/setApiRoles', payload)
},
}
export const dictionaryApi = {
getDictionaryList(params?: Record<string, unknown>) {
return http.get<Dictionary[]>('/sysDictionary/getSysDictionaryList', { params })
},
createDictionary(payload: Partial<Dictionary>) {
return http.post<Record<string, never>>('/sysDictionary/createSysDictionary', payload)
},
updateDictionary(payload: Partial<Dictionary>) {
return http.put<Record<string, never>>('/sysDictionary/updateSysDictionary', payload)
},
deleteDictionary(payload: { ID: number }) {
return http.delete<Record<string, never>>('/sysDictionary/deleteSysDictionary', { data: payload })
},
getDictionaryTree(sysDictionaryID: number) {
return http.get<{ list: DictionaryDetail[] }>('/sysDictionaryDetail/getDictionaryTreeList', {
params: { sysDictionaryID },
})
},
createDictionaryDetail(payload: Partial<DictionaryDetail>) {
return http.post<Record<string, never>>('/sysDictionaryDetail/createSysDictionaryDetail', payload)
},
updateDictionaryDetail(payload: Partial<DictionaryDetail>) {
return http.put<Record<string, never>>('/sysDictionaryDetail/updateSysDictionaryDetail', payload)
},
deleteDictionaryDetail(payload: { ID: number }) {
return http.delete<Record<string, never>>('/sysDictionaryDetail/deleteSysDictionaryDetail', { data: payload })
},
}
export const sysParamsApi = {
getParamsList(params?: Record<string, unknown>) {
return http.get<PagePayload<SysParam>>('/sysParams/getSysParamsList', { params })
},
createParam(payload: Partial<SysParam>) {
return http.post<Record<string, never>>('/sysParams/createSysParams', payload)
},
updateParam(payload: Partial<SysParam>) {
return http.put<Record<string, never>>('/sysParams/updateSysParams', payload)
},
deleteParam(id: number) {
return http.delete<Record<string, never>>('/sysParams/deleteSysParams', { params: { ID: id } })
},
}
export const loginLogApi = {
getLoginLogList(params?: Record<string, unknown>) {
return http.get<PagePayload<LoginLog>>('/sysLoginLog/getLoginLogList', { params })
},
deleteLoginLog(id: number) {
return http.delete<Record<string, never>>('/sysLoginLog/deleteLoginLog', { data: { ID: id } })
},
deleteLoginLogByIds(ids: number[]) {
return http.delete<Record<string, never>>('/sysLoginLog/deleteLoginLogByIds', { data: { ids } })
},
}
export const operationApi = {
getOperationList(params?: Record<string, unknown>) {
return http.get<PagePayload<OperationRecord>>('/sysOperationRecord/getSysOperationRecordList', { params })
},
deleteOperation(id: number) {
return http.delete<Record<string, never>>('/sysOperationRecord/deleteSysOperationRecord', { data: { ID: id } })
},
deleteOperationByIds(ids: number[]) {
return http.delete<Record<string, never>>('/sysOperationRecord/deleteSysOperationRecordByIds', { data: { ids } })
},
}
export const apiTokenApi = {
getTokenList(payload: Record<string, unknown>) {
return http.post<PagePayload<ApiTokenRecord>>('/sysApiToken/getApiTokenList', payload)
},
createToken(payload: { userId: number; authorityId: number; days: number; remark?: string }) {
return http.post<{ token: string }>('/sysApiToken/createApiToken', payload)
},
deleteToken(id: number) {
return http.post<Record<string, never>>('/sysApiToken/deleteApiToken', { ID: id })
},
}
export const sysErrorApi = {
getErrorList(params?: Record<string, unknown>) {
return http.get<PagePayload<SysErrorRecord>>('/sysError/getSysErrorList', { params })
},
updateError(payload: Partial<SysErrorRecord>) {
return http.put<Record<string, never>>('/sysError/updateSysError', payload)
},
deleteError(id: number) {
return http.delete<Record<string, never>>('/sysError/deleteSysError', { params: { ID: id } })
},
handleError(id: number) {
return http.get<Record<string, never>>('/sysError/getSysErrorSolution', { params: { id } })
},
}
export const inventoryApi = {
getServerInfo() {
return http.post<{ server: Record<string, unknown> }>('/system/getServerInfo')
},
}

View File

@@ -0,0 +1,9 @@
import dayjs from 'dayjs'
export function formatDate(value?: string) {
if (!value) {
return '-'
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}

63
web-admin/src/lib/http.ts Normal file
View File

@@ -0,0 +1,63 @@
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
import { message } from 'antd'
import type { ApiResponse } from '@/types/system'
import { storage } from './storage'
const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_API || '/api',
timeout: 60000,
})
instance.interceptors.request.use((config) => {
const token = storage.getToken()
if (token) {
config.headers['x-token'] = token
}
return config
})
instance.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiResponse<unknown>>) => {
const status = error.response?.status
const errorText = error.response?.data?.msg || error.message || '请求失败'
if (status === 401) {
storage.clearToken()
window.location.hash = '#/login'
window.location.reload()
return Promise.reject(error)
}
message.error(errorText)
return Promise.reject(error)
},
)
async function request<T>(config: AxiosRequestConfig) {
const response = await instance.request<ApiResponse<T>>(config)
const payload = response.data
if (typeof payload?.code === 'number' && payload.code !== 0) {
message.error(payload.msg || '请求失败')
throw new Error(payload.msg || '请求失败')
}
return payload
}
export const http = {
get<T>(url: string, config?: AxiosRequestConfig) {
return request<T>({ ...config, url, method: 'get' })
},
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
return request<T>({ ...config, url, data, method: 'post' })
},
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig) {
return request<T>({ ...config, url, data, method: 'put' })
},
delete<T>(url: string, config?: AxiosRequestConfig) {
return request<T>({ ...config, url, method: 'delete' })
},
}

57
web-admin/src/lib/menu.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { AppMenu, MenuNode } from '@/types/system'
export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
return (
menu.path.startsWith('http://') ||
menu.path.startsWith('https://') ||
menu.component.startsWith('http://') ||
menu.component === '/'
)
}
function normalizePathSegment(path: string) {
if (!path) {
return ''
}
return path.replace(/^\/+/, '').replace(/\/+$/, '')
}
export function buildFullMenus(
menus: MenuNode[],
parentPath = '',
parentName?: string,
): AppMenu[] {
return [...menus]
.sort((left, right) => left.sort - right.sort)
.map((menu) => {
const normalized = normalizePathSegment(menu.path)
const fullPath = isExternalMenu(menu)
? menu.path
: `${parentPath}/${normalized}`.replace(/\/+/g, '/')
const children = buildFullMenus(menu.children || [], fullPath, menu.name)
return {
...menu,
parentName,
fullPath,
fullPathKey: fullPath.replace(/^\//, ''),
children,
}
})
}
export function flattenMenus(menus: AppMenu[]): AppMenu[] {
return menus.flatMap((menu) => [menu, ...flattenMenus(menu.children || [])])
}
export function findDefaultRoute(menus: AppMenu[], routeName?: string) {
const flatMenus = flattenMenus(menus).filter((menu) => !isExternalMenu(menu))
const byDefaultName = flatMenus.find((menu) => menu.name === routeName)
if (byDefaultName) {
return byDefaultName.fullPath
}
return flatMenus[0]?.fullPath
}

View File

@@ -0,0 +1,13 @@
const TOKEN_KEY = 'gva-react-admin-token'
export const storage = {
getToken() {
return window.localStorage.getItem(TOKEN_KEY) || ''
},
setToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token)
},
clearToken() {
window.localStorage.removeItem(TOKEN_KEY)
},
}

26
web-admin/src/lib/tree.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Authority, MenuNode } from '@/types/system'
export function flattenAuthorities(authorities: Authority[], depth = 0): Array<Authority & { depth: number }> {
return authorities.flatMap((authority) => [
{ ...authority, depth },
...flattenAuthorities(authority.children || [], depth + 1),
])
}
export function flattenMenusForOptions(menus: MenuNode[], depth = 0): Array<MenuNode & { depth: number }> {
return menus.flatMap((menu) => [
{ ...menu, depth },
...flattenMenusForOptions(menu.children || [], depth + 1),
])
}
export function collectCheckedLeafMenus(menus: MenuNode[], checkedKeys: Set<number>): MenuNode[] {
return menus.flatMap((menu) => {
const children = menu.children || []
if (!children.length) {
return checkedKeys.has(menu.ID) ? [menu] : []
}
return collectCheckedLeafMenus(children, checkedKeys)
})
}

46
web-admin/src/main.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App as AntdApp, ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import App from './App'
import './index.css'
dayjs.locale('zh-cn')
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#d16f3f',
colorSuccess: '#1f9d78',
colorWarning: '#d29b2f',
colorError: '#c34747',
borderRadius: 16,
wireframe: false,
fontFamily:
'"Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
},
}}
>
<AntdApp>
<App />
</AntdApp>
</ConfigProvider>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { storage } from '@/lib/storage'
import type { AppMenu, UserInfo } from '@/types/system'
type AuthState = {
token: string
user: UserInfo | null
menus: AppMenu[]
setToken: (token: string) => void
setUser: (user: UserInfo | null) => void
setMenus: (menus: AppMenu[]) => void
applySession: (payload: { token: string; user: UserInfo }) => void
clearSession: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: storage.getToken(),
user: null,
menus: [],
setToken: (token) => {
storage.setToken(token)
set({ token })
},
setUser: (user) => set({ user }),
setMenus: (menus) => set({ menus }),
applySession: ({ token, user }) => {
storage.setToken(token)
set({ token, user })
},
clearSession: () => {
storage.clearToken()
set({ token: '', user: null, menus: [] })
},
}),
{
name: 'gva-react-admin-auth',
partialize: (state) => ({
token: state.token,
user: state.user,
}),
},
),
)

View File

@@ -0,0 +1,171 @@
export type ApiResponse<T> = {
code: number
data: T
msg: string
}
export type BaseEntity = {
ID: number
CreatedAt?: string
UpdatedAt?: string
}
export type Authority = {
authorityId: number
authorityName: string
parentId?: number | null
defaultRouter?: string
children?: Authority[]
}
export type PagePayload<T> = {
list: T[]
total: number
page: number
pageSize: number
}
export type UserInfo = BaseEntity & {
uuid?: string
userName: string
nickName: string
headerImg?: string
authorityId?: number
authority?: Authority
authorities?: Authority[]
phone?: string
email?: string
enable?: number
originSetting?: Record<string, unknown>
}
export type MenuMeta = {
title: string
icon?: string
keepAlive?: boolean
closeTab?: boolean
defaultMenu?: boolean
activeName?: string
transitionType?: string
}
export type MenuNode = BaseEntity & {
parentId: number
path: string
name: string
hidden?: boolean
component: string
sort: number
meta: MenuMeta
menuBtn?: Array<{ ID: number; name: string; desc?: string }>
btns?: Record<string, number>
children?: MenuNode[]
}
export type AppMenu = MenuNode & {
fullPath: string
fullPathKey: string
parentName?: string
children?: AppMenu[]
}
export type LoginResult = {
user: UserInfo
token: string
expiresAt: number
}
export type CaptchaInfo = {
captchaId: string
picPath: string
captchaLength: number
openCaptcha: boolean
}
export type ApiRecord = BaseEntity & {
path: string
description: string
apiGroup: string
method: string
}
export type Dictionary = BaseEntity & {
name: string
type: string
status?: boolean | null
desc?: string
parentID?: number | null
children?: Dictionary[]
}
export type DictionaryDetail = BaseEntity & {
label: string
value: string
extend?: string
status?: boolean | null
sort?: number
sysDictionaryID: number
parentID?: number | null
level?: number
path?: string
children?: DictionaryDetail[]
}
export type SysParam = BaseEntity & {
name: string
key: string
value: string
desc?: string
}
export type LoginLog = BaseEntity & {
username: string
ip: string
status: boolean
errorMessage?: string
agent?: string
}
export type OperationRecord = BaseEntity & {
ip: string
method: string
path: string
status: number
latency?: string
agent?: string
error_message?: string
body?: string
resp?: string
user_id?: number
user?: UserInfo
}
export type ApiTokenRecord = BaseEntity & {
userId: number
authorityId: number
token?: string
status: boolean
expiresAt: string
remark?: string
user?: UserInfo
}
export type SysErrorRecord = BaseEntity & {
form?: string | null
info?: string | null
level?: string
solution?: string | null
status?: string
}
export type ModuleStatus = 'ready' | 'partial' | 'planned'
export type ModuleDescriptor = {
name: string
title: string
summary: string
group: string
status: ModuleStatus
features: string[]
endpoints: string[]
}