🎉 完善基础模块
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -81,3 +81,4 @@ server/config.yaml
|
|||||||
|
|
||||||
|
|
||||||
web
|
web
|
||||||
|
.tmp
|
||||||
10
.mcp.json
Normal file
10
.mcp.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"GVA_MCP": {
|
||||||
|
"headers": {
|
||||||
|
"x-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiMTM4MWYwZDgtYjU4MS00NTdhLWFhZmItMmE5NzgwMGVkOGFkIiwiSUQiOjEsIlVzZXJuYW1lIjoiYWRtaW4iLCJOaWNrTmFtZSI6Ik1yLuWlh-a3vCIsIkF1dGhvcml0eUlkIjo4ODgsIkJ1ZmZlclRpbWUiOjg2NDAwLCJpc3MiOiJxbVBsdXMiLCJhdWQiOlsiR1ZBIl0sImV4cCI6MTc3NjEyNzY1MSwibmJmIjoxNzc1NTIyODUxfQ.XO9T9e0ALSWoi41We9NCBfj3YTXmkzIctgPTcgql5ME"
|
||||||
|
},
|
||||||
|
"url": "http://127.0.0.1:8889/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/service/system/auto_code_service.go
Normal file
3
server/service/system/auto_code_service.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
type AutoCodeService struct{}
|
||||||
407
web-admin/src/features/breakpoint/BreakpointPage.tsx
Normal file
407
web-admin/src/features/breakpoint/BreakpointPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
393
web-admin/src/features/mcp/McpTestPage.tsx
Normal file
393
web-admin/src/features/mcp/McpTestPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
267
web-admin/src/features/mcp/McpToolPage.tsx
Normal file
267
web-admin/src/features/mcp/McpToolPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
521
web-admin/src/features/media/MediaLibraryPage.tsx
Normal file
521
web-admin/src/features/media/MediaLibraryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
web-admin/src/features/menus/menuComponentCatalog.ts
Normal file
146
web-admin/src/features/menus/menuComponentCatalog.ts
Normal 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)
|
||||||
|
}
|
||||||
10
web-admin/src/router/createModulePage.tsx
Normal file
10
web-admin/src/router/createModulePage.tsx
Normal 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
|
||||||
|
}
|
||||||
25
web-admin/src/router/fsRoutes.tsx
Normal file
25
web-admin/src/router/fsRoutes.tsx
Normal 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))
|
||||||
7
web-admin/src/router/pages/admin/api/page.tsx
Normal file
7
web-admin/src/router/pages/admin/api/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ApiManagementPage } from '@/features/apis/ApiManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'api',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiManagementPage
|
||||||
7
web-admin/src/router/pages/admin/apiToken/page.tsx
Normal file
7
web-admin/src/router/pages/admin/apiToken/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ApiTokenPage } from '@/features/tokens/ApiTokenPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'apiToken',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiTokenPage
|
||||||
7
web-admin/src/router/pages/admin/authority/page.tsx
Normal file
7
web-admin/src/router/pages/admin/authority/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { RoleManagementPage } from '@/features/roles/RoleManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'authority',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleManagementPage
|
||||||
7
web-admin/src/router/pages/admin/dictionary/page.tsx
Normal file
7
web-admin/src/router/pages/admin/dictionary/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { DictionaryManagementPage } from '@/features/dictionaries/DictionaryManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'dictionary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DictionaryManagementPage
|
||||||
7
web-admin/src/router/pages/admin/loginLog/page.tsx
Normal file
7
web-admin/src/router/pages/admin/loginLog/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { LoginLogPage } from '@/features/logs/LoginLogPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'loginLog',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginLogPage
|
||||||
7
web-admin/src/router/pages/admin/menu/page.tsx
Normal file
7
web-admin/src/router/pages/admin/menu/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { MenuManagementPage } from '@/features/menus/MenuManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'menu',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MenuManagementPage
|
||||||
7
web-admin/src/router/pages/admin/operation/page.tsx
Normal file
7
web-admin/src/router/pages/admin/operation/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { OperationLogPage } from '@/features/logs/OperationLogPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'operation',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OperationLogPage
|
||||||
7
web-admin/src/router/pages/admin/page.tsx
Normal file
7
web-admin/src/router/pages/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'superAdmin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('superAdmin')
|
||||||
7
web-admin/src/router/pages/admin/sysError/page.tsx
Normal file
7
web-admin/src/router/pages/admin/sysError/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ErrorLogPage } from '@/features/errors/ErrorLogPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'sysError',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorLogPage
|
||||||
7
web-admin/src/router/pages/admin/sysParams/page.tsx
Normal file
7
web-admin/src/router/pages/admin/sysParams/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ParamsManagementPage } from '@/features/params/ParamsManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'sysParams',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParamsManagementPage
|
||||||
7
web-admin/src/router/pages/admin/system/page.tsx
Normal file
7
web-admin/src/router/pages/admin/system/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SystemConfigPage } from '@/features/system/SystemConfigPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'system',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemConfigPage
|
||||||
7
web-admin/src/router/pages/admin/user/page.tsx
Normal file
7
web-admin/src/router/pages/admin/user/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { UserManagementPage } from '@/features/users/UserManagementPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'user',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserManagementPage
|
||||||
7
web-admin/src/router/pages/common/breakpoint/page.tsx
Normal file
7
web-admin/src/router/pages/common/breakpoint/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { BreakpointPage } from '@/features/breakpoint/BreakpointPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'breakpoint',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BreakpointPage
|
||||||
7
web-admin/src/router/pages/common/page.tsx
Normal file
7
web-admin/src/router/pages/common/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'common',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('common')
|
||||||
7
web-admin/src/router/pages/dashboard/page.tsx
Normal file
7
web-admin/src/router/pages/dashboard/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { DashboardPage } from '@/features/dashboard/DashboardPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'dashboard',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage
|
||||||
7
web-admin/src/router/pages/example/customer/page.tsx
Normal file
7
web-admin/src/router/pages/example/customer/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'customer',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('customer')
|
||||||
7
web-admin/src/router/pages/example/page.tsx
Normal file
7
web-admin/src/router/pages/example/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'example',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('example')
|
||||||
7
web-admin/src/router/pages/person/page.tsx
Normal file
7
web-admin/src/router/pages/person/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ProfilePage } from '@/features/person/ProfilePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'person',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage
|
||||||
7
web-admin/src/router/pages/plugin/anInfo/page.tsx
Normal file
7
web-admin/src/router/pages/plugin/anInfo/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'anInfo',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('anInfo')
|
||||||
7
web-admin/src/router/pages/plugin/installPlugin/page.tsx
Normal file
7
web-admin/src/router/pages/plugin/installPlugin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'installPlugin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('installPlugin')
|
||||||
7
web-admin/src/router/pages/plugin/page.tsx
Normal file
7
web-admin/src/router/pages/plugin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'plugin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('plugin')
|
||||||
7
web-admin/src/router/pages/plugin/plugin-email/page.tsx
Normal file
7
web-admin/src/router/pages/plugin/plugin-email/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'plugin-email',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('plugin-email')
|
||||||
7
web-admin/src/router/pages/plugin/pubPlug/page.tsx
Normal file
7
web-admin/src/router/pages/plugin/pubPlug/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'pubPlug',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('pubPlug')
|
||||||
7
web-admin/src/router/pages/state/page.tsx
Normal file
7
web-admin/src/router/pages/state/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ServerStatePage } from '@/features/server/ServerStatePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'state',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerStatePage
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'aiWorkflow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('aiWorkflow')
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'exportTemplate',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('exportTemplate')
|
||||||
7
web-admin/src/router/pages/systemTools/mcpTest/page.tsx
Normal file
7
web-admin/src/router/pages/systemTools/mcpTest/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { McpTestPage } from '@/features/mcp/McpTestPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'mcpTest',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default McpTestPage
|
||||||
7
web-admin/src/router/pages/systemTools/mcpTool/page.tsx
Normal file
7
web-admin/src/router/pages/systemTools/mcpTool/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { McpToolPage } from '@/features/mcp/McpToolPage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'mcpTool',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default McpToolPage
|
||||||
7
web-admin/src/router/pages/systemTools/page.tsx
Normal file
7
web-admin/src/router/pages/systemTools/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createModulePage } from '@/router/createModulePage'
|
||||||
|
|
||||||
|
export const routeMeta = {
|
||||||
|
menuName: 'systemTools',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createModulePage('systemTools')
|
||||||
16
web-admin/src/router/routeTypes.ts
Normal file
16
web-admin/src/router/routeTypes.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user