diff --git a/.gitignore b/.gitignore index 8bed08f..eb2854d 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,5 @@ server/config.yaml .gpt -web \ No newline at end of file +web +.tmp \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ef8da2f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "GVA_MCP": { + "headers": { + "x-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiMTM4MWYwZDgtYjU4MS00NTdhLWFhZmItMmE5NzgwMGVkOGFkIiwiSUQiOjEsIlVzZXJuYW1lIjoiYWRtaW4iLCJOaWNrTmFtZSI6Ik1yLuWlh-a3vCIsIkF1dGhvcml0eUlkIjo4ODgsIkJ1ZmZlclRpbWUiOjg2NDAwLCJpc3MiOiJxbVBsdXMiLCJhdWQiOlsiR1ZBIl0sImV4cCI6MTc3NjEyNzY1MSwibmJmIjoxNzc1NTIyODUxfQ.XO9T9e0ALSWoi41We9NCBfj3YTXmkzIctgPTcgql5ME" + }, + "url": "http://127.0.0.1:8889/mcp" + } + } +} diff --git a/server/service/system/auto_code_service.go b/server/service/system/auto_code_service.go new file mode 100644 index 0000000..0c895b1 --- /dev/null +++ b/server/service/system/auto_code_service.go @@ -0,0 +1,3 @@ +package system + +type AutoCodeService struct{} diff --git a/web-admin/src/features/breakpoint/BreakpointPage.tsx b/web-admin/src/features/breakpoint/BreakpointPage.tsx new file mode 100644 index 0000000..3e1ba10 --- /dev/null +++ b/web-admin/src/features/breakpoint/BreakpointPage.tsx @@ -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 | undefined): BreakpointRemoteState { + const chunks = (input?.exaFileChunk || input?.ExaFileChunk || []) as Array> + + 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(null) + const [fileList, setFileList] = useState([]) + const [fileFingerprint, setFileFingerprint] = useState('') + const [chunkSizeMb, setChunkSizeMb] = useState(4) + const [status, setStatus] = useState('idle') + const [loading, setLoading] = useState(false) + const [uploadedChunks, setUploadedChunks] = useState([]) + 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) + 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 ( +
+ +
+
+ + 断点续传 + + + 当前页基于服务端切片记录实现续传。重新选择同一个文件后,会自动校验已上传分片并从缺失部分继续。 + +
+ +