🎨 精简完善系统

This commit is contained in:
2026-04-10 17:57:48 +08:00
parent ee6565371e
commit 82c5020e71
55 changed files with 5785 additions and 9712 deletions

View File

@@ -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、菜单、权限和接口格式。
- 当前仍有部分研发辅助模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。
- 当前仍有部分模块保留为入口页,原因不是无法实现,而是这些模块交互面较大,适合单独拆阶段继续重构。

View File

@@ -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 />} />

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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"

View File

@@ -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',

View File

@@ -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>
)

View File

@@ -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)
}

View File

@@ -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>
)
}

View File

@@ -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;

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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
})
}

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'sysVersion',
}
export default createModulePage('sysVersion')

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'exportTemplate',
}
export default createModulePage('exportTemplate')

View File

@@ -1,7 +0,0 @@
import { createModulePage } from '@/router/createModulePage'
export const routeMeta = {
menuName: 'formCreate',
}
export default createModulePage('formCreate')

View File

@@ -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