🎉 完善基础模块

This commit is contained in:
2026-04-07 13:25:39 +08:00
parent c6f6930416
commit 22bb5fdc94
40 changed files with 2003 additions and 1 deletions

1
.gitignore vendored
View File

@@ -81,3 +81,4 @@ server/config.yaml
web web
.tmp

10
.mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"GVA_MCP": {
"headers": {
"x-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiMTM4MWYwZDgtYjU4MS00NTdhLWFhZmItMmE5NzgwMGVkOGFkIiwiSUQiOjEsIlVzZXJuYW1lIjoiYWRtaW4iLCJOaWNrTmFtZSI6Ik1yLuWlh-a3vCIsIkF1dGhvcml0eUlkIjo4ODgsIkJ1ZmZlclRpbWUiOjg2NDAwLCJpc3MiOiJxbVBsdXMiLCJhdWQiOlsiR1ZBIl0sImV4cCI6MTc3NjEyNzY1MSwibmJmIjoxNzc1NTIyODUxfQ.XO9T9e0ALSWoi41We9NCBfj3YTXmkzIctgPTcgql5ME"
},
"url": "http://127.0.0.1:8889/mcp"
}
}
}

View File

@@ -0,0 +1,3 @@
package system
type AutoCodeService struct{}

View File

@@ -0,0 +1,407 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Alert,
Button,
Card,
Col,
Descriptions,
List,
Progress,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
Upload,
message,
} from 'antd'
import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface'
import { PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
import { breakpointApi } from '@/lib/api'
import { http } from '@/lib/http'
type UploadStatus = 'idle' | 'checking' | 'uploading' | 'paused' | 'completed' | 'error'
type BreakpointRemoteState = {
isFinish: boolean
filePath: string
uploadedChunks: number[]
}
const chunkSizeOptions = [
{ label: '2 MB / 片', value: 2 },
{ label: '4 MB / 片', value: 4 },
{ label: '8 MB / 片', value: 8 },
{ label: '16 MB / 片', value: 16 },
]
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) {
return `${Math.round(bytes / 1024)} KB`
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
async function createFileFingerprint(file: File) {
const seed = `${file.name}:${file.size}:${file.lastModified}`
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed))
return Array.from(new Uint8Array(buffer))
.map((item) => item.toString(16).padStart(2, '0'))
.join('')
}
function normalizeRemoteState(input: Record<string, unknown> | undefined): BreakpointRemoteState {
const chunks = (input?.exaFileChunk || input?.ExaFileChunk || []) as Array<Record<string, unknown>>
return {
isFinish: Boolean(input?.isFinish ?? input?.IsFinish),
filePath: String(input?.filePath ?? input?.FilePath ?? ''),
uploadedChunks: chunks
.map((item) => Number(item.fileChunkNumber ?? item.FileChunkNumber))
.filter((item) => Number.isFinite(item)),
}
}
export function BreakpointPage() {
const stopRequestedRef = useRef(false)
const [file, setFile] = useState<File | null>(null)
const [fileList, setFileList] = useState<UploadFile[]>([])
const [fileFingerprint, setFileFingerprint] = useState('')
const [chunkSizeMb, setChunkSizeMb] = useState(4)
const [status, setStatus] = useState<UploadStatus>('idle')
const [loading, setLoading] = useState(false)
const [uploadedChunks, setUploadedChunks] = useState<number[]>([])
const [chunkProgress, setChunkProgress] = useState(0)
const [resultPath, setResultPath] = useState('')
const [errorText, setErrorText] = useState('')
const chunkSize = chunkSizeMb * 1024 * 1024
const totalChunks = file ? Math.max(1, Math.ceil(file.size / chunkSize)) : 0
const progressPercent = useMemo(() => {
if (!totalChunks) {
return 0
}
const completed = uploadedChunks.length
const next = Math.min(totalChunks, completed + chunkProgress / 100)
return Math.round((next / totalChunks) * 100)
}, [chunkProgress, totalChunks, uploadedChunks.length])
const missingChunks = useMemo(() => {
if (!totalChunks) {
return []
}
const uploadedSet = new Set(uploadedChunks)
return Array.from({ length: totalChunks }, (_, index) => index).filter((index) => !uploadedSet.has(index))
}, [totalChunks, uploadedChunks])
useEffect(() => {
if (!file) {
return
}
let ignore = false
createFileFingerprint(file)
.then((fingerprint) => {
if (!ignore) {
setFileFingerprint(fingerprint)
}
})
.catch(() => {
if (!ignore) {
setErrorText('文件指纹生成失败')
setStatus('error')
}
})
return () => {
ignore = true
}
}, [file])
async function inspectRemoteState(targetFile = file, fingerprint = fileFingerprint) {
if (!targetFile || !fingerprint) {
return
}
setStatus('checking')
setErrorText('')
const response = await breakpointApi.findFile({
fileMd5: fingerprint,
fileName: targetFile.name,
chunkTotal: totalChunks,
})
const remote = normalizeRemoteState(response.data.file as unknown as Record<string, unknown>)
const nextUploadedChunks = Array.from(new Set(remote.uploadedChunks)).sort((left, right) => left - right)
setUploadedChunks(nextUploadedChunks)
setChunkProgress(0)
setResultPath(remote.filePath)
if (remote.isFinish) {
setStatus('completed')
return remote
}
setStatus(nextUploadedChunks.length > 0 ? 'paused' : 'idle')
return remote
}
async function startUpload() {
if (!file) {
message.warning('请先选择文件')
return
}
if (!fileFingerprint) {
message.warning('文件指纹正在生成,请稍后再试')
return
}
setLoading(true)
setErrorText('')
stopRequestedRef.current = false
try {
const remote = await inspectRemoteState(file, fileFingerprint)
if (remote?.isFinish) {
message.success('文件已完成上传')
return
}
const uploadedSet = new Set(remote?.uploadedChunks || uploadedChunks)
setStatus('uploading')
for (let index = 0; index < totalChunks; index += 1) {
if (uploadedSet.has(index)) {
continue
}
if (stopRequestedRef.current) {
setStatus('paused')
return
}
const start = index * chunkSize
const end = Math.min(file.size, start + chunkSize)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('file', chunk, `${file.name}.part${index}`)
formData.append('fileMd5', fileFingerprint)
formData.append('fileName', file.name)
formData.append('chunkNumber', String(index))
formData.append('chunkTotal', String(totalChunks))
formData.append('chunkMd5', '')
await http.post('/fileUploadAndDownload/breakpointContinue', formData, {
timeout: 0,
onUploadProgress: (event) => {
setChunkProgress(Math.round((event.progress || 0) * 100))
},
})
uploadedSet.add(index)
setUploadedChunks(Array.from(uploadedSet).sort((left, right) => left - right))
setChunkProgress(0)
}
if (stopRequestedRef.current) {
setStatus('paused')
return
}
const finishResponse = await breakpointApi.finish({
fileMd5: fileFingerprint,
fileName: file.name,
})
const filePath = finishResponse.data.filePath
await breakpointApi.removeChunk({
fileMd5: fileFingerprint,
filePath,
})
setResultPath(filePath)
setStatus('completed')
setChunkProgress(0)
message.success('文件已完成合并')
} catch (error) {
setStatus('error')
setErrorText(error instanceof Error ? error.message : '断点续传失败')
} finally {
setLoading(false)
}
}
function pauseUpload() {
stopRequestedRef.current = true
}
function resetTask(clearFile = false) {
stopRequestedRef.current = true
setStatus('idle')
setUploadedChunks([])
setChunkProgress(0)
setResultPath('')
setErrorText('')
if (clearFile) {
setFile(null)
setFileList([])
setFileFingerprint('')
}
}
const uploadSelectProps: UploadProps = {
multiple: false,
maxCount: 1,
beforeUpload: (nextFile) => {
resetTask()
setFile(nextFile as RcFile)
setFileList([
{
uid: nextFile.uid,
name: nextFile.name,
size: nextFile.size,
status: 'done',
},
])
return false
},
onRemove: () => {
resetTask(true)
},
fileList,
}
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</div>
<Space wrap>
<Select
value={chunkSizeMb}
options={chunkSizeOptions}
style={{ minWidth: 160 }}
disabled={loading}
onChange={(value) => {
setChunkSizeMb(value)
resetTask()
}}
/>
<Upload {...uploadSelectProps}>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<Button icon={<ReloadOutlined />} onClick={() => inspectRemoteState()} disabled={!file || !fileFingerprint}>
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={startUpload}
disabled={!file}
>
/
</Button>
<Button icon={<PauseCircleOutlined />} onClick={pauseUpload} disabled={!loading}>
</Button>
</Space>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="文件大小" value={file ? formatBytes(file.size) : '-'} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="分片总数" value={totalChunks || '-'} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="已完成分片" value={uploadedChunks.length} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="状态" value={status === 'idle' ? '待开始' : status} />
</Card>
</Col>
</Row>
</Card>
<Card className="glass-panel page-panel">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<div className="section-heading" style={{ marginBottom: 8 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Tag color={status === 'completed' ? 'success' : status === 'error' ? 'error' : 'processing'}>
{status}
</Tag>
</div>
<Progress percent={progressPercent} status={status === 'error' ? 'exception' : undefined} />
<Typography.Text className="text-muted">
{uploadedChunks.length} / {totalChunks || 0}
</Typography.Text>
</div>
{errorText ? <Alert type="error" showIcon message={errorText} /> : null}
{resultPath ? <Alert type="success" showIcon message={`文件已生成:${resultPath}`} /> : null}
<Descriptions bordered column={1} size="middle">
<Descriptions.Item label="文件名">{file?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="文件指纹">
<Typography.Text copyable={Boolean(fileFingerprint)}>
{fileFingerprint || '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="分片大小">{`${chunkSizeMb} MB`}</Descriptions.Item>
<Descriptions.Item label="剩余分片">
{missingChunks.length ? missingChunks.map((item) => item + 1).join('、') : '无'}
</Descriptions.Item>
</Descriptions>
<Card
size="small"
title="已完成分片"
extra={
<Button type="link" onClick={() => resetTask(true)}>
</Button>
}
>
<List
locale={{ emptyText: '暂无分片记录' }}
dataSource={uploadedChunks.map((item) => `${item + 1}`)}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Card>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,393 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Empty,
Input,
List,
Row,
Space,
Spin,
Statistic,
Tag,
Typography,
message,
} from 'antd'
import { mcpApi } from '@/lib/api'
import type { JsonSchema, McpContent, McpManagedStatus, McpServerConfig, McpToolDescriptor } from '@/types/system'
const { TextArea } = Input
function statusColor(state?: string) {
switch (state) {
case 'running':
return 'green'
case 'starting':
return 'orange'
case 'external':
return 'blue'
default:
return 'default'
}
}
function asSchemaRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
return value as Record<string, unknown>
}
function buildExampleValue(schema?: JsonSchema | Record<string, unknown> | unknown): unknown {
const current = asSchemaRecord(schema)
if ('default' in current) {
return current.default
}
const enumValues = Array.isArray(current.enum) ? current.enum : []
if (enumValues.length > 0) {
return enumValues[0]
}
const schemaType = typeof current.type === 'string' ? current.type : ''
switch (schemaType) {
case 'string':
return ''
case 'number':
case 'integer':
return 0
case 'boolean':
return false
case 'array':
return current.items ? [buildExampleValue(current.items)] : []
case 'object':
break
default:
if (!current.properties) {
return {}
}
}
const properties = asSchemaRecord(current.properties)
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(properties)) {
result[key] = buildExampleValue(value)
}
return result
}
function buildExampleArguments(tool?: McpToolDescriptor | null) {
if (!tool?.inputSchema) {
return '{}'
}
return JSON.stringify(buildExampleValue(tool.inputSchema), null, 2)
}
function renderContent(content: McpContent, index: number) {
if (content.type === 'image' && content.data) {
const mimeType = content.mimeType || 'image/png'
return (
<Card key={`content-${index}`} type="inner" title={`结果 ${index + 1} · image`}>
<img
alt={`mcp-result-${index + 1}`}
src={`data:${mimeType};base64,${content.data}`}
style={{ maxWidth: '100%', borderRadius: 12 }}
/>
</Card>
)
}
return (
<Card key={`content-${index}`} type="inner" title={`结果 ${index + 1} · ${content.type || 'unknown'}`}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{content.text || JSON.stringify(content, null, 2)}
</pre>
</Card>
)
}
export function McpTestPage() {
const [status, setStatus] = useState<McpManagedStatus | null>(null)
const [serverConfig, setServerConfig] = useState<McpServerConfig | null>(null)
const [tools, setTools] = useState<McpToolDescriptor[]>([])
const [selectedToolName, setSelectedToolName] = useState('')
const [argumentsText, setArgumentsText] = useState('{}')
const [result, setResult] = useState<McpContent[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [calling, setCalling] = useState(false)
const selectedTool = useMemo(
() => tools.find((item) => item.name === selectedToolName) || null,
[selectedToolName, tools],
)
const reload = async () => {
setLoading(true)
try {
const statusRes = await mcpApi.getStatus()
setStatus(statusRes.data.status)
setServerConfig(statusRes.data.mcpServerConfig)
if (!statusRes.data.status.reachable) {
setTools([])
return
}
const listRes = await mcpApi.getList()
const nextTools = listRes.data.list?.tools || []
setStatus(listRes.data.status)
setServerConfig(listRes.data.mcpServerConfig)
setTools(nextTools)
if (nextTools.length > 0) {
setSelectedToolName((current) =>
nextTools.some((item) => item.name === current) ? current : nextTools[0].name,
)
}
} finally {
setLoading(false)
}
}
useEffect(() => {
reload()
}, [])
useEffect(() => {
setArgumentsText(buildExampleArguments(selectedTool))
}, [selectedTool])
const runAction = async (action: 'start' | 'stop') => {
setActionLoading(true)
try {
if (action === 'start') {
await mcpApi.start()
message.success('MCP 独立服务已启动')
} else {
await mcpApi.stop()
message.success('MCP 独立服务已停用')
setResult([])
}
await reload()
} finally {
setActionLoading(false)
}
}
const callTool = async () => {
if (!selectedToolName) {
message.warning('请先选择一个工具')
return
}
let parsedArguments: Record<string, unknown> = {}
try {
parsedArguments = JSON.parse(argumentsText || '{}') as Record<string, unknown>
} catch {
message.error('参数 JSON 解析失败,请先修正格式')
return
}
setCalling(true)
try {
const response = await mcpApi.test({
name: selectedToolName,
arguments: parsedArguments,
})
setResult(response.data)
message.success('工具调用成功')
} finally {
setCalling(false)
}
}
const activeServer = useMemo(() => {
if (!serverConfig?.mcpServers) {
return null
}
const [serverName, serverValue] = Object.entries(serverConfig.mcpServers)[0] || []
if (!serverName || !serverValue) {
return null
}
return {
serverName,
...serverValue,
}
}, [serverConfig])
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
MCP Tools
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
MCP
</Typography.Paragraph>
</div>
<Space>
<Button onClick={reload} loading={loading}>
</Button>
<Button type="primary" onClick={() => runAction('start')} loading={actionLoading}>
</Button>
<Button danger onClick={() => runAction('stop')} loading={actionLoading}>
</Button>
</Space>
</div>
{status?.lastError ? (
<Alert
type="warning"
showIcon
message={status.message || 'MCP 状态异常'}
description={status.lastError}
style={{ marginBottom: 16 }}
/>
) : null}
<Row gutter={[16, 16]}>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="服务状态" value={status?.state || 'stopped'} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="已注册工具" value={tools.length} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="鉴权请求头" value={status?.authHeader || '-'} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card bordered={false}>
<Statistic title="监听地址" value={status?.listenAddr || '-'} />
</Card>
</Col>
</Row>
</Card>
<Card className="glass-panel page-panel" loading={loading}>
<Descriptions
title="服务连接信息"
bordered
column={1}
items={[
{
key: 'status',
label: '运行状态',
children: (
<Space>
<Tag color={statusColor(status?.state)}>{status?.state || 'stopped'}</Tag>
<span>{status?.message || '未获取状态信息'}</span>
</Space>
),
},
{ key: 'managed', label: '托管状态', children: status?.managed ? '页面托管' : '外部或未启动' },
{ key: 'reachable', label: '健康检查', children: status?.reachable ? '可访问' : '不可访问' },
{ key: 'baseURL', label: 'MCP 地址', children: status?.baseURL || '-' },
{ key: 'healthURL', label: '健康检查地址', children: status?.healthURL || '-' },
{ key: 'logPath', label: '日志文件', children: status?.logPath || '-' },
{ key: 'config', label: '客户端配置', children: activeServer ? JSON.stringify(activeServer, null, 2) : '-' },
]}
/>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} xl={8}>
<Card className="glass-panel page-panel" title={`工具列表${loading ? '' : ` (${tools.length})`}`}>
{loading ? (
<div className="fullscreen-status" style={{ minHeight: 240 }}>
<Spin />
</div>
) : tools.length === 0 ? (
<Empty description={status?.reachable ? '当前服务未返回工具' : '请先启动 MCP 独立服务'} />
) : (
<List
dataSource={tools}
renderItem={(tool) => (
<List.Item
style={{
cursor: 'pointer',
borderRadius: 12,
paddingInline: 12,
background: tool.name === selectedToolName ? 'rgba(24, 144, 255, 0.08)' : 'transparent',
}}
onClick={() => setSelectedToolName(tool.name)}
>
<List.Item.Meta
title={
<Space>
<span>{tool.name}</span>
{tool.name === selectedToolName ? <Tag color="blue"></Tag> : null}
</Space>
}
description={tool.description || '未提供工具描述'}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
<Col xs={24} xl={16}>
<Card
className="glass-panel page-panel"
title={selectedTool ? `工具调试 · ${selectedTool.name}` : '工具调试'}
extra={
<Button type="primary" loading={calling} onClick={callTool} disabled={!selectedTool}>
</Button>
}
>
{selectedTool ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={selectedTool.description || '当前工具未提供描述'}
/>
<div>
<Typography.Title level={5}> Schema</Typography.Title>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(selectedTool.inputSchema || {}, null, 2)}
</pre>
</div>
<div>
<Typography.Title level={5}> JSON</Typography.Title>
<TextArea
value={argumentsText}
onChange={(event) => setArgumentsText(event.target.value)}
autoSize={{ minRows: 12, maxRows: 24 }}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
/>
</div>
</Space>
) : (
<Empty description="请先从左侧选择一个工具" />
)}
</Card>
</Col>
</Row>
<Card className="glass-panel page-panel" title="调用结果">
{result.length === 0 ? (
<Empty description="工具调用结果会显示在这里" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{result.map((item, index) => renderContent(item, index))}
</Space>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { useState } from 'react'
import {
Alert,
Button,
Card,
Col,
Form,
Input,
Row,
Select,
Space,
Switch,
Typography,
message,
} from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { mcpApi } from '@/lib/api'
import type { AutoMcpToolRequest } from '@/types/system'
const paramTypeOptions = [
{ label: 'string', value: 'string' },
{ label: 'number', value: 'number' },
{ label: 'boolean', value: 'boolean' },
{ label: 'object', value: 'object' },
{ label: 'array', value: 'array' },
] as const
const responseTypeOptions = [
{ label: 'text', value: 'text' },
{ label: 'image', value: 'image' },
] as const
const initialValues: AutoMcpToolRequest = {
name: '',
description: '',
params: [
{
name: '',
description: '',
type: 'string',
required: true,
default: '',
},
],
response: [
{
type: 'text',
},
],
}
function extractToolPath(messageText: string) {
const marker = 'MCP Tool路径:'
const index = messageText.indexOf(marker)
if (index === -1) {
return ''
}
return messageText.slice(index + marker.length).trim()
}
export function McpToolPage() {
const [form] = Form.useForm<AutoMcpToolRequest>()
const [submitting, setSubmitting] = useState(false)
const [toolPath, setToolPath] = useState('')
const submit = async () => {
const values = await form.validateFields()
if (!values.response?.length) {
message.error('至少需要定义一种响应类型')
return
}
setSubmitting(true)
try {
const response = await mcpApi.createTool(values)
const generatedPath = extractToolPath(response.msg || '')
setToolPath(generatedPath)
message.success(response.msg || 'MCP Tool 模板已生成')
} finally {
setSubmitting(false)
}
}
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
MCP Tool
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
`server/mcp/*.go` MCP
</Typography.Paragraph>
</div>
<Space>
<Button onClick={() => form.resetFields()}></Button>
<Button type="primary" loading={submitting} onClick={submit}>
</Button>
</Space>
</div>
{toolPath ? (
<Alert
type="success"
showIcon
message="模板生成成功"
description={`文件已写入:${toolPath}`}
style={{ marginBottom: 16 }}
/>
) : null}
<Alert
type="info"
showIcon
message="生成后需要做的事"
description="模板文件生成后,补充具体业务逻辑并重新启动 MCP 独立服务,新工具才会出现在 MCP Tools 管理页。"
style={{ marginBottom: 16 }}
/>
</Card>
<Card className="glass-panel page-panel">
<Form form={form} layout="vertical" initialValues={initialValues}>
<Row gutter={[16, 0]}>
<Col xs={24} md={12}>
<Form.Item
name="name"
label="工具名称"
rules={[
{ required: true, message: '请输入工具名称' },
{ pattern: /^[a-z][a-z0-9_]*$/, message: '工具名称使用小写字母、数字和下划线,且必须以字母开头' },
]}
>
<Input placeholder="create_invoice" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="description"
label="工具描述"
rules={[{ required: true, message: '请输入工具描述' }]}
>
<Input placeholder="创建发票并返回结果摘要" />
</Form.Item>
</Col>
</Row>
<Typography.Title level={4}></Typography.Title>
<Form.List name="params">
{(fields, { add, remove }) => (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{fields.map((field) => (
<Card
key={field.key}
type="inner"
title={`参数 ${field.name + 1}`}
extra={
fields.length > 1 ? (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(field.name)}>
</Button>
) : null
}
>
<Row gutter={[16, 0]}>
<Col xs={24} md={8}>
<Form.Item
name={[field.name, 'name']}
label="参数名"
rules={[
{ required: true, message: '请输入参数名' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '参数名使用字母、数字和下划线' },
]}
>
<Input placeholder="invoiceNo" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name={[field.name, 'type']}
label="参数类型"
rules={[{ required: true, message: '请选择参数类型' }]}
>
<Select options={paramTypeOptions as unknown as Array<{ label: string; value: string }>} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name={[field.name, 'default']} label="默认值">
<Input placeholder="可选,按字符串写入模板" />
</Form.Item>
</Col>
</Row>
<Row gutter={[16, 0]}>
<Col xs={24} md={18}>
<Form.Item
name={[field.name, 'description']}
label="参数描述"
rules={[{ required: true, message: '请输入参数描述' }]}
>
<Input placeholder="发票编号" />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={[field.name, 'required']} label="必填" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Card>
))}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
add({
name: '',
description: '',
type: 'string',
required: false,
default: '',
})
}
>
</Button>
</Space>
)}
</Form.List>
<Typography.Title level={4} style={{ marginTop: 24 }}>
</Typography.Title>
<Form.List name="response">
{(fields, { add, remove }) => (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{fields.map((field) => (
<Card
key={field.key}
type="inner"
title={`响应 ${field.name + 1}`}
extra={
fields.length > 1 ? (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(field.name)}>
</Button>
) : null
}
>
<Form.Item
name={[field.name, 'type']}
label="响应类型"
rules={[{ required: true, message: '请选择响应类型' }]}
>
<Select options={responseTypeOptions as unknown as Array<{ label: string; value: string }>} />
</Form.Item>
</Card>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ type: 'text' })}>
</Button>
</Space>
)}
</Form.List>
</Form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,521 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
Col,
Form,
Image,
Input,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tree,
Typography,
Upload,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { UploadProps } from 'antd'
import type { DataNode } from 'antd/es/tree'
import { DeleteOutlined, EditOutlined, FolderAddOutlined, UploadOutlined } from '@ant-design/icons'
import { formatDate } from '@/lib/date'
import { http } from '@/lib/http'
import { mediaLibraryApi } from '@/lib/api'
import type { AttachmentCategory, MediaFileRecord } from '@/types/system'
type SearchFormValues = {
keyword?: string
classId?: number
}
type CategoryFormValues = {
name: string
pid: number
}
type RenameFormValues = {
name: string
}
function flattenCategories(categories?: AttachmentCategory[] | null): AttachmentCategory[] {
return (categories || []).flatMap((item) => [item, ...flattenCategories(item.children || [])])
}
function buildTreeData(categories?: AttachmentCategory[] | null): DataNode[] {
return (categories || []).map((item) => ({
key: item.ID,
title: item.name,
children: buildTreeData(item.children || []),
}))
}
function buildSelectOptions(
categories?: AttachmentCategory[] | null,
depth = 0,
): Array<{ label: string; value: number }> {
return (categories || []).flatMap((item) => [
{ label: `${' '.repeat(depth)}${item.name}`, value: item.ID },
...buildSelectOptions(item.children || [], depth + 1),
])
}
function isImageFile(tag: string) {
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(tag.toLowerCase())
}
export function MediaLibraryPage() {
const [searchForm] = Form.useForm<SearchFormValues>()
const [categoryForm] = Form.useForm<CategoryFormValues>()
const [renameForm] = Form.useForm<RenameFormValues>()
const [categories, setCategories] = useState<AttachmentCategory[]>([])
const [files, setFiles] = useState<MediaFileRecord[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [renameModalOpen, setRenameModalOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<AttachmentCategory | null>(null)
const [editingFile, setEditingFile] = useState<MediaFileRecord | null>(null)
const [selectedCategoryId, setSelectedCategoryId] = useState<number>()
const [uploadClassId, setUploadClassId] = useState<number>()
const flatCategories = useMemo(() => flattenCategories(categories), [categories])
const categoryOptions = useMemo(() => buildSelectOptions(categories), [categories])
const categoryNameMap = useMemo(
() => Object.fromEntries(flatCategories.map((item) => [item.ID, item.name])),
[flatCategories],
)
const loadCategories = useCallback(async () => {
const response = await mediaLibraryApi.getCategoryList()
setCategories(response.data || [])
}, [])
const loadFiles = useCallback(async (nextPage = page, nextPageSize = pageSize) => {
setLoading(true)
try {
const values = searchForm.getFieldsValue()
const response = await mediaLibraryApi.getFileList({
page: nextPage,
pageSize: nextPageSize,
keyword: values.keyword,
classId: values.classId,
})
setFiles(response.data.list)
setTotal(response.data.total)
} finally {
setLoading(false)
}
}, [page, pageSize, searchForm])
useEffect(() => {
loadCategories()
}, [loadCategories])
useEffect(() => {
loadFiles()
}, [loadFiles])
const uploadProps: UploadProps = {
multiple: true,
showUploadList: false,
customRequest: async (options) => {
const formData = new FormData()
formData.append('file', options.file as File)
formData.append('classId', String(uploadClassId || searchForm.getFieldValue('classId') || 0))
try {
await http.post('/fileUploadAndDownload/upload', formData, {
timeout: 0,
onUploadProgress: (event) => {
options.onProgress?.({ percent: Math.round((event.progress || 0) * 100) })
},
})
options.onSuccess?.({})
const fileLabel =
typeof options.file === 'string' ? options.file : 'name' in options.file ? options.file.name : '文件'
message.success(`${fileLabel} 上传成功`)
await loadFiles(1, pageSize)
setPage(1)
} catch (error) {
options.onError?.(error as Error)
}
},
}
function openCreateCategory() {
setEditingCategory(null)
categoryForm.setFieldsValue({
name: '',
pid: selectedCategoryId || 0,
})
setCategoryModalOpen(true)
}
function openEditCategory() {
if (!selectedCategoryId) {
message.warning('请先选择分类')
return
}
const target = flatCategories.find((item) => item.ID === selectedCategoryId)
if (!target) {
return
}
setEditingCategory(target)
categoryForm.setFieldsValue({
name: target.name,
pid: target.pid,
})
setCategoryModalOpen(true)
}
async function saveCategory() {
const values = await categoryForm.validateFields()
await mediaLibraryApi.saveCategory({
ID: editingCategory?.ID,
name: values.name,
pid: values.pid,
})
message.success(editingCategory ? '分类已更新' : '分类已创建')
setCategoryModalOpen(false)
await loadCategories()
}
function removeCategory() {
if (!selectedCategoryId) {
message.warning('请先选择分类')
return
}
const target = flatCategories.find((item) => item.ID === selectedCategoryId)
if (!target) {
return
}
Modal.confirm({
title: `删除分类 ${target.name}`,
content: '删除前需要先清空子分类和分类下的文件。',
okButtonProps: { danger: true },
onOk: async () => {
await mediaLibraryApi.deleteCategory(target.ID)
message.success('分类已删除')
setSelectedCategoryId(undefined)
searchForm.setFieldValue('classId', undefined)
await loadCategories()
await loadFiles(1, pageSize)
setPage(1)
},
})
}
function openRename(record: MediaFileRecord) {
setEditingFile(record)
renameForm.setFieldsValue({ name: record.name })
setRenameModalOpen(true)
}
async function saveRename() {
const values = await renameForm.validateFields()
if (!editingFile) {
return
}
await mediaLibraryApi.editFile({
ID: editingFile.ID,
name: values.name,
})
message.success('文件名已更新')
setRenameModalOpen(false)
await loadFiles()
}
function removeFile(record: MediaFileRecord) {
Modal.confirm({
title: `删除文件 ${record.name}`,
content: '删除后无法恢复。',
okButtonProps: { danger: true },
onOk: async () => {
await mediaLibraryApi.deleteFile(record.ID)
message.success('文件已删除')
await loadFiles()
},
})
}
const columns: ColumnsType<MediaFileRecord> = [
{
title: '预览',
width: 96,
render: (_, record) =>
isImageFile(record.tag) ? (
<Image
width={56}
height={56}
src={record.url}
style={{ objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<div
style={{
width: 56,
height: 56,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: 'rgba(16, 37, 66, 0.06)',
color: 'var(--text-secondary)',
fontSize: 12,
textTransform: 'uppercase',
}}
>
{record.tag || 'file'}
</div>
),
},
{
title: '文件名',
dataIndex: 'name',
ellipsis: true,
},
{
title: '分类',
width: 160,
render: (_, record) => categoryNameMap[record.classId] || '未分类',
},
{
title: '类型',
dataIndex: 'tag',
width: 120,
render: (value) => String(value || '-').toUpperCase(),
},
{
title: '访问地址',
dataIndex: 'url',
ellipsis: true,
render: (value: string) => (
<Typography.Link href={value} target="_blank">
</Typography.Link>
),
},
{
title: '上传时间',
width: 180,
render: (_, record) => formatDate(record.CreatedAt),
},
{
title: '操作',
key: 'actions',
width: 160,
render: (_, record) => (
<Space>
<Button type="link" onClick={() => openRename(record)}>
</Button>
<Button danger type="link" onClick={() => removeFile(record)}>
</Button>
</Space>
),
},
]
return (
<div className="page-stack">
<Card className="glass-panel page-panel">
<div className="section-heading">
<div>
<Typography.Title level={2} style={{ marginBottom: 8 }}>
</Typography.Title>
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</div>
<Space wrap>
<Select
allowClear
placeholder="上传到分类"
style={{ minWidth: 220 }}
options={categoryOptions}
value={uploadClassId}
onChange={(value) => setUploadClassId(value)}
/>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />}>
</Button>
</Upload>
</Space>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card bordered={false}>
<Statistic title="分类数量" value={flatCategories.length} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card bordered={false}>
<Statistic title="当前页文件数" value={files.length} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card bordered={false}>
<Statistic title="文件总数" value={total} />
</Card>
</Col>
</Row>
<Card
bordered={false}
style={{ marginTop: 20, borderRadius: 20, background: 'rgba(16, 37, 66, 0.04)' }}
>
<Upload.Dragger {...uploadProps} style={{ background: 'transparent' }}>
<p className="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Upload.Dragger>
</Card>
</Card>
<Row gutter={[20, 20]}>
<Col xs={24} xl={7}>
<Card className="glass-panel page-panel" style={{ height: '100%' }}>
<div className="section-heading">
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space size="small" wrap>
<Button icon={<FolderAddOutlined />} onClick={openCreateCategory}>
</Button>
<Button icon={<EditOutlined />} onClick={openEditCategory}>
</Button>
<Button danger icon={<DeleteOutlined />} onClick={removeCategory}>
</Button>
</Space>
</div>
<Tree
blockNode
selectedKeys={selectedCategoryId ? [selectedCategoryId] : []}
treeData={buildTreeData(categories)}
onSelect={(keys) => {
const nextId = Number(keys[0]) || undefined
setSelectedCategoryId(nextId)
searchForm.setFieldValue('classId', nextId)
setPage(1)
loadFiles(1, pageSize)
}}
/>
</Card>
</Col>
<Col xs={24} xl={17}>
<Card className="glass-panel page-panel">
<div className="section-heading">
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
onClick={() => {
searchForm.resetFields()
setSelectedCategoryId(undefined)
setPage(1)
loadFiles(1, pageSize)
}}
>
</Button>
</div>
<Form
form={searchForm}
layout="vertical"
onFinish={() => {
setPage(1)
loadFiles(1, pageSize)
}}
>
<Row gutter={[16, 0]}>
<Col xs={24} md={10}>
<Form.Item name="keyword" label="关键词">
<Input placeholder="按文件名检索" />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="classId" label="分类">
<Select allowClear options={categoryOptions} placeholder="全部分类" />
</Form.Item>
</Col>
<Col xs={24} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button type="primary" htmlType="submit" block>
</Button>
</Col>
</Row>
</Form>
<Table
rowKey="ID"
loading={loading}
columns={columns}
dataSource={files}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
/>
</Card>
</Col>
</Row>
<Modal
open={categoryModalOpen}
title={editingCategory ? '编辑分类' : '新建分类'}
onCancel={() => setCategoryModalOpen(false)}
onOk={saveCategory}
>
<Form form={categoryForm} layout="vertical">
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
<Input />
</Form.Item>
<Form.Item name="pid" label="父级分类" initialValue={0}>
<Select
options={[{ label: '顶级分类', value: 0 }, ...categoryOptions]}
placeholder="顶级分类"
/>
</Form.Item>
</Form>
</Modal>
<Modal
open={renameModalOpen}
title="重命名文件"
onCancel={() => setRenameModalOpen(false)}
onOk={saveRename}
>
<Form form={renameForm} layout="vertical">
<Form.Item name="name" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,146 @@
type ComponentOption = {
value: string
label: string
routeName: string
}
type ComponentTreeNode = {
value: string
label: string
children?: ComponentTreeNode[]
}
const componentOptions: ComponentOption[] = [
{ value: 'features/dashboard/DashboardPage', label: 'DashboardPage · 仪表盘', routeName: 'dashboard' },
{ 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' },
{ value: 'features/users/UserManagementPage', label: 'UserManagementPage · 用户管理', routeName: 'user' },
{
value: 'features/dictionaries/DictionaryManagementPage',
label: 'DictionaryManagementPage · 字典管理',
routeName: 'dictionary',
},
{ value: 'features/logs/OperationLogPage', label: 'OperationLogPage · 操作历史', routeName: 'operation' },
{ value: 'features/params/ParamsManagementPage', label: 'ParamsManagementPage · 参数管理', routeName: 'sysParams' },
{ value: 'features/system/SystemConfigPage', label: 'SystemConfigPage · 系统配置', routeName: 'system' },
{ value: 'features/tokens/ApiTokenPage', label: 'ApiTokenPage · API Token', routeName: 'apiToken' },
{ value: 'features/logs/LoginLogPage', label: 'LoginLogPage · 登录日志', routeName: 'loginLog' },
{ value: 'features/errors/ErrorLogPage', label: 'ErrorLogPage · 错误日志', routeName: 'sysError' },
{ value: 'features/person/ProfilePage', label: 'ProfilePage · 个人中心', routeName: 'person' },
{ value: 'features/server/ServerStatePage', label: 'ServerStatePage · 服务器状态', routeName: 'state' },
{ value: 'features/mcp/McpTestPage', label: 'McpTestPage · MCP Tools 管理', routeName: 'mcpTest' },
{ value: 'features/mcp/McpToolPage', label: 'McpToolPage · MCP Tools 模板', routeName: 'mcpTool' },
{
value: 'features/discovery/ModuleLandingPage:superAdmin',
label: 'ModuleLandingPage · 超级管理员',
routeName: 'superAdmin',
},
{
value: 'features/discovery/ModuleLandingPage:common',
label: 'ModuleLandingPage · 公共能力',
routeName: 'common',
},
{
value: 'features/discovery/ModuleLandingPage:upload',
label: 'ModuleLandingPage · 媒体库',
routeName: 'upload',
},
{
value: 'features/discovery/ModuleLandingPage:breakpoint',
label: 'ModuleLandingPage · 断点续传',
routeName: 'breakpoint',
},
{
value: 'features/discovery/ModuleLandingPage:systemTools',
label: 'ModuleLandingPage · 编程辅助',
routeName: 'systemTools',
},
{
value: 'features/discovery/ModuleLandingPage:aiWorkflow',
label: 'ModuleLandingPage · AI 需求工作流',
routeName: 'aiWorkflow',
},
{
value: 'features/discovery/ModuleLandingPage:exportTemplate',
label: 'ModuleLandingPage · 导出模板',
routeName: 'exportTemplate',
},
{
value: 'features/discovery/ModuleLandingPage:example',
label: 'ModuleLandingPage · 示例文件',
routeName: 'example',
},
{
value: 'features/discovery/ModuleLandingPage:customer',
label: 'ModuleLandingPage · 客户列表示例',
routeName: 'customer',
},
{
value: 'features/discovery/ModuleLandingPage:plugin',
label: 'ModuleLandingPage · 插件系统',
routeName: 'plugin',
},
{
value: 'features/discovery/ModuleLandingPage:installPlugin',
label: 'ModuleLandingPage · 插件安装',
routeName: 'installPlugin',
},
{
value: 'features/discovery/ModuleLandingPage:pubPlug',
label: 'ModuleLandingPage · 打包插件',
routeName: 'pubPlug',
},
{
value: 'features/discovery/ModuleLandingPage:plugin-email',
label: 'ModuleLandingPage · 邮件插件',
routeName: 'plugin-email',
},
{
value: 'features/discovery/ModuleLandingPage:anInfo',
label: 'ModuleLandingPage · 公告管理示例',
routeName: 'anInfo',
},
]
const componentRouteNameMap = new Map(componentOptions.map((item) => [item.value, item.routeName]))
export function buildMenuComponentOptions(): ComponentTreeNode[] {
const result: ComponentTreeNode[] = []
for (const option of componentOptions) {
const parts = option.value.split('/').filter(Boolean)
let currentLevel = result
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index]
let node = currentLevel.find((item) => item.value === part)
if (!node) {
node = {
value: part,
label: part,
children: [],
}
currentLevel.push(node)
}
if (index === parts.length - 1) {
node.label = option.label
delete node.children
}
currentLevel = node.children || []
}
}
return result
}
export function isKnownMenuComponent(componentValue: string) {
return componentRouteNameMap.has(componentValue)
}
export function getComponentSuggestedRouteName(componentValue: string) {
return componentRouteNameMap.get(componentValue)
}

View File

@@ -0,0 +1,10 @@
import { ModuleLandingPage } from '@/features/discovery/ModuleLandingPage'
export function createModulePage(moduleName: string) {
function RouteComponent() {
return <ModuleLandingPage moduleName={moduleName} />
}
RouteComponent.displayName = `ModulePage(${moduleName})`
return RouteComponent
}

View File

@@ -0,0 +1,25 @@
import type { AppRoute, FileRouteModule } from './routeTypes'
const routeModules = import.meta.glob('./pages/**/page.tsx', {
eager: true,
}) as Record<string, FileRouteModule>
function toRoutePath(modulePath: string) {
const normalized = modulePath
.replace('./pages/', '')
.replace(/\/page\.tsx$/, '')
.split('/')
.filter(Boolean)
.map((segment) => segment.replace(/^\[(.+)\]$/, ':$1'))
.join('/')
return normalized
}
export const appRoutes: AppRoute[] = Object.entries(routeModules)
.map(([modulePath, routeModule]) => ({
path: toRoutePath(modulePath),
menuName: routeModule.routeMeta.menuName,
component: routeModule.default,
}))
.sort((left, right) => left.path.localeCompare(right.path))

View File

@@ -0,0 +1,7 @@
import { ApiManagementPage } from '@/features/apis/ApiManagementPage'
export const routeMeta = {
menuName: 'api',
}
export default ApiManagementPage

View File

@@ -0,0 +1,7 @@
import { ApiTokenPage } from '@/features/tokens/ApiTokenPage'
export const routeMeta = {
menuName: 'apiToken',
}
export default ApiTokenPage

View File

@@ -0,0 +1,7 @@
import { RoleManagementPage } from '@/features/roles/RoleManagementPage'
export const routeMeta = {
menuName: 'authority',
}
export default RoleManagementPage

View File

@@ -0,0 +1,7 @@
import { DictionaryManagementPage } from '@/features/dictionaries/DictionaryManagementPage'
export const routeMeta = {
menuName: 'dictionary',
}
export default DictionaryManagementPage

View File

@@ -0,0 +1,7 @@
import { LoginLogPage } from '@/features/logs/LoginLogPage'
export const routeMeta = {
menuName: 'loginLog',
}
export default LoginLogPage

View File

@@ -0,0 +1,7 @@
import { MenuManagementPage } from '@/features/menus/MenuManagementPage'
export const routeMeta = {
menuName: 'menu',
}
export default MenuManagementPage

View File

@@ -0,0 +1,7 @@
import { OperationLogPage } from '@/features/logs/OperationLogPage'
export const routeMeta = {
menuName: 'operation',
}
export default OperationLogPage

View File

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

View File

@@ -0,0 +1,7 @@
import { ErrorLogPage } from '@/features/errors/ErrorLogPage'
export const routeMeta = {
menuName: 'sysError',
}
export default ErrorLogPage

View File

@@ -0,0 +1,7 @@
import { ParamsManagementPage } from '@/features/params/ParamsManagementPage'
export const routeMeta = {
menuName: 'sysParams',
}
export default ParamsManagementPage

View File

@@ -0,0 +1,7 @@
import { SystemConfigPage } from '@/features/system/SystemConfigPage'
export const routeMeta = {
menuName: 'system',
}
export default SystemConfigPage

View File

@@ -0,0 +1,7 @@
import { UserManagementPage } from '@/features/users/UserManagementPage'
export const routeMeta = {
menuName: 'user',
}
export default UserManagementPage

View File

@@ -0,0 +1,7 @@
import { BreakpointPage } from '@/features/breakpoint/BreakpointPage'
export const routeMeta = {
menuName: 'breakpoint',
}
export default BreakpointPage

View File

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

View File

@@ -0,0 +1,7 @@
import { DashboardPage } from '@/features/dashboard/DashboardPage'
export const routeMeta = {
menuName: 'dashboard',
}
export default DashboardPage

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { ProfilePage } from '@/features/person/ProfilePage'
export const routeMeta = {
menuName: 'person',
}
export default ProfilePage

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { ServerStatePage } from '@/features/server/ServerStatePage'
export const routeMeta = {
menuName: 'state',
}
export default ServerStatePage

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { McpTestPage } from '@/features/mcp/McpTestPage'
export const routeMeta = {
menuName: 'mcpTest',
}
export default McpTestPage

View File

@@ -0,0 +1,7 @@
import { McpToolPage } from '@/features/mcp/McpToolPage'
export const routeMeta = {
menuName: 'mcpTool',
}
export default McpToolPage

View File

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

View File

@@ -0,0 +1,16 @@
import type { ComponentType } from 'react'
export type RouteMeta = {
menuName: string
}
export type FileRouteModule = {
default: ComponentType
routeMeta: RouteMeta
}
export type AppRoute = {
path: string
menuName: string
component: ComponentType
}