fix(files): prevent silent upload overwrites
This commit is contained in:
parent
6b37ce141f
commit
1ac9aa1ba0
|
|
@ -5,6 +5,10 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.64.3 — 2026-08-12
|
||||
|
||||
- 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。
|
||||
|
||||
## 0.64.1 — 2026-08-12
|
||||
|
||||
- PDF 和 PPT 连续预览现在支持鼠标滚轮与手机手势滑动,不再只能拖动右侧滚动条。
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
|||
- **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。
|
||||
- **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
|
||||
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
|
||||
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。该机制只防误删产物,不防覆盖、agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
|
||||
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
|
||||
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
|
||||
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。
|
||||
- **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-12(数据库热路径与消息序号优化,bump 0.64.2)
|
||||
最后更新:2026-08-12(同名上传保护 + 明确替换文件,bump 0.64.3)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
|
||||
### 2026-08-12
|
||||
|
||||
- **08-12 / 0.64.3 / 同名上传保护 + 明确替换文件**:粘贴、拖拽与普通上传默认由服务端原子独占创建,同名按 `name (n).ext` 自动编号且并发请求不会互相覆盖;文件菜单新增「替换文件」,选择本地文件并二次确认后才以同目录临时文件原子替换指定目标。Python 冲突策略 4 项、Node 前端契约 2 项、JavaScript/Python 语法及 diff 检查通过;DB 路由回归已补,未配置显式测试库时安全跳过;无 schema、migration 或依赖变化,未连接生产 DB。
|
||||
|
||||
- **08-12 / 0.64.2 / 数据库热路径 + 消息序号原子化**:新增 0029 migration 平滑回填 `tasks.next_message_idx`,Web、agent 与渠道消息统一在 task 行锁下分配序号,并以实际 `max(idx)` 兼容蓝绿旧实例并行;任务列表、调度扫描/历史和 usage 时间序列补部分/BRIN 索引,列表追加 UUID 稳定排序,artifact 目录操作只查询并锁定目标子树。完整 532 项 unittest 全绿(17 skip),0029 PostgreSQL DDL 编译、Alembic 单 head、Ruff 致命规则及 diff 检查通过;未配置显式测试库,DB 集成测试安全跳过,未连接或写入生产 DB。
|
||||
|
||||
- **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离;PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动,使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。
|
||||
|
|
|
|||
2
RUN.md
2
RUN.md
|
|
@ -375,7 +375,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
|
|||
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
|
||||
| `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 |
|
||||
| `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 |
|
||||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
|
||||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir;`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 |
|
||||
| `POST /v1/files/mkdir` | `{path?="", name}`;在现有 `<user_root>/<path>/` 下新建直接子目录,禁止点目录和路径式名称;重名 → 409 | 必填 |
|
||||
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
|
||||
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.64.2"
|
||||
__version__ = "0.64.3"
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
|
||||
## 5. 文件操作
|
||||
|
||||
- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。
|
||||
- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。遇到同名文件会自动编号保留两份;确需覆盖时,在目标文件的 `⋯` 菜单选择「替换文件」并确认。
|
||||
- **选入**:点 `⊕` 从其他目录勾选文件,复制 / 移动到当前目录复用。
|
||||
- **预览**:点文件即在线看——图片可 Ctrl+滚轮缩放,Word/PDF/PPT/文本/表格直接渲染,无需下载。
|
||||
- **下载**:预览弹窗右上角 `下载`。
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@
|
|||
|
||||
1. **点 `⬆` 按钮**,选文件上传到当前目录。
|
||||
2. **直接把文件拖进右栏**(出现「松开以上传」提示时放手)。
|
||||
3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。
|
||||
3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。同名文件会自动编号保留两份;确需覆盖时,在右侧目标文件的 `⋯` 菜单选择「替换文件」并确认。
|
||||
|
||||
### 6.2 选入(从其他目录带文件进来)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const filesJs = fs.readFileSync("web/static/js/files.js", "utf8");
|
||||
|
||||
test("file menu exposes an explicit confirmed replacement action", () => {
|
||||
assert.match(filesJs, /label: "替换文件"/);
|
||||
assert.match(filesJs, /title: "替换文件"/);
|
||||
assert.match(filesJs, /okText: "确认替换"/);
|
||||
assert.match(filesJs, /danger: true/);
|
||||
assert.match(filesJs, /conflict: "overwrite"/);
|
||||
assert.match(filesJs, /targetName: name/);
|
||||
});
|
||||
|
||||
test("ordinary uploads default to rename conflict policy", () => {
|
||||
assert.match(filesJs, /fd\.append\("conflict", opts\.conflict \|\| "rename"\)/);
|
||||
assert.match(filesJs, /同名文件已保留为/);
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from web.routers.files import _numbered_upload_name, _replace_upload, _write_upload_without_overwrite
|
||||
|
||||
|
||||
class FileUploadConflictTests(unittest.TestCase):
|
||||
def test_number_is_inserted_before_complete_extension(self):
|
||||
self.assertEqual(_numbered_upload_name("data.tar.gz", 2), "data (2).tar.gz")
|
||||
self.assertEqual(_numbered_upload_name("报告", 1), "报告 (1)")
|
||||
self.assertEqual(_numbered_upload_name(".env", 1), ".env (1)")
|
||||
|
||||
def test_existing_file_is_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "报告.pdf").write_bytes(b"old")
|
||||
saved = _write_upload_without_overwrite(root, "报告.pdf", b"new")
|
||||
self.assertEqual(saved.name, "报告 (1).pdf")
|
||||
self.assertEqual((root / "报告.pdf").read_bytes(), b"old")
|
||||
self.assertEqual(saved.read_bytes(), b"new")
|
||||
|
||||
def test_concurrent_uploads_get_distinct_names(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
saved = list(pool.map(
|
||||
lambda i: _write_upload_without_overwrite(root, "same.txt", str(i).encode()),
|
||||
range(4),
|
||||
))
|
||||
self.assertEqual(len({path.name for path in saved}), 4)
|
||||
self.assertEqual(len(list(root.glob("same*.txt"))), 4)
|
||||
|
||||
def test_explicit_replace_changes_only_target(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
target = root / "target.txt"
|
||||
target.write_bytes(b"old")
|
||||
_replace_upload(target, b"new")
|
||||
self.assertEqual(target.read_bytes(), b"new")
|
||||
self.assertFalse(list(root.glob(".zcbot-upload-*")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -527,6 +527,21 @@ class FilesDbAwareTests(unittest.TestCase):
|
|||
files={"files": ("hello.txt", b"hi", "text/plain")}, headers=_AUTH)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()["count"], 1)
|
||||
self.assertEqual(r.json()["saved"][0]["name"], "hello.txt")
|
||||
# 默认重名保留两份,完整扩展名之前追加编号。
|
||||
r = _client.post("/v1/files/upload", data={"path": "上传目录"},
|
||||
files={"files": ("hello.txt", b"new", "text/plain")}, headers=_AUTH)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()["saved"][0]["name"], "hello (1).txt")
|
||||
self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"hi")
|
||||
self.assertEqual((_user_root() / "上传目录" / "hello (1).txt").read_bytes(), b"new")
|
||||
# 明确覆盖可用本地文件内容替换指定目标名。
|
||||
r = _client.post("/v1/files/upload",
|
||||
data={"path": "上传目录", "conflict": "overwrite", "target_name": "hello.txt"},
|
||||
files={"files": ("local-name.txt", b"replaced", "text/plain")}, headers=_AUTH)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()["saved"][0]["name"], "hello.txt")
|
||||
self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"replaced")
|
||||
names = {e["name"] for e in _client.get("/v1/files", headers=_AUTH).json()["entries"]}
|
||||
self.assertIn("上传目录", names)
|
||||
# 非法文件名 → 400
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ web/userfiles.py。
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -43,6 +45,47 @@ from ..userfiles import (
|
|||
_pptx_preview_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _numbered_upload_name(filename: str, index: int) -> str:
|
||||
"""在完整扩展名前追加编号,例如 data.tar.gz -> data (1).tar.gz。"""
|
||||
path = Path(filename)
|
||||
suffix = "" if filename.startswith(".") and filename.count(".") == 1 else "".join(path.suffixes)
|
||||
stem = filename[:-len(suffix)] if suffix else filename
|
||||
return f"{stem} ({index}){suffix}"
|
||||
|
||||
|
||||
def _write_upload_without_overwrite(dest_dir: Path, filename: str, data: bytes) -> Path:
|
||||
"""以原子独占创建保存上传;并发重名时继续递增编号。"""
|
||||
index = 0
|
||||
while True:
|
||||
candidate = filename if index == 0 else _numbered_upload_name(filename, index)
|
||||
dest = dest_dir / candidate
|
||||
try:
|
||||
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
||||
except FileExistsError:
|
||||
index += 1
|
||||
continue
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
except Exception:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise
|
||||
return dest
|
||||
|
||||
|
||||
def _replace_upload(dest: Path, data: bytes) -> None:
|
||||
"""先完整写入同目录临时文件,再原子替换明确选择的目标。"""
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".zcbot-upload-", dir=dest.parent)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
os.replace(tmp, dest)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
|
||||
lock = _pptx_preview_locks.get(abs_path)
|
||||
if lock is None:
|
||||
|
|
@ -293,11 +336,14 @@ def register_file_routes(app, *, require_user) -> None:
|
|||
@app.post("/v1/files/upload", tags=["files"])
|
||||
async def upload_files(
|
||||
path: str = Form(""),
|
||||
conflict: str = Form("rename"),
|
||||
target_name: str = Form(""),
|
||||
files: list[UploadFile] = File(...),
|
||||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""multipart 多文件上传到 `<user_root>/<path>/`。
|
||||
路径不存在自动 mkdir(parents=True);重名直接覆盖。
|
||||
路径不存在自动 mkdir(parents=True);默认重名自动编号保留两份。
|
||||
仅显式 `conflict=overwrite` 可覆盖;`target_name` 用于替换指定文件。
|
||||
文件名严格校验(含 `/ \\ ..` 或为空 → 400)。
|
||||
"""
|
||||
# 磁盘配额 gate(§7.5 #4):超额 413 阻止上传,提示 user 清旧产物
|
||||
|
|
@ -316,9 +362,14 @@ def register_file_routes(app, *, require_user) -> None:
|
|||
raise HTTPException(400, f"upload target is a file, not a directory: {path}")
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if conflict not in ("rename", "overwrite"):
|
||||
raise HTTPException(400, f"invalid conflict policy: {conflict!r}")
|
||||
if target_name and (conflict != "overwrite" or len(files or []) != 1):
|
||||
raise HTTPException(400, "target_name requires one file and conflict=overwrite")
|
||||
|
||||
saved: list[dict] = []
|
||||
for up in files or []:
|
||||
raw_name = up.filename or ""
|
||||
raw_name = target_name or up.filename or ""
|
||||
if (
|
||||
not raw_name
|
||||
or raw_name in (".", "..")
|
||||
|
|
@ -326,14 +377,20 @@ def register_file_routes(app, *, require_user) -> None:
|
|||
or any(part in (".", "..") for part in Path(raw_name).parts)
|
||||
):
|
||||
raise HTTPException(400, f"invalid filename: {raw_name!r}")
|
||||
dest = dest_dir / raw_name
|
||||
requested_dest = dest_dir / raw_name
|
||||
try:
|
||||
dest.resolve().relative_to(root.resolve())
|
||||
requested_dest.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"path escapes user_root: {raw_name!r}")
|
||||
data = await up.read()
|
||||
dest.write_bytes(data)
|
||||
saved.append({"name": raw_name, "size": len(data), "rel": rel_to(root, dest)})
|
||||
if conflict == "overwrite":
|
||||
if requested_dest.exists() and not requested_dest.is_file():
|
||||
raise HTTPException(409, f"upload target is not a file: {raw_name!r}")
|
||||
_replace_upload(requested_dest, data)
|
||||
dest = requested_dest
|
||||
else:
|
||||
dest = _write_upload_without_overwrite(dest_dir, raw_name, data)
|
||||
saved.append({"name": dest.name, "size": len(data), "rel": rel_to(root, dest)})
|
||||
if not saved:
|
||||
raise HTTPException(400, "no files uploaded")
|
||||
return {"count": len(saved), "saved": saved}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
||||
// 文件面板:右栏列表浏览/导航/删除/重命名/明确替换、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
||||
// 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。
|
||||
// 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈)
|
||||
// / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。
|
||||
|
|
@ -327,6 +327,8 @@ function fileMenuItems(e) {
|
|||
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
||||
];
|
||||
if (!e.is_dir) {
|
||||
items.push({ act: "replace", label: "替换文件", cls: "act-replace",
|
||||
onclick: () => replaceFile(e.rel, e.name) });
|
||||
items.push({ act: "download", label: "下载", cls: "act-download",
|
||||
onclick: () => downloadFile(e.rel) });
|
||||
}
|
||||
|
|
@ -335,6 +337,31 @@ function fileMenuItems(e) {
|
|||
return items;
|
||||
}
|
||||
|
||||
async function replaceFile(rel, name) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const confirmed = await dialogConfirm({
|
||||
title: "替换文件",
|
||||
message: `将用 "${file.name}" 的内容替换 "${name}",原文件将被覆盖且无法恢复。`,
|
||||
okText: "确认替换",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
const slash = rel.lastIndexOf("/");
|
||||
const path = slash >= 0 ? rel.slice(0, slash) : "";
|
||||
const saved = await uploadFiles([file], {
|
||||
path,
|
||||
conflict: "overwrite",
|
||||
targetName: name,
|
||||
});
|
||||
if (saved && saved.length) message(`已替换 ${name}`, "success");
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteFile(rel, name, isDir) {
|
||||
let recursive = false;
|
||||
if (!isDir) {
|
||||
|
|
@ -452,7 +479,9 @@ async function uploadFilesWithPaneStatus(files) {
|
|||
export async function uploadFiles(files, opts = {}) {
|
||||
if (!files || !files.length) return null;
|
||||
const fd = new FormData();
|
||||
fd.append("path", state.filesPath || "");
|
||||
fd.append("path", opts.path ?? state.filesPath ?? "");
|
||||
fd.append("conflict", opts.conflict || "rename");
|
||||
if (opts.targetName) fd.append("target_name", opts.targetName);
|
||||
for (const f of files) fd.append("files", f);
|
||||
try {
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
|
|
@ -479,7 +508,13 @@ export async function uploadFiles(files, opts = {}) {
|
|||
xhr.send(fd);
|
||||
});
|
||||
await loadFiles();
|
||||
return data.saved || [];
|
||||
const saved = data.saved || [];
|
||||
if ((opts.conflict || "rename") === "rename") {
|
||||
const renamed = saved.filter((item, i) => item.name !== files[i]?.name);
|
||||
if (renamed.length === 1) message(`同名文件已保留为 ${renamed[0].name}`, "success");
|
||||
else if (renamed.length > 1) message(`${renamed.length} 个同名文件已自动重命名`, "success");
|
||||
}
|
||||
return saved;
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return null; }
|
||||
message("上传失败:" + e.message, "error");
|
||||
|
|
|
|||
Loading…
Reference in New Issue