🎨 精简完善系统
This commit is contained in:
@@ -23,7 +23,6 @@
|
||||
- `system` 系统配置
|
||||
- `apiToken` API Token
|
||||
- `loginLog` 登录日志
|
||||
- `sysVersion` 版本管理
|
||||
- `sysError` 错误日志
|
||||
|
||||
### 公共模块
|
||||
@@ -33,8 +32,6 @@
|
||||
|
||||
### 编程辅助
|
||||
|
||||
- `formCreate` 表单生成器
|
||||
- `exportTemplate` 导出模板
|
||||
- `mcpTest` MCP Tools 管理
|
||||
- `mcpTool` MCP Tools 模板
|
||||
|
||||
@@ -154,15 +151,12 @@
|
||||
### 已做成模块入口页
|
||||
|
||||
- 关于系统
|
||||
- 版本管理
|
||||
- 媒体库
|
||||
- 断点续传
|
||||
- 表单生成器
|
||||
- 导出模板
|
||||
- MCP Tools 管理
|
||||
- MCP Tools 模板
|
||||
|
||||
## 说明
|
||||
|
||||
- 新后台没有修改后端协议,仍然复用原有 token、菜单、权限和接口格式。
|
||||
- 当前仍有部分研发辅助模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。
|
||||
- 当前仍有部分模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { InitPage } from '@/features/auth/InitPage'
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { AdminShell } from '@/features/layout/AdminShell'
|
||||
import { appRoutes } from '@/router/fsRoutes'
|
||||
@@ -133,6 +134,7 @@ export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/init" element={<InitPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<BootstrapGate />}>
|
||||
<Route path="/*" element={<LayoutFrame />} />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -16,84 +15,121 @@ import {
|
||||
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'
|
||||
import { apiRegistryApi } from '@/lib/api'
|
||||
import type { ApiRecord, SyncApiPayload } from '@/types/system'
|
||||
|
||||
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
const methodOptions = [
|
||||
{ value: 'POST', label: '创建', color: 'green' },
|
||||
{ value: 'GET', label: '查看', color: 'blue' },
|
||||
{ value: 'PUT', label: '更新', color: 'orange' },
|
||||
{ value: 'DELETE', label: '删除', color: 'red' },
|
||||
]
|
||||
|
||||
type SearchValues = {
|
||||
path?: string
|
||||
description?: string
|
||||
apiGroup?: string
|
||||
method?: string
|
||||
}
|
||||
|
||||
type ApiFormValues = {
|
||||
path: string
|
||||
description: string
|
||||
apiGroup: string
|
||||
method: string
|
||||
}
|
||||
|
||||
function methodMeta(method: string) {
|
||||
return methodOptions.find((item) => item.value === method) || { label: method, color: 'default' }
|
||||
}
|
||||
|
||||
function normalizeSyncPayload(payload?: SyncApiPayload): SyncApiPayload {
|
||||
return {
|
||||
newApis: payload?.newApis || [],
|
||||
deleteApis: payload?.deleteApis || [],
|
||||
ignoreApis: payload?.ignoreApis || [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiManagementPage() {
|
||||
const [searchForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [searchForm] = Form.useForm<SearchValues>()
|
||||
const [editForm] = Form.useForm<ApiFormValues>()
|
||||
const [apis, setApis] = useState<ApiRecord[]>([])
|
||||
const [roles, setRoles] = useState<Authority[]>([])
|
||||
const [selectedRows, setSelectedRows] = useState<ApiRecord[]>([])
|
||||
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 [orderKey, setOrderKey] = useState<string>()
|
||||
const [desc, setDesc] = useState<boolean>(true)
|
||||
const [drawerOpen, setDrawerOpen] = 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 [syncDrawerOpen, setSyncDrawerOpen] = useState(false)
|
||||
const [syncPayload, setSyncPayload] = useState<SyncApiPayload>(normalizeSyncPayload())
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [groupOptions, setGroupOptions] = useState<Array<{ label: string; value: string }>>([])
|
||||
const [groupMap, setGroupMap] = useState<Record<string, string>>({})
|
||||
const [editGroupSearch, setEditGroupSearch] = useState('')
|
||||
const [syncGroupSearch, setSyncGroupSearch] = useState('')
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
flattenAuthorities(roles).map((item) => ({
|
||||
label: `${' '.repeat(item.depth)}${item.authorityName} (${item.authorityId})`,
|
||||
value: item.authorityId,
|
||||
})),
|
||||
[roles],
|
||||
)
|
||||
const ensureGroupOption = useCallback((rawValue: string) => {
|
||||
const value = rawValue.trim()
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
setGroupOptions((current) => {
|
||||
if (current.some((item) => item.value === value)) {
|
||||
return current
|
||||
}
|
||||
return [...current, { label: value, value }]
|
||||
})
|
||||
return value
|
||||
}, [])
|
||||
|
||||
const apiGroupOptions = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(apis.map((item) => item.apiGroup)))
|
||||
.filter(Boolean)
|
||||
.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
[apis],
|
||||
)
|
||||
const loadGroupOptions = useCallback(async () => {
|
||||
const response = await apiRegistryApi.getApiGroups()
|
||||
setGroupOptions(response.data.groups.map((item) => ({ label: item, value: item })))
|
||||
setGroupMap(response.data.apiGroupMap)
|
||||
}, [])
|
||||
|
||||
const reloadApis = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [apiRes, roleRes] = await Promise.all([
|
||||
apiRegistryApi.getApiList({
|
||||
page,
|
||||
pageSize,
|
||||
...searchForm.getFieldsValue(),
|
||||
}),
|
||||
authorityApi.getAuthorityList(),
|
||||
])
|
||||
const apiRes = await apiRegistryApi.getApiList({
|
||||
page,
|
||||
pageSize,
|
||||
orderKey,
|
||||
desc,
|
||||
...searchForm.getFieldsValue(),
|
||||
})
|
||||
setApis(apiRes.data.list)
|
||||
setTotal(apiRes.data.total)
|
||||
setRoles(roleRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchForm])
|
||||
}, [desc, orderKey, page, pageSize, searchForm])
|
||||
|
||||
useEffect(() => {
|
||||
reloadApis()
|
||||
void reloadApis()
|
||||
}, [reloadApis])
|
||||
|
||||
useEffect(() => {
|
||||
void loadGroupOptions()
|
||||
}, [loadGroupOptions])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingApi(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({ method: 'POST' })
|
||||
setModalOpen(true)
|
||||
editForm.setFieldsValue({ method: 'POST', path: '', apiGroup: '', description: '' })
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: ApiRecord) => {
|
||||
const response = await apiRegistryApi.getApiById(record.ID)
|
||||
setEditingApi(response.data.api)
|
||||
editForm.setFieldsValue(response.data.api)
|
||||
setModalOpen(true)
|
||||
setDrawerOpen(true)
|
||||
}
|
||||
|
||||
const saveApi = async () => {
|
||||
@@ -101,17 +137,15 @@ export function ApiManagementPage() {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingApi) {
|
||||
await apiRegistryApi.updateApi({
|
||||
ID: editingApi.ID,
|
||||
...values,
|
||||
})
|
||||
await apiRegistryApi.updateApi({ ID: editingApi.ID, ...values })
|
||||
message.success('接口已更新')
|
||||
} else {
|
||||
await apiRegistryApi.createApi(values)
|
||||
message.success('接口已创建')
|
||||
message.success('接口已创建,请到角色管理页分配权限')
|
||||
}
|
||||
setModalOpen(false)
|
||||
reloadApis()
|
||||
setDrawerOpen(false)
|
||||
await reloadApis()
|
||||
await loadGroupOptions()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -120,64 +154,143 @@ export function ApiManagementPage() {
|
||||
const deleteApi = (record: ApiRecord) => {
|
||||
Modal.confirm({
|
||||
title: `删除接口 ${record.path}`,
|
||||
content: '此操作会删除当前 API 在所有角色下的权限关系。',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await apiRegistryApi.deleteApi({ ID: record.ID })
|
||||
message.success('接口已删除')
|
||||
reloadApis()
|
||||
if (apis.length === 1 && page > 1) {
|
||||
setPage((current) => current - 1)
|
||||
} else {
|
||||
await reloadApis()
|
||||
}
|
||||
await loadGroupOptions()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openRoleDrawer = async (record: ApiRecord) => {
|
||||
const response = await apiRegistryApi.getApiRoles(record.path, record.method)
|
||||
setActiveApi(record)
|
||||
setSelectedRoles(response.data)
|
||||
setDrawerOpen(true)
|
||||
const batchDeleteApis = () => {
|
||||
Modal.confirm({
|
||||
title: '批量删除接口',
|
||||
content: `当前将删除 ${selectedRows.length} 条接口记录,是否继续?`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await apiRegistryApi.deleteApisByIds(selectedRows.map((item) => item.ID))
|
||||
message.success('批量删除成功')
|
||||
setSelectedRows([])
|
||||
if (apis.length === selectedRows.length && page > 1) {
|
||||
setPage((current) => current - 1)
|
||||
} else {
|
||||
await reloadApis()
|
||||
}
|
||||
await loadGroupOptions()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const saveRoles = async () => {
|
||||
if (!activeApi) {
|
||||
const refreshCasbin = () => {
|
||||
Modal.confirm({
|
||||
title: '刷新 Casbin 缓存',
|
||||
content: '确定立即刷新权限缓存吗?',
|
||||
onOk: async () => {
|
||||
await apiRegistryApi.freshCasbin()
|
||||
message.success('Casbin 缓存已刷新')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openSyncDrawer = async () => {
|
||||
const response = await apiRegistryApi.syncApi()
|
||||
const nextPayload = normalizeSyncPayload(response.data)
|
||||
nextPayload.newApis = nextPayload.newApis.map((item) => ({
|
||||
...item,
|
||||
apiGroup: item.apiGroup || groupMap[item.path.split('/')[1]] || '',
|
||||
}))
|
||||
setSyncPayload(nextPayload)
|
||||
setSyncDrawerOpen(true)
|
||||
}
|
||||
|
||||
const ignoreSyncApi = async (record: ApiRecord, flag: boolean) => {
|
||||
await apiRegistryApi.ignoreApi({ path: record.path, method: record.method, flag })
|
||||
message.success(flag ? '已加入忽略列表' : '已取消忽略')
|
||||
setSyncPayload((current) => {
|
||||
if (flag) {
|
||||
return {
|
||||
...current,
|
||||
newApis: current.newApis.filter((item) => !(item.path === record.path && item.method === record.method)),
|
||||
ignoreApis: [...current.ignoreApis, record],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
ignoreApis: current.ignoreApis.filter((item) => !(item.path === record.path && item.method === record.method)),
|
||||
newApis: [...current.newApis, record],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createSingleSyncApi = async (record: ApiRecord) => {
|
||||
if (!record.apiGroup) {
|
||||
message.error('请先填写 API 分组')
|
||||
return
|
||||
}
|
||||
setSavingRoles(true)
|
||||
if (!record.description) {
|
||||
message.error('请先填写 API 描述')
|
||||
return
|
||||
}
|
||||
await apiRegistryApi.createApi(record)
|
||||
message.success('接口已新增')
|
||||
setSyncPayload((current) => ({
|
||||
...current,
|
||||
newApis: current.newApis.filter((item) => !(item.path === record.path && item.method === record.method)),
|
||||
}))
|
||||
await reloadApis()
|
||||
await loadGroupOptions()
|
||||
}
|
||||
|
||||
const submitSyncPayload = async () => {
|
||||
if (syncPayload.newApis.some((item) => !item.apiGroup || !item.description)) {
|
||||
message.error('存在未填写分组或描述的 API,无法确认同步')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
await apiRegistryApi.setApiRoles({
|
||||
path: activeApi.path,
|
||||
method: activeApi.method,
|
||||
authorityIds: selectedRoles,
|
||||
})
|
||||
message.success('接口角色关系已更新')
|
||||
setDrawerOpen(false)
|
||||
await apiRegistryApi.enterSyncApi(syncPayload)
|
||||
message.success('API 同步完成')
|
||||
setSyncDrawerOpen(false)
|
||||
await reloadApis()
|
||||
await loadGroupOptions()
|
||||
} finally {
|
||||
setSavingRoles(false)
|
||||
setSyncing(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: 'ID', dataIndex: 'ID', width: 80, sorter: true },
|
||||
{ title: 'API 路径', dataIndex: 'path', width: 260, sorter: true },
|
||||
{ title: 'API 分组', dataIndex: 'apiGroup', width: 180, sorter: true },
|
||||
{ title: 'API 简介', dataIndex: 'description', width: 220, sorter: true },
|
||||
{
|
||||
title: '方法',
|
||||
title: '请求',
|
||||
dataIndex: 'method',
|
||||
width: 100,
|
||||
render: (value: string) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : value === 'PUT' ? 'orange' : 'red'}>{value}</Tag>,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
render: (value: string) => {
|
||||
const meta = methodMeta(value)
|
||||
return <Tag color={meta.color}>{`${value} / ${meta.label}`}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
width: 160,
|
||||
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>
|
||||
@@ -189,75 +302,89 @@ export function ApiManagementPage() {
|
||||
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>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
API 管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
这里仅维护 API 目录和分组。菜单权限与 API 权限统一在角色管理中分配。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新增
|
||||
</Button>
|
||||
<Button disabled={!selectedRows.length} onClick={batchDeleteApis}>
|
||||
删除
|
||||
</Button>
|
||||
<Button onClick={refreshCasbin}>刷新缓存</Button>
|
||||
<Button onClick={() => void openSyncDrawer()}>同步 API</Button>
|
||||
</Space>
|
||||
</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>
|
||||
<Form
|
||||
form={searchForm}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
setPage(1)
|
||||
void reloadApis()
|
||||
}}
|
||||
>
|
||||
<Form.Item name="path" label="路径">
|
||||
<Input placeholder="路径" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input placeholder="描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="apiGroup" label="API 分组">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: 180 }}
|
||||
options={groupOptions}
|
||||
placeholder="请选择"
|
||||
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="method" label="请求">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
options={methodOptions.map((item) => ({ label: `${item.label}(${item.value})`, value: item.value }))}
|
||||
placeholder="请选择"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
void reloadApis()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel">
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={apis}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1400 }}
|
||||
rowSelection={{
|
||||
onChange: (_, rows) => setSelectedRows(rows),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -268,50 +395,231 @@ export function ApiManagementPage() {
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
onChange={(_, __, sorter) => {
|
||||
if (Array.isArray(sorter)) {
|
||||
return
|
||||
}
|
||||
if (!sorter.order || !sorter.field) {
|
||||
setOrderKey(undefined)
|
||||
setDesc(true)
|
||||
return
|
||||
}
|
||||
setOrderKey(sorter.field === 'ID' ? 'id' : String(sorter.field))
|
||||
setDesc(sorter.order === 'descend')
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingApi ? '编辑接口' : '新建接口'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveApi}
|
||||
confirmLoading={saving}
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
title={editingApi ? `编辑 API · ${editingApi.path}` : '新增 API'}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={520}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={saveApi}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="新增 API 后不会自动授权角色。正确流程是:新建 API -> 新建菜单 -> 到角色管理页统一分配菜单和 API 权限。"
|
||||
/>
|
||||
<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 name="method" label="请求" rules={[{ required: true, message: '请选择请求方式' }]}>
|
||||
<Select options={methodOptions.map((item) => ({ label: `${item.label}(${item.value})`, value: item.value }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="apiGroup" label="分组" rules={[{ required: true, message: '请输入分组' }]}>
|
||||
<Input />
|
||||
<Form.Item name="apiGroup" label="API 分组" rules={[{ required: true, message: '请选择或输入 API 分组' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="输入后会先模糊搜索;若不存在,按回车创建新分组"
|
||||
options={groupOptions}
|
||||
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={setEditGroupSearch}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const createdGroup = ensureGroupOption(editGroupSearch)
|
||||
if (createdGroup) {
|
||||
editForm.setFieldValue('apiGroup', createdGroup)
|
||||
setEditGroupSearch('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="method" label="方法" rules={[{ required: true, message: '请选择方法' }]}>
|
||||
<Select options={methodOptions.map((item) => ({ label: item, value: item }))} />
|
||||
<Form.Item name="description" label="API 简介" rules={[{ required: true, message: '请输入 API 简介' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title={activeApi ? `接口角色分配 · ${activeApi.path}` : '接口角色分配'}
|
||||
width={520}
|
||||
open={syncDrawerOpen}
|
||||
onClose={() => setSyncDrawerOpen(false)}
|
||||
width={1080}
|
||||
title="同步路由"
|
||||
extra={
|
||||
<Button type="primary" loading={savingRoles} onClick={saveRoles}>
|
||||
保存
|
||||
</Button>
|
||||
<Space>
|
||||
<Button onClick={() => setSyncDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={syncing} onClick={() => void submitSyncPayload()}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedRoles}
|
||||
options={roleOptions}
|
||||
onChange={setSelectedRoles}
|
||||
/>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="同步 API 会把路由变更写入接口表。新增路由需要先补齐分组和描述;忽略路由不会参与同步。"
|
||||
/>
|
||||
|
||||
<Typography.Title level={5}>新增路由</Typography.Title>
|
||||
<Table
|
||||
rowKey={(record) => `${record.path}::${record.method}`}
|
||||
pagination={false}
|
||||
dataSource={syncPayload.newApis}
|
||||
columns={[
|
||||
{ title: 'API 路径', dataIndex: 'path', width: 220 },
|
||||
{
|
||||
title: 'API 分组',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
showSearch
|
||||
value={record.apiGroup}
|
||||
options={groupOptions}
|
||||
filterOption={(input, option) => String(option?.value || '').toLowerCase().includes(input.toLowerCase())}
|
||||
onSearch={setSyncGroupSearch}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const createdGroup = ensureGroupOption(syncGroupSearch)
|
||||
if (!createdGroup) {
|
||||
return
|
||||
}
|
||||
setSyncGroupSearch('')
|
||||
setSyncPayload((current) => ({
|
||||
...current,
|
||||
newApis: current.newApis.map((item) =>
|
||||
item.path === record.path && item.method === record.method ? { ...item, apiGroup: createdGroup } : item,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSyncPayload((current) => ({
|
||||
...current,
|
||||
newApis: current.newApis.map((item) =>
|
||||
item.path === record.path && item.method === record.method ? { ...item, apiGroup: value } : item,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'API 简介',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Input
|
||||
value={record.description}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setSyncPayload((current) => ({
|
||||
...current,
|
||||
newApis: current.newApis.map((item) =>
|
||||
item.path === record.path && item.method === record.method ? { ...item, description: value } : item,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '请求',
|
||||
width: 140,
|
||||
render: (_, record) => {
|
||||
const meta = methodMeta(record.method)
|
||||
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => void createSingleSyncApi(record)}>
|
||||
单条新增
|
||||
</Button>
|
||||
<Button type="link" onClick={() => void ignoreSyncApi(record, true)}>
|
||||
忽略
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5}>已删除路由</Typography.Title>
|
||||
<Table
|
||||
rowKey={(record) => `${record.path}::${record.method}`}
|
||||
pagination={false}
|
||||
dataSource={syncPayload.deleteApis}
|
||||
columns={[
|
||||
{ title: 'API 路径', dataIndex: 'path' },
|
||||
{ title: 'API 分组', dataIndex: 'apiGroup' },
|
||||
{ title: 'API 简介', dataIndex: 'description' },
|
||||
{
|
||||
title: '请求',
|
||||
render: (_, record) => {
|
||||
const meta = methodMeta(record.method)
|
||||
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5}>忽略路由</Typography.Title>
|
||||
<Table
|
||||
rowKey={(record) => `${record.path}::${record.method}`}
|
||||
pagination={false}
|
||||
dataSource={syncPayload.ignoreApis}
|
||||
columns={[
|
||||
{ title: 'API 路径', dataIndex: 'path' },
|
||||
{ title: 'API 分组', dataIndex: 'apiGroup' },
|
||||
{ title: 'API 简介', dataIndex: 'description' },
|
||||
{
|
||||
title: '请求',
|
||||
render: (_, record) => {
|
||||
const meta = methodMeta(record.method)
|
||||
return <Tag color={meta.color}>{`${record.method} / ${meta.label}`}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, record) => (
|
||||
<Button type="link" onClick={() => void ignoreSyncApi(record, false)}>
|
||||
取消忽略
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
|
||||
305
web-admin/src/features/auth/InitPage.tsx
Normal file
305
web-admin/src/features/auth/InitPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Alert, Button, Card, Form, Input, Result, Select, Space, Spin, Typography, message } from 'antd'
|
||||
import { initApi } from '@/lib/api'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { InitCheckResult, InitDBPayload } from '@/types/system'
|
||||
|
||||
const dbTypeOptions: Array<{ label: string; value: InitDBPayload['dbType'] }> = [
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'PostgreSQL', value: 'pgsql' },
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'MSSQL', value: 'mssql' },
|
||||
]
|
||||
|
||||
type InitState = 'checking' | 'required' | 'ready'
|
||||
|
||||
const dbDefaults: Record<InitDBPayload['dbType'], Partial<InitDBPayload>> = {
|
||||
mysql: {
|
||||
host: '127.0.0.1',
|
||||
port: '3306',
|
||||
},
|
||||
pgsql: {
|
||||
host: '127.0.0.1',
|
||||
port: '5432',
|
||||
template: 'template1',
|
||||
},
|
||||
sqlite: {
|
||||
dbPath: 'db',
|
||||
},
|
||||
mssql: {
|
||||
host: '127.0.0.1',
|
||||
port: '1433',
|
||||
},
|
||||
}
|
||||
|
||||
function applyDbDefaults(currentValues: InitDBPayload, nextDbType: InitDBPayload['dbType']): InitDBPayload {
|
||||
return {
|
||||
...currentValues,
|
||||
...dbDefaults[nextDbType],
|
||||
dbType: nextDbType,
|
||||
}
|
||||
}
|
||||
|
||||
export function InitPage() {
|
||||
const navigate = useNavigate()
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const [form] = Form.useForm<InitDBPayload>()
|
||||
const [state, setState] = useState<InitState>('checking')
|
||||
const [checkingError, setCheckingError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const dbType = Form.useWatch('dbType', form) ?? 'mysql'
|
||||
|
||||
const checkInitialization = async () => {
|
||||
try {
|
||||
const response = await initApi.checkDB()
|
||||
const data: InitCheckResult = response.data
|
||||
|
||||
if (data.needInit) {
|
||||
setState('required')
|
||||
} else {
|
||||
setState('ready')
|
||||
}
|
||||
setCheckingError(null)
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : '初始化状态检测失败'
|
||||
setCheckingError(messageText)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void checkInitialization()
|
||||
}, [])
|
||||
|
||||
const helperText = useMemo(() => {
|
||||
switch (dbType) {
|
||||
case 'mysql':
|
||||
return '将创建数据库并写回 MySQL 连接配置。'
|
||||
case 'pgsql':
|
||||
return '默认使用 PostgreSQL 公共库建库,可按需指定 template。'
|
||||
case 'sqlite':
|
||||
return '将创建本地 SQLite 数据库文件,并写回 sqlite 配置。'
|
||||
case 'mssql':
|
||||
return '将按当前连接信息接入 MSSQL,并写回 mssql 配置。'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}, [dbType])
|
||||
|
||||
const onDbTypeChange = (nextDbType: InitDBPayload['dbType']) => {
|
||||
const currentValues = form.getFieldsValue()
|
||||
form.setFieldsValue(applyDbDefaults(currentValues, nextDbType))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await initApi.initDB(values)
|
||||
message.success('初始化完成,请使用 admin 账户登录')
|
||||
navigate('/login', { replace: true })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Project Bootstrap</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 }}>
|
||||
初始化页只在数据库尚未建立、配置尚未回写时使用。检测完成后会自动切换到对应状态。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-form-wrap">
|
||||
<Card className="glass-panel login-card" bordered={false}>
|
||||
<div className="fullscreen-status" style={{ minHeight: 360 }}>
|
||||
<Spin size="large" />
|
||||
<span>正在检测初始化状态...</span>
|
||||
{checkingError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="检测失败"
|
||||
description={checkingError}
|
||||
action={
|
||||
<Button size="small" onClick={() => void checkInitialization()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'ready') {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Project Bootstrap</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 }}>
|
||||
当前服务已经具备数据库连接和基础管理员账号,不需要再次执行初始化。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-form-wrap">
|
||||
<Card className="glass-panel login-card" bordered={false}>
|
||||
<Result
|
||||
status="success"
|
||||
title="无需再次初始化"
|
||||
subTitle="如果需要调整系统配置,请登录后台后在系统配置页修改。"
|
||||
extra={[
|
||||
<Button key="login" type="primary" onClick={() => navigate(token ? '/' : '/login', { replace: true })}>
|
||||
{token ? '进入后台' : '前往登录'}
|
||||
</Button>,
|
||||
<Button key="check" onClick={() => void checkInitialization()}>
|
||||
重新检测
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Project Bootstrap</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 }}>
|
||||
这里负责创建主业务数据库、写回当前服务配置,并生成默认管理员账号。初始化完成后,再进入后台登录。
|
||||
</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" style={{ marginBottom: 20 }}>
|
||||
默认管理员账号固定为 `admin`,密码使用下面填写的管理员密码。
|
||||
</Typography.Paragraph>
|
||||
{checkingError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="状态检测存在异常"
|
||||
description={checkingError}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
adminPassword: '',
|
||||
dbType: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: '3306',
|
||||
dbName: 'go_web_template',
|
||||
userName: 'root',
|
||||
dbPath: 'db',
|
||||
template: 'template1',
|
||||
}}
|
||||
onFinish={submit}
|
||||
>
|
||||
<Form.Item name="dbType" label="数据库类型" rules={[{ required: true, message: '请选择数据库类型' }]}>
|
||||
<Select options={dbTypeOptions} onChange={onDbTypeChange} />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
{helperText}
|
||||
</Typography.Paragraph>
|
||||
<div className="init-form-grid">
|
||||
{dbType === 'sqlite' ? (
|
||||
<Form.Item
|
||||
name="dbPath"
|
||||
label="数据库目录"
|
||||
rules={[{ required: true, message: '请输入 SQLite 数据库目录' }]}
|
||||
>
|
||||
<Input placeholder="例如 db" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="host" label="数据库地址" rules={[{ required: true, message: '请输入数据库地址' }]}>
|
||||
<Input placeholder="例如 127.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="port" label="数据库端口" rules={[{ required: true, message: '请输入数据库端口' }]}>
|
||||
<Input placeholder="例如 3306" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="userName"
|
||||
label="数据库用户名"
|
||||
rules={[{ required: true, message: '请输入数据库用户名' }]}
|
||||
>
|
||||
<Input placeholder="例如 root" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="数据库密码">
|
||||
<Input.Password placeholder="请输入数据库密码" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="dbName" label="数据库名" rules={[{ required: true, message: '请输入数据库名' }]}>
|
||||
<Input placeholder={dbType === 'sqlite' ? '例如 go_web_template' : '例如 go_web_template'} />
|
||||
</Form.Item>
|
||||
{dbType === 'pgsql' ? (
|
||||
<Form.Item name="template" label="建库模板">
|
||||
<Input placeholder="默认 template1" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Form.Item
|
||||
name="adminPassword"
|
||||
label="管理员密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入管理员密码' },
|
||||
{ min: 6, message: '管理员密码至少 6 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="初始化后 admin 账户使用此密码登录" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 20 }}
|
||||
message="初始化完成后,后端会立即持有数据库连接,并将连接配置写回当前配置文件。"
|
||||
/>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
开始初始化
|
||||
</Button>
|
||||
<Button onClick={() => void checkInitialization()}>重新检测</Button>
|
||||
<Button onClick={() => navigate('/login', { replace: true })}>返回登录页</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { Alert, Button, Card, Form, Input, Result, Spin, Typography, message } from 'antd'
|
||||
import { authApi, initApi, menuApi } from '@/lib/api'
|
||||
import { buildFullMenus, findDefaultRoute } from '@/lib/menu'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import type { CaptchaInfo } from '@/types/system'
|
||||
import type { CaptchaInfo, InitCheckResult } from '@/types/system'
|
||||
|
||||
type LoginForm = {
|
||||
username: string
|
||||
@@ -34,6 +34,9 @@ export function LoginPage() {
|
||||
const [captcha, setCaptcha] = useState<CaptchaInfo | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loadCaptchaError, setLoadCaptchaError] = useState<string | null>(null)
|
||||
const [checkingInit, setCheckingInit] = useState(true)
|
||||
const [needInit, setNeedInit] = useState(false)
|
||||
const [loadInitError, setLoadInitError] = useState<string | null>(null)
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const state = location.state as { redirectTo?: string } | null
|
||||
@@ -51,8 +54,38 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const checkInitialization = async () => {
|
||||
try {
|
||||
setCheckingInit(true)
|
||||
const response = await initApi.checkDB()
|
||||
const data: InitCheckResult = response.data
|
||||
setNeedInit(data.needInit)
|
||||
setLoadInitError(null)
|
||||
return data.needInit
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : '初始化状态检测失败'
|
||||
setLoadInitError(messageText)
|
||||
return false
|
||||
} finally {
|
||||
setCheckingInit(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptcha()
|
||||
let ignore = false
|
||||
|
||||
const prepare = async () => {
|
||||
const required = await checkInitialization()
|
||||
if (!required && !ignore) {
|
||||
await fetchCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
void prepare()
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const submit = async () => {
|
||||
@@ -87,10 +120,84 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (checkingInit) {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Gin-React-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 }}>
|
||||
如果当前服务还没有建立数据库连接,登录页会直接给出初始化入口,避免出现无法登录但原因不清楚的状态。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-form-wrap">
|
||||
<Card className="glass-panel login-card" bordered={false}>
|
||||
<div className="fullscreen-status" style={{ minHeight: 360 }}>
|
||||
<Spin size="large" />
|
||||
<span>正在检测初始化状态...</span>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (needInit) {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Gin-React-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 }}>
|
||||
后端已经暴露公开初始化接口。先创建数据库并写回配置,再使用管理员账号登录后台。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-form-wrap">
|
||||
<Card className="glass-panel login-card" bordered={false}>
|
||||
<Result
|
||||
status="warning"
|
||||
title="检测到项目尚未初始化"
|
||||
subTitle="当前登录流程不可用。请先进入初始化页,完成数据库和管理员账号初始化。"
|
||||
extra={[
|
||||
<Button key="init" type="primary" onClick={() => navigate('/init', { replace: true })}>
|
||||
前往初始化
|
||||
</Button>,
|
||||
<Button key="retry" onClick={() => void checkInitialization()}>
|
||||
重新检测
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
{loadInitError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="初始化状态检测异常"
|
||||
description={loadInitError}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<section className="login-hero">
|
||||
<span className="capsule">Gin-Vue-Admin · React 重设计版</span>
|
||||
<span className="capsule">Gin-React-Admin · React 重设计版</span>
|
||||
<div style={{ maxWidth: 620, marginTop: 72 }}>
|
||||
<Typography.Title style={{ color: 'rgba(255,255,255,0.96)', fontSize: 52, marginBottom: 18 }}>
|
||||
把现有后端协议
|
||||
@@ -110,6 +217,20 @@ export function LoginPage() {
|
||||
<Typography.Paragraph className="text-muted">
|
||||
首版默认沿用原系统认证协议,避免后端改造。
|
||||
</Typography.Paragraph>
|
||||
{loadInitError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="初始化状态检测失败"
|
||||
description={loadInitError}
|
||||
action={
|
||||
<Button size="small" onClick={() => void checkInitialization()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
{loadCaptchaError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
|
||||
@@ -42,18 +42,18 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
||||
title: '菜单管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '维护后台菜单树、路由属性、按钮定义和角色关联。',
|
||||
features: ['菜单树编辑', '路由路径维护', '角色分配', '按钮权限入口'],
|
||||
endpoints: ['/menu/getBaseMenuTree', '/menu/addBaseMenu', '/menu/updateBaseMenu', '/menu/setMenuRoles'],
|
||||
summary: '维护后台菜单树、路由属性、参数和按钮定义。',
|
||||
features: ['菜单树编辑', '路由路径维护', '参数配置', '按钮定义'],
|
||||
endpoints: ['/menu/getMenuList', '/menu/addBaseMenu', '/menu/updateBaseMenu'],
|
||||
},
|
||||
api: {
|
||||
name: 'api',
|
||||
title: 'API 管理',
|
||||
group: '平台治理',
|
||||
status: 'ready',
|
||||
summary: '管理接口目录、接口角色授权和 Casbin 刷新。',
|
||||
features: ['接口列表', '接口增删改', '角色授权', '接口分组'],
|
||||
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/setApiRoles'],
|
||||
summary: '管理接口目录、同步结果、分组和 Casbin 刷新。',
|
||||
features: ['接口列表', '接口增删改', '接口同步', '接口分组'],
|
||||
endpoints: ['/api/getApiList', '/api/createApi', '/api/updateApi', '/api/syncApi'],
|
||||
},
|
||||
user: {
|
||||
name: 'user',
|
||||
@@ -118,15 +118,6 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
||||
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: '错误日志',
|
||||
@@ -168,27 +159,9 @@ export const moduleCatalog: Record<string, ModuleDescriptor> = {
|
||||
title: '编程辅助',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '聚合表单设计、导出模板和 MCP 管理等研发辅助能力。',
|
||||
features: ['表单设计', '导出模板', 'MCP 管理'],
|
||||
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/mcp/status'],
|
||||
},
|
||||
formCreate: {
|
||||
name: 'formCreate',
|
||||
title: '表单生成器',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '拖拽式表单构建入口。',
|
||||
features: ['表单设计'],
|
||||
endpoints: [],
|
||||
},
|
||||
exportTemplate: {
|
||||
name: 'exportTemplate',
|
||||
title: '导出模板',
|
||||
group: '研发辅助',
|
||||
status: 'partial',
|
||||
summary: '维护 Excel 导入导出模板。',
|
||||
features: ['模板管理', 'SQL 预览'],
|
||||
endpoints: ['/sysExportTemplate/getSysExportTemplateList', '/sysExportTemplate/previewSQL'],
|
||||
summary: '聚合 MCP 管理等研发辅助能力。',
|
||||
features: ['MCP 管理', 'MCP 模板'],
|
||||
endpoints: ['/mcp/status', '/mcp/tools', '/mcp/createTool'],
|
||||
},
|
||||
mcpTest: {
|
||||
name: 'mcpTest',
|
||||
|
||||
@@ -2,13 +2,16 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Cascader,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Segmented,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -16,33 +19,136 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ApiOutlined,
|
||||
BugOutlined,
|
||||
BookOutlined,
|
||||
CloudServerOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DeleteOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
FileTextOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
LockOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
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'
|
||||
import { authorityBtnApi, menuApi } from '@/lib/api'
|
||||
import {
|
||||
buildMenuComponentOptions,
|
||||
getComponentLabelByRouteName,
|
||||
getComponentSuggestedRouteName,
|
||||
getComponentValueByRouteName,
|
||||
} from '@/features/menus/menuComponentCatalog'
|
||||
import { flattenMenusForOptions } from '@/lib/tree'
|
||||
import { filterRemovedMenus } from '@/lib/menu'
|
||||
import type { MenuButton, MenuNode, MenuParameter } from '@/types/system'
|
||||
|
||||
type MenuFormValues = {
|
||||
parentId: number
|
||||
path: string
|
||||
name: string
|
||||
hidden: boolean
|
||||
component: string
|
||||
sort: number
|
||||
meta: {
|
||||
title: string
|
||||
icon?: string
|
||||
activeName?: string
|
||||
defaultMenu?: boolean
|
||||
closeTab?: boolean
|
||||
keepAlive?: boolean
|
||||
transitionType?: string
|
||||
}
|
||||
parameters: MenuParameter[]
|
||||
menuBtn: MenuButton[]
|
||||
}
|
||||
|
||||
type ComponentInputMode = 'selector' | 'manual'
|
||||
|
||||
const componentOptions = buildMenuComponentOptions()
|
||||
|
||||
const iconRegistry = {
|
||||
AppstoreOutlined,
|
||||
ApiOutlined,
|
||||
BugOutlined,
|
||||
BookOutlined,
|
||||
CloudServerOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
FileTextOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
LockOutlined,
|
||||
ProfileOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UploadOutlined,
|
||||
UserOutlined,
|
||||
}
|
||||
|
||||
const iconOptions = Object.entries(iconRegistry).map(([name, IconComponent]) => ({
|
||||
value: name,
|
||||
searchText: name.toLowerCase(),
|
||||
label: (
|
||||
<Space>
|
||||
<IconComponent />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
}))
|
||||
|
||||
function createEmptyMenuForm(parentId = 0): MenuFormValues {
|
||||
return {
|
||||
parentId,
|
||||
path: '',
|
||||
name: '',
|
||||
hidden: false,
|
||||
component: '',
|
||||
sort: 1,
|
||||
meta: {
|
||||
title: '',
|
||||
icon: '',
|
||||
activeName: '',
|
||||
defaultMenu: false,
|
||||
closeTab: false,
|
||||
keepAlive: false,
|
||||
transitionType: '',
|
||||
},
|
||||
parameters: [],
|
||||
menuBtn: [],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMenuComponentValue(menu: Pick<MenuNode, 'name' | 'component'>) {
|
||||
return getComponentValueByRouteName(menu.name) || menu.component
|
||||
}
|
||||
|
||||
function resolveMenuComponentDisplay(menu: Pick<MenuNode, 'name' | 'component'>) {
|
||||
return getComponentLabelByRouteName(menu.name) || resolveMenuComponentValue(menu)
|
||||
}
|
||||
|
||||
export function MenuManagementPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [form] = Form.useForm<MenuFormValues>()
|
||||
const [menus, setMenus] = useState<MenuNode[]>([])
|
||||
const [roles, setRoles] = useState<Authority[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = 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 [componentInputMode, setComponentInputMode] = useState<ComponentInputMode>('selector')
|
||||
const selectedComponent = Form.useWatch('component', form)
|
||||
const selectedIconName = Form.useWatch(['meta', 'icon'], form)
|
||||
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
@@ -56,48 +162,51 @@ export function MenuManagementPage() {
|
||||
const reloadMenus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [menuRes, roleRes] = await Promise.all([menuApi.getBaseMenuTree(), authorityApi.getAuthorityList()])
|
||||
setMenus(menuRes.data.menus)
|
||||
setRoles(roleRes.data)
|
||||
const menuRes = await menuApi.getMenuList()
|
||||
setMenus(filterRemovedMenus(menuRes.data))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadMenus()
|
||||
void reloadMenus()
|
||||
}, [])
|
||||
|
||||
const openCreate = (parentId = 0) => {
|
||||
setEditingMenu(null)
|
||||
setComponentInputMode('selector')
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
parentId,
|
||||
sort: 1,
|
||||
hidden: false,
|
||||
keepAlive: false,
|
||||
closeTab: false,
|
||||
})
|
||||
setModalOpen(true)
|
||||
form.setFieldsValue(createEmptyMenuForm(parentId))
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: MenuNode) => {
|
||||
const response = await menuApi.getBaseMenuById(record.ID)
|
||||
const menu = response.data.menu
|
||||
const resolvedComponent = resolveMenuComponentValue(menu)
|
||||
setEditingMenu(menu)
|
||||
setComponentInputMode(getComponentValueByRouteName(menu.name) || getComponentSuggestedRouteName(menu.component) ? 'selector' : 'manual')
|
||||
form.setFieldsValue({
|
||||
parentId: menu.parentId,
|
||||
title: menu.meta.title,
|
||||
name: menu.name,
|
||||
path: menu.path,
|
||||
component: menu.component,
|
||||
icon: menu.meta.icon,
|
||||
name: menu.name,
|
||||
hidden: Boolean(menu.hidden),
|
||||
component: resolvedComponent,
|
||||
sort: menu.sort,
|
||||
hidden: menu.hidden,
|
||||
keepAlive: menu.meta.keepAlive,
|
||||
closeTab: menu.meta.closeTab,
|
||||
meta: {
|
||||
title: menu.meta.title,
|
||||
icon: menu.meta.icon,
|
||||
activeName: menu.meta.activeName,
|
||||
defaultMenu: Boolean(menu.meta.defaultMenu),
|
||||
closeTab: Boolean(menu.meta.closeTab),
|
||||
keepAlive: Boolean(menu.meta.keepAlive),
|
||||
transitionType: menu.meta.transitionType,
|
||||
},
|
||||
parameters: menu.parameters || [],
|
||||
menuBtn: menu.menuBtn || [],
|
||||
})
|
||||
setModalOpen(true)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const saveMenu = async () => {
|
||||
@@ -105,17 +214,31 @@ export function MenuManagementPage() {
|
||||
const payload = {
|
||||
ID: editingMenu?.ID,
|
||||
parentId: values.parentId || 0,
|
||||
name: values.name,
|
||||
path: values.path,
|
||||
name: values.name,
|
||||
hidden: Boolean(values.hidden),
|
||||
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),
|
||||
title: values.meta.title,
|
||||
icon: values.meta.icon,
|
||||
activeName: values.meta.activeName,
|
||||
defaultMenu: Boolean(values.meta.defaultMenu),
|
||||
closeTab: Boolean(values.meta.closeTab),
|
||||
keepAlive: Boolean(values.meta.keepAlive),
|
||||
transitionType: values.meta.transitionType,
|
||||
},
|
||||
parameters: (values.parameters || []).map((item) => ({
|
||||
ID: item.ID,
|
||||
type: item.type,
|
||||
key: item.key,
|
||||
value: item.value,
|
||||
})),
|
||||
menuBtn: (values.menuBtn || []).map((item) => ({
|
||||
ID: item.ID,
|
||||
name: item.name,
|
||||
desc: item.desc,
|
||||
})),
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
@@ -125,10 +248,10 @@ export function MenuManagementPage() {
|
||||
message.success('菜单已更新')
|
||||
} else {
|
||||
await menuApi.addBaseMenu(payload)
|
||||
message.success('菜单已创建')
|
||||
message.success('菜单已创建,请到角色管理分配菜单权限')
|
||||
}
|
||||
setModalOpen(false)
|
||||
reloadMenus()
|
||||
setEditorOpen(false)
|
||||
await reloadMenus()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -137,39 +260,29 @@ export function MenuManagementPage() {
|
||||
const deleteMenu = (record: MenuNode) => {
|
||||
Modal.confirm({
|
||||
title: `删除菜单 ${record.meta.title}`,
|
||||
content: '如果该菜单已被角色使用,删除前请先调整菜单授权。',
|
||||
content: '此操作会删除所有角色下的当前菜单关系,请确认后继续。',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await menuApi.deleteBaseMenu(record.ID)
|
||||
message.success('菜单已删除')
|
||||
reloadMenus()
|
||||
await 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) {
|
||||
const removeMenuButton = async (index: number) => {
|
||||
const currentButtons = form.getFieldValue('menuBtn') || []
|
||||
const target = currentButtons[index]
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setSavingRoles(true)
|
||||
try {
|
||||
await menuApi.setMenuRoles({
|
||||
menuId: activeMenu.ID,
|
||||
authorityIds: selectedRoles,
|
||||
})
|
||||
message.success('菜单角色关系已更新')
|
||||
setDrawerOpen(false)
|
||||
} finally {
|
||||
setSavingRoles(false)
|
||||
if (target.ID) {
|
||||
await authorityBtnApi.canRemoveAuthorityBtn(target.ID)
|
||||
}
|
||||
form.setFieldValue(
|
||||
'menuBtn',
|
||||
currentButtons.filter((_: MenuButton, currentIndex: number) => currentIndex !== index),
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<MenuNode> = [
|
||||
@@ -179,23 +292,36 @@ export function MenuManagementPage() {
|
||||
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,
|
||||
title: '图标',
|
||||
width: 140,
|
||||
render: (_, record) => record.meta.icon || '-',
|
||||
},
|
||||
{ title: '路由 Name', dataIndex: 'name', width: 160 },
|
||||
{ title: '路由 Path', dataIndex: 'path', width: 180 },
|
||||
{ title: '父节点', dataIndex: 'parentId', width: 100 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 90 },
|
||||
{
|
||||
title: '组件路径',
|
||||
width: 320,
|
||||
render: (_, record) => resolveMenuComponentDisplay(record),
|
||||
},
|
||||
{
|
||||
title: '显示配置',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Tag color={record.hidden ? 'default' : 'green'}>{record.hidden ? '隐藏' : '显示'}</Tag>
|
||||
{record.meta.keepAlive ? <Tag color="blue">KeepAlive</Tag> : null}
|
||||
{record.meta.closeTab ? <Tag color="orange">CloseTab</Tag> : null}
|
||||
{record.meta.defaultMenu ? <Tag color="purple">基础页</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 260,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
@@ -205,9 +331,6 @@ export function MenuManagementPage() {
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openRoleDrawer(record)}>
|
||||
分配角色
|
||||
</Button>
|
||||
<Button danger type="link" onClick={() => deleteMenu(record)}>
|
||||
删除
|
||||
</Button>
|
||||
@@ -219,118 +342,277 @@ export function MenuManagementPage() {
|
||||
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>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="菜单页只负责维护菜单结构。角色授权统一放到角色管理中处理:先新建 API,再新建菜单,最后到角色管理分配菜单和 API 权限。"
|
||||
/>
|
||||
<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>
|
||||
<Button type="primary" onClick={() => openCreate()}>
|
||||
新建根菜单
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1300 }}
|
||||
/>
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={menus}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1700 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingMenu ? '编辑菜单' : '新建菜单'}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={saveMenu}
|
||||
confirmLoading={saving}
|
||||
width={760}
|
||||
<Drawer
|
||||
open={editorOpen}
|
||||
title={editingMenu ? `编辑菜单 · ${editingMenu.meta.title}` : '新增菜单'}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
width={920}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setEditorOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={saveMenu}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="文件路径支持组件选择器和手动输入两种模式。新增菜单后,请到角色管理页继续分配菜单权限。"
|
||||
/>
|
||||
<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>
|
||||
<Typography.Title level={5}>基础信息</Typography.Title>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">文件路径模式</Typography.Text>
|
||||
<Segmented<ComponentInputMode>
|
||||
value={componentInputMode}
|
||||
options={[
|
||||
{ label: '组件选择器', value: 'selector' },
|
||||
{ label: '手动输入', value: 'manual' },
|
||||
]}
|
||||
onChange={(value) => setComponentInputMode(value)}
|
||||
/>
|
||||
</Space>
|
||||
{componentInputMode === 'selector' ? (
|
||||
<Form.Item label="文件路径" required>
|
||||
<Cascader
|
||||
style={{ width: '100%' }}
|
||||
options={componentOptions}
|
||||
value={(selectedComponent || '').split('/').filter(Boolean)}
|
||||
onChange={(value) => {
|
||||
const componentValue = value.join('/')
|
||||
form.setFieldValue('component', componentValue)
|
||||
if (!editingMenu) {
|
||||
const suggestedRouteName = getComponentSuggestedRouteName(componentValue)
|
||||
if (suggestedRouteName && !form.getFieldValue('name')) {
|
||||
form.setFieldValue('name', suggestedRouteName)
|
||||
form.setFieldValue('path', suggestedRouteName)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="请选择页面组件"
|
||||
/>
|
||||
<Form.Item name="component" hidden rules={[{ required: true, message: '请输入文件路径' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="component" label="文件路径" rules={[{ required: true, message: '请输入文件路径' }]}>
|
||||
<Input placeholder="例如 features/users/UserManagementPage" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<div className="config-form-grid">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="路由 Name"
|
||||
rules={[{ required: true, message: '请输入路由 Name' }]}
|
||||
style={{ width: '50%' }}
|
||||
className="config-grid-span-full"
|
||||
name={['meta', 'title']}
|
||||
label="展示名称"
|
||||
rules={[{ required: true, message: '请输入菜单展示名称' }]}
|
||||
>
|
||||
<Input />
|
||||
<Input placeholder="请输入菜单展示名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="path"
|
||||
label="路由 Path"
|
||||
rules={[{ required: true, message: '请输入路由 Path' }]}
|
||||
style={{ width: '50%' }}
|
||||
>
|
||||
<Input />
|
||||
<Form.Item name="name" label="路由 Name" rules={[{ required: true, message: '请输入路由 Name' }]}>
|
||||
<Input
|
||||
placeholder="唯一英文字符串"
|
||||
onChange={(event) => {
|
||||
const currentPath = form.getFieldValue('path')
|
||||
if (!editingMenu || currentPath === form.getFieldValue('name')) {
|
||||
form.setFieldValue('path', event.target.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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 name="path" label="路由 Path" rules={[{ required: true, message: '请输入路由 Path' }]}>
|
||||
<Input placeholder="建议与路由 Name 保持一致" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" style={{ width: '40%' }}>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title level={5}>路由配置</Typography.Title>
|
||||
<div className="config-form-grid">
|
||||
<Form.Item name="parentId" label="父节点">
|
||||
<Select options={[{ label: '根目录', value: 0 }, ...menuOptions]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序标记">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
<Space size="large">
|
||||
<Form.Item name="hidden" label="隐藏" valuePropName="checked">
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title level={5}>显示设置</Typography.Title>
|
||||
<div className="config-form-grid">
|
||||
<Form.Item name={['meta', 'icon']} label="图标">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="请选择图标"
|
||||
options={iconOptions}
|
||||
filterOption={(input, option) => String((option as { searchText?: string })?.searchText || '').includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="hidden" label="是否隐藏" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="keepAlive" label="KeepAlive" valuePropName="checked">
|
||||
</div>
|
||||
{selectedIconName && selectedIconName in iconRegistry ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag icon={(() => {
|
||||
const PreviewIcon = iconRegistry[selectedIconName as keyof typeof iconRegistry]
|
||||
return <PreviewIcon />
|
||||
})()}>
|
||||
{selectedIconName}
|
||||
</Tag>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Divider />
|
||||
<Typography.Title level={5}>高级配置</Typography.Title>
|
||||
<div className="config-form-grid">
|
||||
<Form.Item name={['meta', 'activeName']} label="高亮菜单">
|
||||
<Input placeholder="为空时默认使用当前路由 Name" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['meta', 'transitionType']} label="路由切换动画">
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ label: '淡入淡出', value: 'fade' },
|
||||
{ label: '滑动', value: 'slide' },
|
||||
{ label: '缩放', value: 'zoom' },
|
||||
{ label: '无动画', value: 'none' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Space size="large" wrap>
|
||||
<Form.Item name={['meta', 'keepAlive']} label="KeepAlive" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="closeTab" label="自动关闭 Tab" valuePropName="checked">
|
||||
<Form.Item name={['meta', 'closeTab']} label="CloseTab" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['meta', 'defaultMenu']} label="基础页面" 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}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="section-heading" style={{ marginBottom: 12 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||||
菜单参数配置
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Form.List name="parameters">
|
||||
{(fields, { add, remove }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{fields.map((field) => (
|
||||
<Card key={field.key} size="small" className="config-array-item">
|
||||
<div className="config-form-grid">
|
||||
<Form.Item
|
||||
name={[field.name, 'type']}
|
||||
label="参数类型"
|
||||
rules={[{ required: true, message: '请选择参数类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'query', value: 'query' },
|
||||
{ label: 'params', value: 'params' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[field.name, 'key']}
|
||||
label="参数 Key"
|
||||
rules={[{ required: true, message: '请输入参数 Key' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item className="config-grid-span-full" name={[field.name, 'value']} label="参数值">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button danger type="link" icon={<DeleteOutlined />} onClick={() => remove(field.name)}>
|
||||
删除参数
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add({ type: 'query', key: '', value: '' })}
|
||||
>
|
||||
新增菜单参数
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Divider />
|
||||
<div className="section-heading" style={{ marginBottom: 12 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||||
可控按钮配置
|
||||
</Typography.Title>
|
||||
</div>
|
||||
<Form.List name="menuBtn">
|
||||
{(fields, { add }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.key} size="small" className="config-array-item">
|
||||
<div className="config-form-grid">
|
||||
<Form.Item
|
||||
name={[field.name, 'name']}
|
||||
label="按钮名称"
|
||||
rules={[{ required: true, message: '请输入按钮名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'desc']} label="按钮备注">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button danger type="link" icon={<DeleteOutlined />} onClick={() => void removeMenuButton(index)}>
|
||||
删除按钮
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ name: '', desc: '' })}>
|
||||
新增可控按钮
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ type ComponentTreeNode = {
|
||||
|
||||
const componentOptions: ComponentOption[] = [
|
||||
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
|
||||
{ value: 'features/discovery/ModuleLandingPage:about', label: 'ModuleLandingPage · 关于系统', routeName: 'about' },
|
||||
{ value: 'features/roles/RoleManagementPage', label: 'RoleManagementPage · 角色管理', routeName: 'authority' },
|
||||
{ value: 'features/menus/MenuManagementPage', label: 'MenuManagementPage · 菜单管理', routeName: 'menu' },
|
||||
{ value: 'features/apis/ApiManagementPage', label: 'ApiManagementPage · API 管理', routeName: 'api' },
|
||||
@@ -54,17 +55,14 @@ const componentOptions: ComponentOption[] = [
|
||||
label: 'ModuleLandingPage · 编程辅助',
|
||||
routeName: 'systemTools',
|
||||
},
|
||||
{
|
||||
value: 'features/discovery/ModuleLandingPage:exportTemplate',
|
||||
label: 'ModuleLandingPage · 导出模板',
|
||||
routeName: 'exportTemplate',
|
||||
},
|
||||
{ value: 'features/mcp/McpTestPage', label: 'McpTestPage · MCP Tools 管理', routeName: 'mcpTest' },
|
||||
{ value: 'features/mcp/McpToolPage', label: 'McpToolPage · MCP Tools 模板', routeName: 'mcpTool' },
|
||||
{ value: 'features/media/MediaLibraryPage', label: 'MediaLibraryPage · 媒体库', routeName: 'upload' },
|
||||
]
|
||||
|
||||
const componentRouteNameMap = new Map(componentOptions.map((item) => [item.value, item.routeName]))
|
||||
const routeNameComponentMap = new Map(componentOptions.map((item) => [item.routeName, item.value]))
|
||||
const routeNameLabelMap = new Map(componentOptions.map((item) => [item.routeName, item.label]))
|
||||
|
||||
export function buildMenuComponentOptions(): ComponentTreeNode[] {
|
||||
const result: ComponentTreeNode[] = []
|
||||
@@ -105,3 +103,11 @@ export function isKnownMenuComponent(componentValue: string) {
|
||||
export function getComponentSuggestedRouteName(componentValue: string) {
|
||||
return componentRouteNameMap.get(componentValue)
|
||||
}
|
||||
|
||||
export function getComponentValueByRouteName(routeName: string) {
|
||||
return routeNameComponentMap.get(routeName)
|
||||
}
|
||||
|
||||
export function getComponentLabelByRouteName(routeName: string) {
|
||||
return routeNameLabelMap.get(routeName)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
@@ -17,58 +18,150 @@ import {
|
||||
} 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'
|
||||
import { authorityBtnApi, apiRegistryApi, authorityApi, casbinApi, menuApi, userApi } from '@/lib/api'
|
||||
import { filterRemovedMenus } from '@/lib/menu'
|
||||
import { collectMenusByIds, flattenAuthorities } from '@/lib/tree'
|
||||
import type { ApiRecord, Authority, MenuButton, MenuNode, UserInfo } from '@/types/system'
|
||||
|
||||
type PermissionTab = 'menus' | 'apis' | 'users'
|
||||
type PermissionTab = 'menus' | 'apis'
|
||||
|
||||
function mapMenusToTree(menus: MenuNode[]): DataNode[] {
|
||||
return menus.map((menu) => ({
|
||||
key: menu.ID,
|
||||
title: menu.meta.title,
|
||||
children: mapMenusToTree(menu.children || []),
|
||||
}))
|
||||
type RoleFormValues = {
|
||||
authorityId: number
|
||||
authorityName: string
|
||||
parentId: number
|
||||
}
|
||||
|
||||
function mapApisToTree(apis: ApiRecord[]): DataNode[] {
|
||||
const grouped = apis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
|
||||
if (!accumulator[api.apiGroup]) {
|
||||
accumulator[api.apiGroup] = []
|
||||
type UserSearchValues = {
|
||||
username: string
|
||||
nickName: string
|
||||
}
|
||||
|
||||
function filterMenuTree(nodes: MenuNode[], keyword: string): MenuNode[] {
|
||||
const normalized = keyword.trim()
|
||||
if (!normalized) {
|
||||
return nodes
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
const children = filterMenuTree(node.children || [], normalized)
|
||||
if (node.meta.title.includes(normalized) || node.name.includes(normalized) || children.length > 0) {
|
||||
return [{ ...node, children }]
|
||||
}
|
||||
accumulator[api.apiGroup].push(api)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function buildApiTree(apis: ApiRecord[], nameKeyword: string, pathKeyword: string): DataNode[] {
|
||||
const filteredApis = apis.filter((api) => {
|
||||
const matchedName = !nameKeyword || api.description.includes(nameKeyword)
|
||||
const matchedPath = !pathKeyword || api.path.includes(pathKeyword)
|
||||
return matchedName && matchedPath
|
||||
})
|
||||
|
||||
const grouped = filteredApis.reduce<Record<string, ApiRecord[]>>((accumulator, api) => {
|
||||
const group = api.apiGroup || '未分组'
|
||||
if (!accumulator[group]) {
|
||||
accumulator[group] = []
|
||||
}
|
||||
accumulator[group].push(api)
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
return Object.entries(grouped).map(([group, items]) => ({
|
||||
key: `group:${group}`,
|
||||
title: `${group} 组`,
|
||||
title: `${group}组`,
|
||||
children: items.map((item) => ({
|
||||
key: `${item.path}::${item.method}`,
|
||||
title: `${item.method} ${item.path} · ${item.description}`,
|
||||
title: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>{item.description || `${item.method} ${item.path}`}</span>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.path}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildMenuTree(
|
||||
menus: MenuNode[],
|
||||
defaultRouter: string | undefined,
|
||||
onOpenButtons: (menu: MenuNode) => void,
|
||||
onSetHomepage: (menu: MenuNode) => void,
|
||||
): DataNode[] {
|
||||
return menus.map((menu) => ({
|
||||
key: menu.ID,
|
||||
title: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>{menu.meta.title}</span>
|
||||
{defaultRouter === menu.name ? <Tag color="gold">默认首页</Tag> : null}
|
||||
{menu.name && !(menu.children || []).length ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onSetHomepage(menu)
|
||||
}}
|
||||
>
|
||||
设为首页
|
||||
</Button>
|
||||
) : null}
|
||||
{menu.menuBtn?.length ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onOpenButtons(menu)
|
||||
}}
|
||||
>
|
||||
分配按钮
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: buildMenuTree(menu.children || [], defaultRouter, onOpenButtons, onSetHomepage),
|
||||
}))
|
||||
}
|
||||
|
||||
export function RoleManagementPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [form] = Form.useForm<RoleFormValues>()
|
||||
const [userSearchForm] = Form.useForm<UserSearchValues>()
|
||||
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 [roleDrawerOpen, setRoleDrawerOpen] = useState(false)
|
||||
const [permissionDrawerOpen, setPermissionDrawerOpen] = useState(false)
|
||||
const [userDrawerOpen, setUserDrawerOpen] = useState(false)
|
||||
const [savingRole, setSavingRole] = useState(false)
|
||||
const [loadingUsers, setLoadingUsers] = useState(false)
|
||||
const [savingPermission, setSavingPermission] = useState(false)
|
||||
const [savingUsers, setSavingUsers] = 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 [menuHalfChecked, setMenuHalfChecked] = useState<number[]>([])
|
||||
const [defaultRouter, setDefaultRouter] = useState<string>()
|
||||
const [apiChecked, setApiChecked] = useState<string[]>([])
|
||||
const [userIds, setUserIds] = useState<number[]>([])
|
||||
const [savingPermission, setSavingPermission] = useState(false)
|
||||
const [menuFilter, setMenuFilter] = useState('')
|
||||
const [apiNameFilter, setApiNameFilter] = useState('')
|
||||
const [apiPathFilter, setApiPathFilter] = useState('')
|
||||
const [users, setUsers] = useState<UserInfo[]>([])
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set())
|
||||
const [userPage, setUserPage] = useState(1)
|
||||
const [userPageSize, setUserPageSize] = useState(10)
|
||||
const [userTotal, setUserTotal] = useState(0)
|
||||
const [buttonModalOpen, setButtonModalOpen] = useState(false)
|
||||
const [buttonMenu, setButtonMenu] = useState<MenuNode | null>(null)
|
||||
const [selectedButtonIds, setSelectedButtonIds] = useState<number[]>([])
|
||||
const [savingButtons, setSavingButtons] = useState(false)
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
@@ -79,6 +172,29 @@ export function RoleManagementPage() {
|
||||
[roles],
|
||||
)
|
||||
|
||||
const defaultRouterOptions = useMemo(
|
||||
() =>
|
||||
collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
||||
.filter((menu) => menu.name && !menu.name.startsWith('http://') && !menu.name.startsWith('https://'))
|
||||
.filter((menu) => !(menu.children || []).length)
|
||||
.map((menu) => ({
|
||||
label: menu.meta.title,
|
||||
value: menu.name,
|
||||
})),
|
||||
[menuChecked, menuHalfChecked, menuTree],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionDrawerOpen || !defaultRouter) {
|
||||
return
|
||||
}
|
||||
if (defaultRouterOptions.some((item) => item.value === defaultRouter)) {
|
||||
return
|
||||
}
|
||||
setDefaultRouter(undefined)
|
||||
message.warning('当前默认首页已不在授权菜单内,已自动清空,请重新选择')
|
||||
}, [defaultRouter, defaultRouterOptions, permissionDrawerOpen])
|
||||
|
||||
const reloadRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -90,15 +206,77 @@ export function RoleManagementPage() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reloadRoles()
|
||||
void reloadRoles()
|
||||
}, [])
|
||||
|
||||
const openCreate = (parentId?: number) => {
|
||||
const loadUserList = useCallback(async (roleId: number) => {
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const [userRes, roleUserRes] = await Promise.all([
|
||||
userApi.getUserList({
|
||||
page: userPage,
|
||||
pageSize: userPageSize,
|
||||
username: userSearchForm.getFieldValue('username') || '',
|
||||
nickName: userSearchForm.getFieldValue('nickName') || '',
|
||||
orderKey: 'id',
|
||||
desc: true,
|
||||
}),
|
||||
authorityApi.getUsersByAuthorityId(roleId),
|
||||
])
|
||||
|
||||
setUsers(userRes.data.list)
|
||||
setUserTotal(userRes.data.total)
|
||||
setSelectedUserIds(new Set(roleUserRes.data))
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}, [userPage, userPageSize, userSearchForm])
|
||||
|
||||
const openButtonModal = useCallback(async (menu: MenuNode) => {
|
||||
if (!activeRole) {
|
||||
return
|
||||
}
|
||||
const response = await authorityBtnApi.getAuthorityBtn({
|
||||
menuID: menu.ID,
|
||||
authorityId: activeRole.authorityId,
|
||||
})
|
||||
setButtonMenu(menu)
|
||||
setSelectedButtonIds(response.data.selected)
|
||||
setButtonModalOpen(true)
|
||||
}, [activeRole])
|
||||
|
||||
const filteredMenus = useMemo(() => filterMenuTree(menuTree, menuFilter), [menuFilter, menuTree])
|
||||
const menuTreeData = useMemo(
|
||||
() =>
|
||||
buildMenuTree(
|
||||
filteredMenus,
|
||||
defaultRouter,
|
||||
(menu) => void openButtonModal(menu),
|
||||
(menu) => {
|
||||
const checkedSet = new Set([...menuChecked, ...menuHalfChecked])
|
||||
if (!checkedSet.has(menu.ID)) {
|
||||
message.warning('请先勾选菜单,再将其设为首页')
|
||||
return
|
||||
}
|
||||
setDefaultRouter(menu.name)
|
||||
},
|
||||
),
|
||||
[defaultRouter, filteredMenus, menuChecked, menuHalfChecked, openButtonModal],
|
||||
)
|
||||
const apiTreeData = useMemo(() => buildApiTree(apis, apiNameFilter, apiPathFilter), [apiNameFilter, apiPathFilter, apis])
|
||||
|
||||
useEffect(() => {
|
||||
if (userDrawerOpen && activeRole) {
|
||||
void loadUserList(activeRole.authorityId)
|
||||
}
|
||||
}, [activeRole, loadUserList, userDrawerOpen])
|
||||
|
||||
const openCreate = (parentId = 0) => {
|
||||
setEditingRole(null)
|
||||
setCopySource(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ parentId: parentId ?? 0 })
|
||||
setRoleModalOpen(true)
|
||||
form.setFieldsValue({ parentId, authorityId: undefined as never, authorityName: '' })
|
||||
setRoleDrawerOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (record: Authority) => {
|
||||
@@ -109,18 +287,18 @@ export function RoleManagementPage() {
|
||||
authorityName: record.authorityName,
|
||||
parentId: record.parentId ?? 0,
|
||||
})
|
||||
setRoleModalOpen(true)
|
||||
setRoleDrawerOpen(true)
|
||||
}
|
||||
|
||||
const openCopy = (record: Authority) => {
|
||||
setEditingRole(null)
|
||||
setCopySource(record)
|
||||
form.setFieldsValue({
|
||||
authorityId: undefined,
|
||||
authorityId: undefined as never,
|
||||
authorityName: `${record.authorityName}-副本`,
|
||||
parentId: record.parentId ?? 0,
|
||||
})
|
||||
setRoleModalOpen(true)
|
||||
setRoleDrawerOpen(true)
|
||||
}
|
||||
|
||||
const saveRole = async () => {
|
||||
@@ -131,7 +309,7 @@ export function RoleManagementPage() {
|
||||
await authorityApi.copyAuthority({
|
||||
oldAuthorityId: copySource.authorityId,
|
||||
authority: {
|
||||
authorityId: values.authorityId,
|
||||
authorityId: Number(values.authorityId),
|
||||
authorityName: values.authorityName,
|
||||
parentId: values.parentId,
|
||||
},
|
||||
@@ -147,15 +325,15 @@ export function RoleManagementPage() {
|
||||
message.success('角色已更新')
|
||||
} else {
|
||||
await authorityApi.createAuthority({
|
||||
authorityId: values.authorityId,
|
||||
authorityId: Number(values.authorityId),
|
||||
authorityName: values.authorityName,
|
||||
parentId: values.parentId,
|
||||
})
|
||||
message.success('角色已创建')
|
||||
}
|
||||
|
||||
setRoleModalOpen(false)
|
||||
reloadRoles()
|
||||
setRoleDrawerOpen(false)
|
||||
await reloadRoles()
|
||||
} finally {
|
||||
setSavingRole(false)
|
||||
}
|
||||
@@ -164,37 +342,49 @@ export function RoleManagementPage() {
|
||||
const deleteRole = (record: Authority) => {
|
||||
Modal.confirm({
|
||||
title: `删除角色 ${record.authorityName}`,
|
||||
content: '删除前请确认没有用户正在依赖该角色。',
|
||||
content: '删除前请确认没有用户仍依赖当前角色。',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await authorityApi.deleteAuthority(record.authorityId)
|
||||
message.success('角色已删除')
|
||||
reloadRoles()
|
||||
await reloadRoles()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openPermissionDrawer = async (record: Authority) => {
|
||||
setActiveRole(record)
|
||||
setDrawerOpen(true)
|
||||
const [menuRes, checkedMenusRes, apiRes, policyRes, userRes, roleUserRes] = await Promise.all([
|
||||
setPermissionDrawerOpen(true)
|
||||
setActiveTab('menus')
|
||||
setMenuFilter('')
|
||||
setApiNameFilter('')
|
||||
setApiPathFilter('')
|
||||
const [menuRes, checkedMenusRes, apiRes, policyRes] = 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)
|
||||
setMenuTree(filterRemovedMenus(menuRes.data.menus))
|
||||
setMenuChecked(
|
||||
checkedMenusRes.data.menus.map((item) => Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID)),
|
||||
checkedMenusRes.data.menus.map((item) =>
|
||||
Number((item as unknown as { menuId?: number; ID: number }).menuId ?? item.ID),
|
||||
),
|
||||
)
|
||||
setMenuHalfChecked([])
|
||||
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 openUserDrawer = async (record: Authority) => {
|
||||
setActiveRole(record)
|
||||
setUserDrawerOpen(true)
|
||||
userSearchForm.setFieldsValue({ username: '', nickName: '' })
|
||||
setUserPage(1)
|
||||
setUserPageSize(10)
|
||||
await loadUserList(record.authorityId)
|
||||
}
|
||||
|
||||
const saveCurrentPermission = async () => {
|
||||
@@ -204,7 +394,7 @@ export function RoleManagementPage() {
|
||||
setSavingPermission(true)
|
||||
try {
|
||||
if (activeTab === 'menus') {
|
||||
const checkedMenus = collectCheckedLeafMenus(menuTree, new Set(menuChecked))
|
||||
const checkedMenus = collectMenusByIds(menuTree, new Set([...menuChecked, ...menuHalfChecked]))
|
||||
await menuApi.addMenuAuthority({
|
||||
authorityId: activeRole.authorityId,
|
||||
menus: checkedMenus,
|
||||
@@ -216,7 +406,7 @@ export function RoleManagementPage() {
|
||||
defaultRouter,
|
||||
})
|
||||
message.success('菜单权限已保存')
|
||||
} else if (activeTab === 'apis') {
|
||||
} else {
|
||||
const selectedApis = apis
|
||||
.filter((item) => apiChecked.includes(`${item.path}::${item.method}`))
|
||||
.map((item) => ({ path: item.path, method: item.method }))
|
||||
@@ -225,59 +415,82 @@ export function RoleManagementPage() {
|
||||
casbinInfos: selectedApis,
|
||||
})
|
||||
message.success('API 权限已保存')
|
||||
} else {
|
||||
await authorityApi.setRoleUsers({
|
||||
authorityId: activeRole.authorityId,
|
||||
userIds,
|
||||
})
|
||||
message.success('角色用户关系已保存')
|
||||
}
|
||||
reloadRoles()
|
||||
await reloadRoles()
|
||||
} finally {
|
||||
setSavingPermission(false)
|
||||
}
|
||||
}
|
||||
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
collectCheckedLeafMenus(menuTree, new Set(menuChecked)).map((menu) => ({
|
||||
label: menu.meta.title,
|
||||
value: menu.name,
|
||||
})),
|
||||
[menuChecked, menuTree],
|
||||
)
|
||||
const saveRoleUsers = async () => {
|
||||
if (!activeRole) {
|
||||
return
|
||||
}
|
||||
setSavingUsers(true)
|
||||
try {
|
||||
await authorityApi.setRoleUsers({
|
||||
authorityId: activeRole.authorityId,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
})
|
||||
message.success('角色用户关系已保存')
|
||||
setUserDrawerOpen(false)
|
||||
} finally {
|
||||
setSavingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveButtons = async () => {
|
||||
if (!activeRole || !buttonMenu) {
|
||||
return
|
||||
}
|
||||
setSavingButtons(true)
|
||||
try {
|
||||
await authorityBtnApi.setAuthorityBtn({
|
||||
menuID: buttonMenu.ID,
|
||||
authorityId: activeRole.authorityId,
|
||||
selected: selectedButtonIds,
|
||||
})
|
||||
message.success('按钮权限已保存')
|
||||
setButtonModalOpen(false)
|
||||
} finally {
|
||||
setSavingButtons(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Authority> = [
|
||||
{
|
||||
title: '角色 ID',
|
||||
dataIndex: 'authorityId',
|
||||
width: 120,
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'authorityName',
|
||||
width: 200,
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: '默认首页',
|
||||
dataIndex: 'defaultRouter',
|
||||
width: 140,
|
||||
width: 180,
|
||||
render: (value: string | undefined) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
width: 420,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Button type="link" onClick={() => openPermissionDrawer(record)}>
|
||||
设置权限
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openUserDrawer(record)}>
|
||||
分配给用户
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openCreate(record.authorityId)}>
|
||||
新增子角色
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openCopy(record)}>
|
||||
复制
|
||||
拷贝
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
@@ -293,54 +506,74 @@ export function RoleManagementPage() {
|
||||
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>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon message="右上角头像支持切换角色。角色页同时负责菜单授权、API 授权和角色用户绑定。" />
|
||||
<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>
|
||||
<Button type="primary" onClick={() => openCreate()}>
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="authorityId"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
<Table
|
||||
rowKey="authorityId"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={roleModalOpen}
|
||||
title={copySource ? `复制角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新建角色'}
|
||||
onCancel={() => setRoleModalOpen(false)}
|
||||
onOk={saveRole}
|
||||
confirmLoading={savingRole}
|
||||
<Drawer
|
||||
open={roleDrawerOpen}
|
||||
title={copySource ? `拷贝角色 · ${copySource.authorityName}` : editingRole ? '编辑角色' : '新增角色'}
|
||||
onClose={() => setRoleDrawerOpen(false)}
|
||||
width={520}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setRoleDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={savingRole} onClick={saveRole}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="parentId" label="父角色">
|
||||
<Form.Item name="parentId" label="父级角色" rules={[{ required: true, message: '请选择父级角色' }]}>
|
||||
<Select options={[{ label: '根角色', value: 0 }, ...roleOptions]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="authorityId" label="角色 ID" rules={[{ required: true, message: '请输入角色 ID' }]}>
|
||||
<Input />
|
||||
<Form.Item
|
||||
name="authorityId"
|
||||
label="角色 ID"
|
||||
rules={[
|
||||
{ required: true, message: '请输入角色 ID' },
|
||||
{
|
||||
validator: (_, value: number) =>
|
||||
String(value || '').match(/^[1-9]\d*$/) ? Promise.resolve() : Promise.reject(new Error('请输入正整数')),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input disabled={Boolean(editingRole)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="authorityName" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={900}
|
||||
title={activeRole ? `角色权限 · ${activeRole.authorityName}` : '角色权限'}
|
||||
open={permissionDrawerOpen}
|
||||
onClose={() => setPermissionDrawerOpen(false)}
|
||||
width={960}
|
||||
title={activeRole ? `角色配置 · ${activeRole.authorityName}` : '角色配置'}
|
||||
extra={
|
||||
<Button type="primary" loading={savingPermission} onClick={saveCurrentPermission}>
|
||||
保存当前标签
|
||||
@@ -348,50 +581,199 @@ export function RoleManagementPage() {
|
||||
}
|
||||
>
|
||||
<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)}
|
||||
<Tabs.TabPane tab="角色菜单" key="menus">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input placeholder="筛选菜单" value={menuFilter} onChange={(event) => setMenuFilter(event.target.value)} />
|
||||
<Select
|
||||
value={defaultRouter}
|
||||
allowClear
|
||||
placeholder="请选择默认首页"
|
||||
style={{ minWidth: 260 }}
|
||||
options={defaultRouterOptions}
|
||||
onChange={(value) => setDefaultRouter(value)}
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="默认首页必须来自已勾选菜单。菜单节点上的“分配按钮”用于维护当前角色的按钮权限。"
|
||||
/>
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandAll
|
||||
checkedKeys={menuChecked}
|
||||
treeData={mapMenusToTree(menuTree)}
|
||||
onCheck={(checkedKeys) => setMenuChecked((checkedKeys as number[]).map((item) => Number(item)))}
|
||||
treeData={menuTreeData}
|
||||
onCheck={(checkedKeys, info) => {
|
||||
if (Array.isArray(checkedKeys)) {
|
||||
setMenuChecked(checkedKeys.map((item) => Number(item)))
|
||||
setMenuHalfChecked(((info as { halfCheckedKeys?: Array<string | number> }).halfCheckedKeys || []).map((item) => Number(item)))
|
||||
return
|
||||
}
|
||||
setMenuChecked(checkedKeys.checked.map((item) => Number(item)))
|
||||
setMenuHalfChecked(checkedKeys.halfChecked.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 tab="角色 API" key="apis">
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Input placeholder="筛选描述" value={apiNameFilter} onChange={(event) => setApiNameFilter(event.target.value)} />
|
||||
<Input placeholder="筛选路径" value={apiPathFilter} onChange={(event) => setApiPathFilter(event.target.value)} />
|
||||
</div>
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandAll
|
||||
checkedKeys={apiChecked}
|
||||
treeData={apiTreeData}
|
||||
onCheck={(checkedKeys) => {
|
||||
if (Array.isArray(checkedKeys)) {
|
||||
setApiChecked(checkedKeys.filter((item) => !String(item).startsWith('group:')).map(String))
|
||||
return
|
||||
}
|
||||
setApiChecked(
|
||||
checkedKeys.checked.filter((item) => !String(item).startsWith('group:')).map((item) => String(item)),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={userDrawerOpen}
|
||||
onClose={() => setUserDrawerOpen(false)}
|
||||
width={900}
|
||||
title={activeRole ? `分配给用户 · ${activeRole.authorityName}` : '分配给用户'}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => setUserDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={savingUsers} onClick={saveRoleUsers}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="保存时会全量覆盖当前角色的用户关联关系。若用户仅剩这一个角色,被移除后其主角色不会自动调整。"
|
||||
/>
|
||||
<Form
|
||||
form={userSearchForm}
|
||||
layout="inline"
|
||||
onFinish={() => {
|
||||
setUserPage(1)
|
||||
if (activeRole) {
|
||||
void loadUserList(activeRole.authorityId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="nickName" label="昵称">
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
userSearchForm.resetFields()
|
||||
setUserPage(1)
|
||||
if (activeRole) {
|
||||
void loadUserList(activeRole.authorityId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="ID"
|
||||
loading={loadingUsers}
|
||||
dataSource={users}
|
||||
pagination={{
|
||||
current: userPage,
|
||||
pageSize: userPageSize,
|
||||
total: userTotal,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => {
|
||||
setUserPage(page)
|
||||
setUserPageSize(pageSize)
|
||||
},
|
||||
}}
|
||||
rowSelection={{
|
||||
preserveSelectedRowKeys: true,
|
||||
selectedRowKeys: Array.from(selectedUserIds),
|
||||
onSelect: (record, selected) => {
|
||||
setSelectedUserIds((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (selected) {
|
||||
next.add(record.ID)
|
||||
} else {
|
||||
next.delete(record.ID)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
onSelectAll: (selected, _, changeRows) => {
|
||||
setSelectedUserIds((previous) => {
|
||||
const next = new Set(previous)
|
||||
changeRows.forEach((user) => {
|
||||
if (selected) {
|
||||
next.add(user.ID)
|
||||
} else {
|
||||
next.delete(user.ID)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'ID', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'userName', width: 160 },
|
||||
{ title: '昵称', dataIndex: 'nickName', width: 160 },
|
||||
{
|
||||
title: '主角色',
|
||||
width: 160,
|
||||
render: (_, user) => user.authority?.authorityName || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={buttonModalOpen}
|
||||
title={buttonMenu ? `分配按钮 · ${buttonMenu.meta.title}` : '分配按钮'}
|
||||
onCancel={() => setButtonModalOpen(false)}
|
||||
onOk={() => void saveButtons()}
|
||||
confirmLoading={savingButtons}
|
||||
>
|
||||
<Table<MenuButton>
|
||||
rowKey="ID"
|
||||
pagination={false}
|
||||
dataSource={buttonMenu?.menuBtn || []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedButtonIds,
|
||||
onChange: (keys) => setSelectedButtonIds(keys.map((item) => Number(item))),
|
||||
}}
|
||||
columns={[
|
||||
{ title: '按钮名称', dataIndex: 'name' },
|
||||
{ title: '按钮备注', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,8 +78,9 @@ img {
|
||||
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;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
@@ -117,6 +118,8 @@ img {
|
||||
}
|
||||
|
||||
.admin-nav-menu .ant-menu-sub.ant-menu-inline {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 8px 0 12px;
|
||||
padding: 8px;
|
||||
border-radius: 20px;
|
||||
@@ -124,6 +127,11 @@ img {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-nav-menu .ant-menu-submenu,
|
||||
.admin-nav-menu .ant-menu-submenu-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-nav-menu.ant-menu-dark .ant-menu-item,
|
||||
.admin-nav-menu.ant-menu-dark .ant-menu-submenu-title,
|
||||
.admin-nav-menu.ant-menu-dark .ant-menu-item a,
|
||||
@@ -303,6 +311,12 @@ img {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.init-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.capsule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -319,6 +333,41 @@ img {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.admin-shell,
|
||||
.login-shell,
|
||||
.hero-grid,
|
||||
.metric-grid,
|
||||
.config-form-grid,
|
||||
.server-meter-grid,
|
||||
.server-runtime-grid,
|
||||
.server-disk-grid,
|
||||
.catalog-grid,
|
||||
.init-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
min-height: 320px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.catalog-card {
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { http } from './http'
|
||||
import type {
|
||||
ApiRecord,
|
||||
ApiGroupsPayload,
|
||||
ApiTokenRecord,
|
||||
AttachmentCategory,
|
||||
AuthorityButtonSelection,
|
||||
Authority,
|
||||
CaptchaInfo,
|
||||
Dictionary,
|
||||
DictionaryDetail,
|
||||
InitCheckResult,
|
||||
InitDBPayload,
|
||||
LoginLog,
|
||||
LoginResult,
|
||||
McpContent,
|
||||
@@ -19,6 +23,7 @@ import type {
|
||||
OperationRecord,
|
||||
PagePayload,
|
||||
ServerState,
|
||||
SyncApiPayload,
|
||||
SysErrorRecord,
|
||||
SysParam,
|
||||
UserInfo,
|
||||
@@ -48,6 +53,15 @@ export const authApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const initApi = {
|
||||
checkDB() {
|
||||
return http.post<InitCheckResult>('/init/checkdb')
|
||||
},
|
||||
initDB(payload: InitDBPayload) {
|
||||
return http.post<Record<string, never>>('/init/initdb', payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenu() {
|
||||
return http.post<{ menus: MenuNode[] }>('/menu/getMenu')
|
||||
@@ -167,12 +181,27 @@ export const apiRegistryApi = {
|
||||
deleteApi(payload: { ID: number }) {
|
||||
return http.post<Record<string, never>>('/api/deleteApi', payload)
|
||||
},
|
||||
deleteApisByIds(ids: number[]) {
|
||||
return http.delete<Record<string, never>>('/api/deleteApisByIds', { data: { ids } })
|
||||
},
|
||||
getApiById(id: number) {
|
||||
return http.post<{ api: ApiRecord }>('/api/getApiById', { ID: id })
|
||||
},
|
||||
freshCasbin() {
|
||||
return http.get<Record<string, never>>('/api/freshCasbin')
|
||||
},
|
||||
syncApi() {
|
||||
return http.get<SyncApiPayload>('/api/syncApi')
|
||||
},
|
||||
getApiGroups() {
|
||||
return http.get<ApiGroupsPayload>('/api/getApiGroups')
|
||||
},
|
||||
ignoreApi(payload: { path: string; method: string; flag: boolean }) {
|
||||
return http.post<Record<string, never>>('/api/ignoreApi', payload)
|
||||
},
|
||||
enterSyncApi(payload: SyncApiPayload) {
|
||||
return http.post<Record<string, never>>('/api/enterSyncApi', payload)
|
||||
},
|
||||
getApiRoles(path: string, method: string) {
|
||||
return http.get<number[]>('/api/getApiRoles', { params: { path, method } })
|
||||
},
|
||||
@@ -181,6 +210,20 @@ export const apiRegistryApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const authorityBtnApi = {
|
||||
getAuthorityBtn(payload: { menuID: number; authorityId: number }) {
|
||||
return http.post<AuthorityButtonSelection>('/authorityBtn/getAuthorityBtn', payload)
|
||||
},
|
||||
setAuthorityBtn(payload: { menuID: number; authorityId: number; selected: number[] }) {
|
||||
return http.post<Record<string, never>>('/authorityBtn/setAuthorityBtn', payload)
|
||||
},
|
||||
canRemoveAuthorityBtn(id: number) {
|
||||
return http.post<Record<string, never>>('/authorityBtn/canRemoveAuthorityBtn', undefined, {
|
||||
params: { id },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const dictionaryApi = {
|
||||
getDictionaryList(params?: Record<string, unknown>) {
|
||||
return http.get<Dictionary[]>('/sysDictionary/getSysDictionaryList', { params })
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AppMenu, MenuNode } from '@/types/system'
|
||||
|
||||
const removedFrontendMenuNames = new Set(['sysVersion', 'exportTemplate', 'formCreate'])
|
||||
|
||||
export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
|
||||
return (
|
||||
menu.path.startsWith('http://') ||
|
||||
@@ -9,6 +11,17 @@ export function isExternalMenu(menu: Pick<MenuNode, 'path' | 'component'>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function filterRemovedMenus<T extends MenuNode>(menus: T[]): T[] {
|
||||
return menus.flatMap((menu) => {
|
||||
if (removedFrontendMenuNames.has(menu.name)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const children = filterRemovedMenus((menu.children || []) as T[])
|
||||
return [{ ...menu, children } as T]
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePathSegment(path: string) {
|
||||
if (!path) {
|
||||
return ''
|
||||
@@ -22,7 +35,7 @@ export function buildFullMenus(
|
||||
parentPath = '',
|
||||
parentName?: string,
|
||||
): AppMenu[] {
|
||||
return [...menus]
|
||||
return [...filterRemovedMenus(menus)]
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
.map((menu) => {
|
||||
const normalized = normalizePathSegment(menu.path)
|
||||
|
||||
@@ -24,3 +24,13 @@ export function collectCheckedLeafMenus(menus: MenuNode[], checkedKeys: Set<numb
|
||||
return collectCheckedLeafMenus(children, checkedKeys)
|
||||
})
|
||||
}
|
||||
|
||||
export function collectMenusByIds(menus: MenuNode[], checkedKeys: Set<number>): MenuNode[] {
|
||||
return menus.flatMap((menu) => {
|
||||
const matchedChildren = collectMenusByIds(menu.children || [], checkedKeys)
|
||||
if (checkedKeys.has(menu.ID)) {
|
||||
return [{ ...menu, children: undefined }, ...matchedChildren]
|
||||
}
|
||||
return matchedChildren
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'sysVersion',
|
||||
}
|
||||
|
||||
export default createModulePage('sysVersion')
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'exportTemplate',
|
||||
}
|
||||
|
||||
export default createModulePage('exportTemplate')
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createModulePage } from '@/router/createModulePage'
|
||||
|
||||
export const routeMeta = {
|
||||
menuName: 'formCreate',
|
||||
}
|
||||
|
||||
export default createModulePage('formCreate')
|
||||
@@ -42,13 +42,24 @@ export type UserInfo = BaseEntity & {
|
||||
export type MenuMeta = {
|
||||
title: string
|
||||
icon?: string
|
||||
activeName?: string
|
||||
keepAlive?: boolean
|
||||
closeTab?: boolean
|
||||
defaultMenu?: boolean
|
||||
activeName?: string
|
||||
transitionType?: string
|
||||
}
|
||||
|
||||
export type MenuParameter = BaseEntity & {
|
||||
type: 'query' | 'params'
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type MenuButton = BaseEntity & {
|
||||
name: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
export type MenuNode = BaseEntity & {
|
||||
parentId: number
|
||||
path: string
|
||||
@@ -57,7 +68,8 @@ export type MenuNode = BaseEntity & {
|
||||
component: string
|
||||
sort: number
|
||||
meta: MenuMeta
|
||||
menuBtn?: Array<{ ID: number; name: string; desc?: string }>
|
||||
parameters?: MenuParameter[]
|
||||
menuBtn?: MenuButton[]
|
||||
btns?: Record<string, number>
|
||||
children?: MenuNode[]
|
||||
}
|
||||
@@ -82,6 +94,22 @@ export type CaptchaInfo = {
|
||||
openCaptcha: boolean
|
||||
}
|
||||
|
||||
export type InitCheckResult = {
|
||||
needInit: boolean
|
||||
}
|
||||
|
||||
export type InitDBPayload = {
|
||||
adminPassword: string
|
||||
dbType: 'mysql' | 'pgsql' | 'sqlite' | 'mssql'
|
||||
host?: string
|
||||
port?: string
|
||||
userName?: string
|
||||
password?: string
|
||||
dbName: string
|
||||
dbPath?: string
|
||||
template?: string
|
||||
}
|
||||
|
||||
export type ApiRecord = BaseEntity & {
|
||||
path: string
|
||||
description: string
|
||||
@@ -89,6 +117,21 @@ export type ApiRecord = BaseEntity & {
|
||||
method: string
|
||||
}
|
||||
|
||||
export type SyncApiPayload = {
|
||||
newApis: ApiRecord[]
|
||||
deleteApis: ApiRecord[]
|
||||
ignoreApis: ApiRecord[]
|
||||
}
|
||||
|
||||
export type ApiGroupsPayload = {
|
||||
groups: string[]
|
||||
apiGroupMap: Record<string, string>
|
||||
}
|
||||
|
||||
export type AuthorityButtonSelection = {
|
||||
selected: number[]
|
||||
}
|
||||
|
||||
export type Dictionary = BaseEntity & {
|
||||
name: string
|
||||
type: string
|
||||
|
||||
Reference in New Issue
Block a user