diff --git a/bot-images/Dashboard.Dockerfile b/bot-images/Dashboard.Dockerfile index 1e15d17..4fe17d0 100644 --- a/bot-images/Dashboard.Dockerfile +++ b/bot-images/Dashboard.Dockerfile @@ -4,15 +4,15 @@ ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 ENV PYTHONIOENCODING=utf-8 -# 1. 替换 apt 国内源 -RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources || \ - sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list +# 1. 替换 Debian 源为国内镜像 +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \ + sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources # 2. 安装基础依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - git \ curl \ + gcc \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* # 3. 安装 aiohttp 和基础 python 工具 diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 2e24d04..093f9e8 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -708,6 +708,46 @@ gap: 8px; } +.ops-upload-progress { + margin-top: 8px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; +} + +.ops-upload-progress-track { + height: 8px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line) 72%, transparent); + background: color-mix(in oklab, var(--panel) 78%, var(--panel-soft) 22%); + overflow: hidden; +} + +.ops-upload-progress-fill { + height: 100%; + border-radius: inherit; + width: 0; + background: linear-gradient(90deg, color-mix(in oklab, var(--brand) 75%, #7fb8ff 25%), color-mix(in oklab, var(--brand) 55%, #b6ddff 45%)); + transition: width 0.2s ease; +} + +.ops-upload-progress-track.is-indeterminate .ops-upload-progress-fill { + width: 28%; + animation: ops-upload-indeterminate 1s linear infinite; +} + +.ops-upload-progress-text { + font-size: 11px; + color: var(--subtitle); + white-space: nowrap; +} + +@keyframes ops-upload-indeterminate { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(430%); } +} + .ops-pending-chip { display: inline-flex; align-items: center; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index b9df5a4..f2fe43b 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -300,6 +300,23 @@ function isImagePath(path: string) { return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp'); } +const MEDIA_UPLOAD_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff', + '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', + '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', +]); + +function isMediaUploadFile(file: File): boolean { + const mime = String(file.type || '').toLowerCase(); + if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) { + return true; + } + const name = String(file.name || '').trim().toLowerCase(); + const dot = name.lastIndexOf('.'); + if (dot < 0) return false; + return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot)); +} + function isHtmlPath(path: string) { const normalized = String(path || '').trim().toLowerCase(); return normalized.endsWith('.html') || normalized.endsWith('.htm'); @@ -544,6 +561,7 @@ export function BotDashboardModule({ const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false); const [pendingAttachments, setPendingAttachments] = useState([]); const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); + const [attachmentUploadPercent, setAttachmentUploadPercent] = useState(null); const filePickerRef = useRef(null); const [cronJobs, setCronJobs] = useState([]); const [cronLoading, setCronLoading] = useState(false); @@ -1974,19 +1992,62 @@ export function BotDashboardModule({ event.target.value = ''; return; } - const formData = new FormData(); - files.forEach((file) => formData.append('files', file)); + const mediaFiles: File[] = []; + const normalFiles: File[] = []; + files.forEach((file) => { + if (isMediaUploadFile(file)) { + mediaFiles.push(file); + } else { + normalFiles.push(file); + } + }); - setIsUploadingAttachments(true); - try { + const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); + let uploadedBytes = 0; + const uploadedPaths: string[] = []; + + const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => { + if (batchFiles.length === 0) return; + const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); + const formData = new FormData(); + batchFiles.forEach((file) => formData.append('files', file)); const res = await axios.post( `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`, formData, - { params: { path: 'uploads' } }, + { + params: { path }, + onUploadProgress: (progressEvent) => { + const loaded = Number(progressEvent.loaded || 0); + if (!Number.isFinite(loaded) || loaded < 0) { + setAttachmentUploadPercent(null); + return; + } + if (totalBytes <= 0) { + setAttachmentUploadPercent(null); + return; + } + const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded)); + const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100))); + setAttachmentUploadPercent(pct); + }, + }, ); const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path)); - if (uploaded.length > 0) { - setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploaded]))); + uploadedPaths.push(...uploaded); + uploadedBytes += batchBytes; + if (totalBytes > 0) { + const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100))); + setAttachmentUploadPercent(pct); + } + }; + + setIsUploadingAttachments(true); + setAttachmentUploadPercent(0); + try { + await uploadBatch(mediaFiles, 'media'); + await uploadBatch(normalFiles, 'uploads'); + if (uploadedPaths.length > 0) { + setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths]))); await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath); } } catch (error: any) { @@ -1994,6 +2055,7 @@ export function BotDashboardModule({ notify(msg, { tone: 'error' }); } finally { setIsUploadingAttachments(false); + setAttachmentUploadPercent(null); event.target.value = ''; } }; @@ -2521,6 +2583,21 @@ export function BotDashboardModule({ {isSending ? t.sending : t.send} + {isUploadingAttachments ? ( +
+
+
+
+ + {attachmentUploadPercent === null + ? t.uploadingFile + : `${t.uploadingFile} ${attachmentUploadPercent}%`} + +
+ ) : null} {pendingAttachments.length > 0 ? (
{pendingAttachments.map((p) => (