diff --git a/config/__init__.py b/config/__init__.py
index aae11d2..6c7b36c 100644
--- a/config/__init__.py
+++ b/config/__init__.py
@@ -39,6 +39,7 @@ from . import memory as _memory
from . import ocr as _ocr
from . import todo as _todo
from . import auth as _auth
+from . import uploads as _uploads
from . import sub_agent as _sub_agent
from .api import *
@@ -52,10 +53,11 @@ from .memory import *
from .ocr import *
from .todo import *
from .auth import *
+from .uploads import *
from .sub_agent import *
__all__ = []
-for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent):
+for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent):
__all__ += getattr(module, "__all__", [])
-del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent
+del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent
diff --git a/config/uploads.py b/config/uploads.py
new file mode 100644
index 0000000..85d14b7
--- /dev/null
+++ b/config/uploads.py
@@ -0,0 +1,61 @@
+"""上传隔离与扫描相关配置。"""
+
+import os
+import shlex
+
+_DEFAULT_ALLOWED_EXTENSIONS = (
+ ".txt,.md,.rst,.py,.js,.ts,.json,.yml,.yaml,.ini,.cfg,.conf,"
+ ".csv,.tsv,.log,.mdx,.env,.sh,.bat,.ps1,.sql,.html,.css,"
+ ".svg,.png,.jpg,.jpeg,.gif,.bmp,.webp,.ico,.pdf,.pptx,.docx,"
+ ".xlsx,.mp3,.wav,.flac,.mp4,.mov,.mkv,.avi,.webm,"
+ ".zip,.tar,.tar.gz,.tar.bz2,.tgz,.tbz,.gz,.bz2,.xz,.7z"
+ ",.com"
+)
+
+
+def _parse_extensions(raw: str):
+ entries = []
+ for chunk in (raw or "").split(","):
+ token = chunk.strip().lower()
+ if not token:
+ continue
+ if not token.startswith("."):
+ token = f".{token}"
+ entries.append(token)
+ # 去重但保持顺序
+ deduped = []
+ for item in entries:
+ if item not in deduped:
+ deduped.append(item)
+ return tuple(deduped)
+
+
+UPLOAD_ALLOWED_EXTENSIONS = _parse_extensions(
+ os.environ.get("UPLOAD_ALLOWED_EXTENSIONS", _DEFAULT_ALLOWED_EXTENSIONS)
+)
+UPLOAD_QUARANTINE_SUBDIR = os.environ.get("UPLOAD_QUARANTINE_SUBDIR", ".upload_quarantine")
+
+
+def _parse_bool(value: str, default: bool = True) -> bool:
+ if value is None:
+ return default
+ return str(value).strip().lower() not in {"0", "false", "no", "off"}
+
+
+UPLOAD_CLAMAV_ENABLED = _parse_bool(os.environ.get("UPLOAD_CLAMAV_ENABLED", "1"), default=True)
+UPLOAD_CLAMAV_BIN = os.environ.get("UPLOAD_CLAMAV_BIN", "clamdscan")
+UPLOAD_CLAMAV_ARGS = tuple(
+ shlex.split(os.environ.get("UPLOAD_CLAMAV_ARGS", "--fdpass --no-summary --stdout"))
+)
+UPLOAD_CLAMAV_TIMEOUT_SECONDS = int(os.environ.get("UPLOAD_CLAMAV_TIMEOUT_SECONDS", "30"))
+UPLOAD_SCAN_LOG_SUBDIR = os.environ.get("UPLOAD_SCAN_LOG_SUBDIR", "upload_guard")
+
+__all__ = [
+ "UPLOAD_ALLOWED_EXTENSIONS",
+ "UPLOAD_QUARANTINE_SUBDIR",
+ "UPLOAD_CLAMAV_ENABLED",
+ "UPLOAD_CLAMAV_BIN",
+ "UPLOAD_CLAMAV_ARGS",
+ "UPLOAD_CLAMAV_TIMEOUT_SECONDS",
+ "UPLOAD_SCAN_LOG_SUBDIR",
+]
diff --git a/modules/upload_security.py b/modules/upload_security.py
new file mode 100644
index 0000000..531011b
--- /dev/null
+++ b/modules/upload_security.py
@@ -0,0 +1,278 @@
+"""上传隔离、扫描与审计工具。"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import mimetypes
+import os
+import shutil
+import subprocess
+import time
+import uuid
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+from config import (
+ MAX_UPLOAD_SIZE,
+ UPLOAD_ALLOWED_EXTENSIONS,
+ UPLOAD_CLAMAV_ARGS,
+ UPLOAD_CLAMAV_BIN,
+ UPLOAD_CLAMAV_ENABLED,
+ UPLOAD_CLAMAV_TIMEOUT_SECONDS,
+ UPLOAD_SCAN_LOG_SUBDIR,
+)
+from utils.logger import setup_logger
+
+if TYPE_CHECKING:
+ from modules.user_manager import UserWorkspace
+ from werkzeug.datastructures import FileStorage
+
+
+class UploadSecurityError(Exception):
+ """上传被拒绝或扫描失败时抛出的业务异常。"""
+
+ def __init__(self, message: str, code: str = "upload_error"):
+ super().__init__(message)
+ self.code = code
+
+
+@dataclass
+class StageRecord:
+ """暂存区中的上传文件快照。"""
+
+ path: Path
+ filename: str
+ size: int
+ sha256: str
+ mime: Optional[str]
+
+
+@dataclass
+class ScanReport:
+ """病毒扫描结果。"""
+
+ status: str
+ engine: str
+ signature: Optional[str] = None
+ message: Optional[str] = None
+ duration_ms: Optional[int] = None
+
+ @property
+ def is_clean(self) -> bool:
+ return self.status in {"passed", "skipped"}
+
+
+class UploadQuarantineManager:
+ """负责将上传文件落地到隔离区、执行安全扫描并记录审计日志。"""
+
+ def __init__(self, workspace: "UserWorkspace", *, logger_name: Optional[str] = None):
+ self.workspace = workspace
+ self.quarantine_dir = Path(workspace.quarantine_dir).resolve()
+ self.quarantine_dir.mkdir(parents=True, exist_ok=True)
+ log_rel = Path(UPLOAD_SCAN_LOG_SUBDIR) / f"{workspace.username}.log"
+ self.logger = setup_logger(
+ logger_name or f"upload_guard.{workspace.username}",
+ str(log_rel),
+ )
+ self.allowed_extensions = tuple(UPLOAD_ALLOWED_EXTENSIONS)
+ self.clamav_enabled = bool(UPLOAD_CLAMAV_ENABLED)
+ self.clamav_bin = self._resolve_clamav_bin(UPLOAD_CLAMAV_BIN)
+ self.clamav_args = list(UPLOAD_CLAMAV_ARGS)
+ self.clamav_timeout = int(max(1, UPLOAD_CLAMAV_TIMEOUT_SECONDS))
+
+ def process_upload(
+ self,
+ file_obj: "FileStorage",
+ target_path: Path,
+ *,
+ username: str,
+ source: str,
+ original_name: str,
+ relative_path: str,
+ ) -> Dict[str, Any]:
+ """保存文件到隔离区、执行扫描并将通过的文件同步到工作目录。"""
+ stage = self._stage_file(file_obj, original_name)
+ metadata: Dict[str, Any] = {
+ "upload_id": uuid.uuid4().hex,
+ "username": username,
+ "source": source,
+ "original_name": original_name,
+ "target_path": str(target_path),
+ "target_relative": relative_path,
+ "staged_path": str(stage.path),
+ "size": stage.size,
+ "sha256": stage.sha256,
+ "mime": stage.mime,
+ }
+ scan_report: Optional[ScanReport] = None
+ promoted_path: Optional[Path] = None
+
+ try:
+ self._enforce_size(stage.size)
+ self._enforce_extension(target_path.name)
+ scan_report = self._scan(stage.path)
+ metadata["scan"] = asdict(scan_report)
+ if not scan_report.is_clean:
+ metadata["scan_failure_reason"] = scan_report.message or scan_report.signature
+ raise UploadSecurityError("安全审核未通过", code="scan_failed")
+ promoted_path = self._promote(stage.path, target_path)
+ metadata["final_path"] = str(promoted_path)
+ self._log_event(True, metadata)
+ return {
+ "final_path": promoted_path,
+ "metadata": metadata,
+ }
+ except UploadSecurityError as exc:
+ metadata["error"] = {
+ "code": exc.code,
+ "message": str(exc),
+ }
+ if scan_report:
+ metadata.setdefault("scan", asdict(scan_report))
+ self._log_event(False, metadata)
+ raise
+ finally:
+ if stage.path.exists():
+ try:
+ stage.path.unlink()
+ except OSError:
+ pass
+
+ # ------------------------------------------------------------------
+ # 内部实现
+ # ------------------------------------------------------------------
+ def _stage_file(self, file_obj: "FileStorage", original_name: str) -> StageRecord:
+ suffix = "".join(Path(original_name or "").suffixes[-2:]) or Path(original_name or "").suffix
+ unique_name = f"{int(time.time())}_{uuid.uuid4().hex}{suffix or ''}"
+ staged_path = self.quarantine_dir / unique_name
+ file_obj.save(staged_path)
+ size = staged_path.stat().st_size
+ sha256 = self._hash_file(staged_path)
+ mime, _ = mimetypes.guess_type(original_name or "")
+ return StageRecord(staged_path, original_name, size, sha256, mime)
+
+ def _enforce_size(self, size: int):
+ if size > MAX_UPLOAD_SIZE:
+ raise UploadSecurityError(
+ f"文件大小 {size} 超过上限 {MAX_UPLOAD_SIZE} 字节",
+ code="size_exceeded",
+ )
+
+ def _enforce_extension(self, filename: str):
+ if not self.allowed_extensions:
+ return
+ lowered = (filename or "").lower()
+ for pattern in self.allowed_extensions:
+ if lowered.endswith(pattern):
+ return
+ raise UploadSecurityError("文件类型不在允许列表中", code="extension_forbidden")
+
+ def _scan(self, staged_path: Path) -> ScanReport:
+ if not self.clamav_enabled:
+ return ScanReport(status="skipped", engine="clamdscan", message="已跳过病毒扫描")
+ if not self.clamav_bin:
+ raise UploadSecurityError("未找到 ClamAV 扫描器,请检查配置", code="scanner_missing")
+
+ command = [self.clamav_bin] + self.clamav_args + [str(staged_path)]
+ start = time.time()
+ try:
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=self.clamav_timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return ScanReport(
+ status="error",
+ engine="clamdscan",
+ message="扫描超时",
+ duration_ms=int((time.time() - start) * 1000),
+ )
+ except FileNotFoundError as exc:
+ raise UploadSecurityError("ClamAV 扫描器不可用", code="scanner_missing") from exc
+
+ duration_ms = int((time.time() - start) * 1000)
+ stdout = (result.stdout or "").strip()
+ stderr = (result.stderr or "").strip()
+
+ if result.returncode == 0:
+ return ScanReport(status="passed", engine="clamdscan", duration_ms=duration_ms)
+
+ if result.returncode == 1:
+ signature = self._extract_signature(stdout) or self._extract_signature(stderr)
+ message = signature or "检测到可疑内容"
+ return ScanReport(
+ status="failed",
+ engine="clamdscan",
+ signature=signature,
+ message=message,
+ duration_ms=duration_ms,
+ )
+
+ message = stderr or stdout or f"扫描失败(退出码 {result.returncode})"
+ return ScanReport(
+ status="error",
+ engine="clamdscan",
+ message=message,
+ duration_ms=duration_ms,
+ )
+
+ def _promote(self, staged_path: Path, target_path: Path) -> Path:
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.move(str(staged_path), str(target_path))
+ return target_path
+
+ def _hash_file(self, path: Path) -> str:
+ sha256 = hashlib.sha256()
+ with path.open("rb") as fh:
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
+ sha256.update(chunk)
+ return sha256.hexdigest()
+
+ def _extract_signature(self, text: str) -> Optional[str]:
+ if not text:
+ return None
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line or "FOUND" not in line.upper():
+ continue
+ parts = line.split(":", 1)
+ tail = parts[-1].strip()
+ if tail.upper().endswith("FOUND"):
+ signature = tail[: -len("FOUND")].strip()
+ if signature:
+ return signature
+ return None
+
+ def _resolve_clamav_bin(self, candidate: str) -> Optional[str]:
+ if not candidate:
+ return None
+ candidate = candidate.strip()
+ if not candidate:
+ return None
+ if os.path.isabs(candidate):
+ return candidate if Path(candidate).exists() else None
+ resolved = shutil.which(candidate)
+ return resolved or None
+
+ def _log_event(self, accepted: bool, payload: Dict[str, Any]):
+ entry = {
+ "timestamp": datetime.utcnow().isoformat(),
+ "accepted": accepted,
+ **payload,
+ }
+ try:
+ self.logger.info("UPLOAD_AUDIT %s", json.dumps(entry, ensure_ascii=False))
+ except Exception:
+ self.logger.warning("无法写入上传审计日志:%s", entry)
+
+
+__all__ = [
+ "UploadQuarantineManager",
+ "UploadSecurityError",
+]
diff --git a/modules/user_manager.py b/modules/user_manager.py
index e8a6a32..d4ca9fe 100644
--- a/modules/user_manager.py
+++ b/modules/user_manager.py
@@ -15,6 +15,7 @@ from config import (
INVITE_CODES_FILE,
USER_SPACE_DIR,
USERS_DB_FILE,
+ UPLOAD_QUARANTINE_SUBDIR,
)
from modules.personalization_manager import ensure_personalization_config
@@ -37,6 +38,7 @@ class UserWorkspace:
data_dir: Path
logs_dir: Path
uploads_dir: Path
+ quarantine_dir: Path
class UserManager:
@@ -131,6 +133,12 @@ class UserManager:
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
ensure_personalization_config(data_dir)
+ quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
+ if not quarantine_root.is_absolute():
+ quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
+ quarantine_dir = (quarantine_root / username).resolve()
+ quarantine_dir.mkdir(parents=True, exist_ok=True)
+
return UserWorkspace(
username=username,
root=root,
@@ -138,6 +146,7 @@ class UserManager:
data_dir=data_dir,
logs_dir=logs_dir,
uploads_dir=uploads_dir,
+ quarantine_dir=quarantine_dir,
)
def list_invite_codes(self):
diff --git a/static/app.js b/static/app.js
index a5822c9..64d7369 100644
--- a/static/app.js
+++ b/static/app.js
@@ -322,6 +322,10 @@ async function bootstrapApp() {
usageQuotaTimer: null,
quotaToast: null,
quotaToastTimer: null,
+ toastQueue: [],
+ nextToastId: 1,
+ pendingConfirmResolver: null,
+ confirmDialog: null,
// 对话压缩状态
compressing: false,
@@ -640,7 +644,11 @@ async function bootstrapApp() {
} catch (err) {
message = await response.text();
}
- alert(`下载失败: ${message}`);
+ this.pushToast({
+ title: '下载失败',
+ message: message || '无法完成下载',
+ type: 'error'
+ });
return;
}
@@ -656,7 +664,11 @@ async function bootstrapApp() {
URL.revokeObjectURL(href);
} catch (error) {
console.error('下载失败:', error);
- alert(`下载失败: ${error.message || error}`);
+ this.pushToast({
+ title: '下载失败',
+ message: error.message || String(error),
+ type: 'error'
+ });
} finally {
this.hideContextMenu();
}
@@ -1973,11 +1985,19 @@ async function bootstrapApp() {
} else {
console.error('对话加载失败:', result.message);
- alert(`加载对话失败: ${result.message}`);
+ this.pushToast({
+ title: '加载对话失败',
+ message: result.message || '服务器未返回成功状态',
+ type: 'error'
+ });
}
} catch (error) {
console.error('加载对话异常:', error);
- alert(`加载对话异常: ${error.message}`);
+ this.pushToast({
+ title: '加载对话异常',
+ message: error.message || String(error),
+ type: 'error'
+ });
}
},
@@ -2344,16 +2364,30 @@ async function bootstrapApp() {
} else {
console.error('创建对话失败:', result.message);
- alert(`创建对话失败: ${result.message}`);
+ this.pushToast({
+ title: '创建对话失败',
+ message: result.message || '服务器未返回成功状态',
+ type: 'error'
+ });
}
} catch (error) {
console.error('创建对话异常:', error);
- alert(`创建对话异常: ${error.message}`);
+ this.pushToast({
+ title: '创建对话异常',
+ message: error.message || String(error),
+ type: 'error'
+ });
}
},
-
+
async deleteConversation(conversationId) {
- if (!confirm('确定要删除这个对话吗?删除后无法恢复。')) {
+ const confirmed = await this.confirmAction({
+ title: '删除对话',
+ message: '确定要删除这个对话吗?删除后无法恢复。',
+ confirmText: '删除',
+ cancelText: '取消'
+ });
+ if (!confirmed) {
return;
}
@@ -2385,11 +2419,19 @@ async function bootstrapApp() {
} else {
console.error('删除对话失败:', result.message);
- alert(`删除对话失败: ${result.message}`);
+ this.pushToast({
+ title: '删除对话失败',
+ message: result.message || '服务器未返回成功状态',
+ type: 'error'
+ });
}
} catch (error) {
console.error('删除对话异常:', error);
- alert(`删除对话异常: ${error.message}`);
+ this.pushToast({
+ title: '删除对话异常',
+ message: error.message || String(error),
+ type: 'error'
+ });
}
},
@@ -2412,11 +2454,19 @@ async function bootstrapApp() {
await this.loadConversationsList();
} else {
const message = result.message || result.error || '复制失败';
- alert(`复制失败: ${message}`);
+ this.pushToast({
+ title: '复制对话失败',
+ message,
+ type: 'error'
+ });
}
} catch (error) {
console.error('复制对话异常:', error);
- alert(`复制对话异常: ${error.message}`);
+ this.pushToast({
+ title: '复制对话异常',
+ message: error.message || String(error),
+ type: 'error'
+ });
}
},
@@ -2527,7 +2577,11 @@ async function bootstrapApp() {
window.location.href = '/login';
} catch (error) {
console.error('退出登录失败:', error);
- alert(`退出登录失败:${error.message || '请稍后重试'}`);
+ this.pushToast({
+ title: '退出登录失败',
+ message: error.message || '请稍后重试',
+ type: 'error'
+ });
}
},
@@ -2544,7 +2598,11 @@ async function bootstrapApp() {
this.personalizationLoaded = true;
} catch (error) {
this.personalizationError = error.message || '加载失败';
- alert(`加载个性化配置失败:${this.personalizationError}`);
+ this.pushToast({
+ title: '加载个性化配置失败',
+ message: this.personalizationError,
+ type: 'error'
+ });
} finally {
this.personalizationLoading = false;
}
@@ -2593,7 +2651,11 @@ async function bootstrapApp() {
}, 3000);
} catch (error) {
this.personalizationError = error.message || '保存失败';
- alert(`保存个性化配置失败:${this.personalizationError}`);
+ this.pushToast({
+ title: '保存个性化配置失败',
+ message: this.personalizationError,
+ type: 'error'
+ });
} finally {
this.personalizationSaving = false;
}
@@ -2604,7 +2666,11 @@ async function bootstrapApp() {
return;
}
if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) {
- alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`);
+ this.pushToast({
+ title: '提示',
+ message: `最多添加 ${this.personalizationMaxConsiderations} 条信息`,
+ type: 'warning'
+ });
return;
}
this.personalForm.considerations = [
@@ -2677,7 +2743,11 @@ async function bootstrapApp() {
}
} catch (error) {
console.error('切换思考模式失败:', error);
- alert(`切换思考模式失败: ${error.message}`);
+ this.pushToast({
+ title: '切换思考模式失败',
+ message: error.message || '请稍后重试',
+ type: 'error'
+ });
}
},
@@ -2900,6 +2970,12 @@ async function bootstrapApp() {
}
this.uploading = true;
+ const toastId = this.pushToast({
+ title: '上传文件',
+ message: `正在上传 ${file.name}...`,
+ type: 'info',
+ duration: null
+ });
try {
const formData = new FormData();
@@ -2911,6 +2987,13 @@ async function bootstrapApp() {
body: formData
});
+ if (toastId) {
+ this.updateToast(toastId, {
+ message: `文件已上传,正在执行安全扫描...`,
+ type: 'info'
+ });
+ }
+
let result = {};
try {
result = await response.json();
@@ -2924,10 +3007,48 @@ async function bootstrapApp() {
}
await this.refreshFileTree();
- alert(`上传成功:${result.path || file.name}`);
+ if (toastId) {
+ this.updateToast(toastId, {
+ title: '上传完成',
+ message: `上传成功:${result.path || file.name}`,
+ type: 'success',
+ duration: 4500
+ });
+ } else {
+ this.pushToast({
+ title: '上传完成',
+ message: `上传成功:${result.path || file.name}`,
+ type: 'success'
+ });
+ }
} catch (error) {
console.error('文件上传失败:', error);
- alert(`文件上传失败:${error.message}`);
+ const originalMessage = error && error.message ? String(error.message) : '';
+ const fileLabel = file && file.name ? file.name : '文件';
+ let displayMessage = originalMessage || '请稍后重试';
+ if (/安全审核未通过/.test(originalMessage) || /Eicar/i.test(originalMessage)) {
+ displayMessage = `${fileLabel} 安全审核未通过`;
+ } else if (/文件类型不在允许列表中/.test(originalMessage)) {
+ displayMessage = `${fileLabel} 文件类型不在允许列表中`;
+ } else if (displayMessage) {
+ displayMessage = `${fileLabel} 上传失败:${displayMessage}`;
+ } else {
+ displayMessage = `${fileLabel} 上传失败,请稍后重试`;
+ }
+ if (toastId) {
+ this.updateToast(toastId, {
+ title: '上传失败',
+ message: displayMessage,
+ type: 'error',
+ duration: 5000
+ });
+ } else {
+ this.pushToast({
+ title: '上传失败',
+ message: displayMessage,
+ type: 'error'
+ });
+ }
} finally {
this.uploading = false;
this.resetFileInput();
@@ -2995,15 +3116,25 @@ async function bootstrapApp() {
}
},
- clearChat() {
- if (confirm('确定要清除所有对话记录吗?')) {
+ async clearChat() {
+ const confirmed = await this.confirmAction({
+ title: '清除对话',
+ message: '确定要清除所有对话记录吗?该操作不可撤销。',
+ confirmText: '清除',
+ cancelText: '取消'
+ });
+ if (confirmed) {
this.socket.emit('send_command', { command: '/clear' });
}
},
async compressConversation() {
if (!this.currentConversationId) {
- alert('当前没有可压缩的对话。');
+ this.pushToast({
+ title: '无法压缩',
+ message: '当前没有可压缩的对话。',
+ type: 'info'
+ });
return;
}
@@ -3011,7 +3142,12 @@ async function bootstrapApp() {
return;
}
- const confirmed = confirm('确定要压缩当前对话记录吗?压缩后会生成新的对话副本。');
+ const confirmed = await this.confirmAction({
+ title: '压缩对话',
+ message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。',
+ confirmText: '压缩',
+ cancelText: '取消'
+ });
if (!confirmed) {
return;
}
@@ -3033,11 +3169,19 @@ async function bootstrapApp() {
console.log('对话压缩完成:', result);
} else {
const message = result.message || result.error || '压缩失败';
- alert(`压缩失败: ${message}`);
+ this.pushToast({
+ title: '压缩失败',
+ message,
+ type: 'error'
+ });
}
} catch (error) {
console.error('压缩对话异常:', error);
- alert(`压缩对话异常: ${error.message}`);
+ this.pushToast({
+ title: '压缩对话异常',
+ message: error.message || '请稍后重试',
+ type: 'error'
+ });
} finally {
this.compressing = false;
}
@@ -4100,6 +4244,95 @@ async function bootstrapApp() {
}, 5000);
},
+ dismissQuotaToast() {
+ if (this.quotaToastTimer) {
+ clearTimeout(this.quotaToastTimer);
+ this.quotaToastTimer = null;
+ }
+ this.quotaToast = null;
+ },
+
+ pushToast(options = {}) {
+ const title = options.title || '';
+ const message = options.message || '';
+ if (!title && !message) {
+ return null;
+ }
+ const id = this.nextToastId++;
+ const entry = {
+ id,
+ title,
+ message,
+ type: options.type || 'info',
+ closable: options.closable !== false,
+ timeoutId: null
+ };
+ const duration = Object.prototype.hasOwnProperty.call(options, 'duration') ? options.duration : 4000;
+ if (duration !== null) {
+ entry.timeoutId = setTimeout(() => this.dismissToast(id), duration);
+ }
+ this.toastQueue.push(entry);
+ return id;
+ },
+
+ updateToast(id, patch = {}) {
+ const entry = this.toastQueue.find(item => item.id === id);
+ if (!entry) {
+ return;
+ }
+ Object.assign(entry, patch);
+ if (Object.prototype.hasOwnProperty.call(patch, 'duration')) {
+ if (entry.timeoutId) {
+ clearTimeout(entry.timeoutId);
+ entry.timeoutId = null;
+ }
+ if (patch.duration !== null) {
+ entry.timeoutId = setTimeout(() => this.dismissToast(id), patch.duration);
+ }
+ }
+ },
+
+ dismissToast(id) {
+ const index = this.toastQueue.findIndex(item => item.id === id);
+ if (index === -1) {
+ return;
+ }
+ const [entry] = this.toastQueue.splice(index, 1);
+ if (entry && entry.timeoutId) {
+ clearTimeout(entry.timeoutId);
+ }
+ },
+
+ confirmAction(options = {}) {
+ return new Promise((resolve) => {
+ if (this.pendingConfirmResolver) {
+ const previous = this.pendingConfirmResolver;
+ this.pendingConfirmResolver = null;
+ previous(false);
+ }
+ this.confirmDialog = {
+ visible: true,
+ title: options.title || '确认操作',
+ message: options.message || '请确认本次操作',
+ confirmText: options.confirmText || '确认',
+ cancelText: options.cancelText || '取消'
+ };
+ this.pendingConfirmResolver = resolve;
+ });
+ },
+
+ handleConfirm(choice) {
+ if (this.confirmDialog) {
+ this.confirmDialog.visible = false;
+ }
+ const resolver = this.pendingConfirmResolver;
+ this.pendingConfirmResolver = null;
+ this.confirmDialog = null;
+ if (resolver) {
+ resolver(!!choice);
+ }
+ },
+
containerStatusText() {
if (!this.containerStatus) {
return '未知';
diff --git a/static/index.html b/static/index.html
index f31ef03..97fd333 100644
--- a/static/index.html
+++ b/static/index.html
@@ -35,8 +35,51 @@