From ac10de04666a52a7c24842ff7b050be07de02e3b Mon Sep 17 00:00:00 2001 From: eezd Date: Sun, 25 Oct 2026 11:56:28 +0800 Subject: [PATCH] feat(trash): add bulk purge and automatic retention --- README.md | 1 + cmd/rawmark/main.go | 6 +++ internal/api/api.go | 1 + internal/api/openapi.json | 69 ++++++++++++++++++++++-- internal/api/settings.go | 6 ++- internal/api/trash.go | 79 +++++++++++++++++++++++++++ internal/api/trash_test.go | 96 +++++++++++++++++++++++++++++++++ internal/vault/trash.go | 52 ++++++++++++++++++ internal/vault/trash_test.go | 98 ++++++++++++++++++++++++++++++++++ internal/vault/vault.go | 11 +++- web/src/app/SettingsDialog.vue | 2 +- web/src/app/TrashPanel.vue | 76 +++++++++++++++++++------- web/src/app/api.ts | 6 +++ web/src/app/settings.test.ts | 46 ++++++++++++++++ web/src/app/settings.ts | 3 +- web/src/app/settingsCatalog.ts | 2 + 16 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 internal/api/trash.go create mode 100644 internal/api/trash_test.go create mode 100644 internal/vault/trash.go create mode 100644 internal/vault/trash_test.go diff --git a/README.md b/README.md index 3d8ff0f..50e8dc7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ docker run -d --name rawmark \ - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。 +- 回收站:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。 - 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。 - 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 727a441..636a85c 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -84,6 +84,12 @@ func run(addr string) error { srv.ThemesDir = filepath.Join(dataDir, "themes") srv.PluginsDir = filepath.Join(dataDir, "plugins") srv.FontsDir = filepath.Join(dataDir, "fonts") + trashDone := make(chan struct{}) + go func() { + defer close(trashDone) + srv.RunTrashCleanup(ctx) + }() + defer func() { stop(); <-trashDone }() httpServer := &http.Server{ Addr: addr, Handler: srv.Handler(web.Dist()), diff --git a/internal/api/api.go b/internal/api/api.go index de907ca..0ca28da 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -92,6 +92,7 @@ func (s *Server) routes() []route { {"PUT /api/v1/assets/{path...}", authed, s.replaceImage}, {"GET /api/v1/raw/{path...}", authed, s.raw}, {"GET /api/v1/trash", authed, s.trashList}, + {"DELETE /api/v1/trash", authed, s.trashClear}, {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore}, {"DELETE /api/v1/trash/{id}", authed, s.trashPurge}, {"GET /api/v1/events", authed, s.events}, diff --git a/internal/api/openapi.json b/internal/api/openapi.json index f5690f8..551a56c 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -349,14 +349,25 @@ "cookie": [] } ], - "description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。", + "description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。 trashRetentionDays 为非负安全整数(默认30,0为永不自动删除),null恢复默认。期限适用于已有和新回收站条目,服务启动及每小时按原删除时间永久清理。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "trashRetentionDays": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 9007199254740991, + "default": 30 + } + } } } } @@ -374,7 +385,7 @@ } }, "400": { - "description": "不是 JSON 对象", + "description": "不是 JSON 对象,或自动删除期限无效", "content": { "application/json": { "schema": { @@ -1008,6 +1019,25 @@ } } } + }, + "delete": { + "summary": "清空回收站全部条目", + "description": "永久删除,不可恢复;结果逐项报告,失败条目保留。只读令牌禁止。", + "tags": [ + "回收站" + ], + "responses": { + "200": { + "description": "包含成功及失败的逐项结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrashPurgeResult" + } + } + } + } + } } }, "/trash/{id}/restore": { @@ -2366,6 +2396,39 @@ "description": "Emoji 字符或内置线性图标名称,最多64字节" } } + }, + "TrashPurgeResult": { + "type": "object", + "required": [ + "deleted", + "failed" + ], + "properties": { + "deleted": { + "type": "array", + "items": { + "type": "string" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "error" + ], + "properties": { + "id": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + } + } } } } diff --git a/internal/api/settings.go b/internal/api/settings.go index 2687e32..38dc51a 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -11,7 +11,7 @@ import ( "rawmark/internal/events" ) -// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端只做合并与保存 +// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端合并与保存,并校验由服务端执行的清理期限 const settingsKey = "app.settings" func (s *Server) readSettings() (map[string]json.RawMessage, error) { @@ -60,6 +60,10 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { settings[k] = v } } + if _, err := trashRetentionDays(settings); err != nil { + writeError(w, 400, err.Error()) + return + } merged, err := json.Marshal(settings) if err != nil { internalError(w, r, err) diff --git a/internal/api/trash.go b/internal/api/trash.go new file mode 100644 index 0000000..d232b06 --- /dev/null +++ b/internal/api/trash.go @@ -0,0 +1,79 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +// JSON/JavaScript 共同精确表示的非负整数范围;不乘天数,避免期限溢出。 +const maxRetentionDays = 1<<53 - 1 + +func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) { + raw, ok := settings["trashRetentionDays"] + if !ok || bytes.Equal(raw, []byte("null")) { + return 30, nil + } + var days int64 + if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays { + return 0, fmt.Errorf("回收站保留天数必须为非负安全整数,0 表示永不自动删除") + } + return days, nil +} + +func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) { + result, err := s.vault.PurgeTrash(0, time.Now()) + if err != nil { + internalError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) cleanupTrash(now time.Time) error { + // 与设置写入串行,避免在“永不删除”已保存后使用旧期限开始清理。 + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + settings, err := s.readSettings() + if err != nil { + return err + } + days, err := trashRetentionDays(settings) + if err != nil || days == 0 { + return err + } + result, err := s.vault.PurgeTrash(days, now) + if err != nil { + return err + } + if len(result.Failed) > 0 { + return fmt.Errorf("回收站清理部分失败: %v", result.Failed) + } + if len(result.Deleted) > 0 { + slog.Info("已清理过期回收站条目", "count", len(result.Deleted)) + } + return nil +} + +// RunTrashCleanup 启动时执行一次,其后每小时执行;不依赖浏览器在线。 +func (s *Server) RunTrashCleanup(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return + } + if err := s.cleanupTrash(time.Now()); err != nil { + slog.Error("自动清理回收站失败", "err", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/internal/api/trash_test.go b/internal/api/trash_test.go new file mode 100644 index 0000000..e853259 --- /dev/null +++ b/internal/api/trash_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "rawmark/internal/vault" +) + +func TestTrashRetentionSettingsValidation(t *testing.T) { + f := newFixture(t) + for _, body := range []string{`{"trashRetentionDays":-1}`, `{"trashRetentionDays":0.5}`, `{"trashRetentionDays":"30"}`, `{"trashRetentionDays":true}`, `{"trashRetentionDays":9007199254740992}`} { + if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 { + t.Fatalf("应拒绝 %s: %d", body, code) + } + } + for _, value := range []string{"30", "0", "9007199254740991", "null"} { + if code := f.status(f.admin, "PATCH", "/api/v1/settings", `{"trashRetentionDays":`+value+`}`, nil); code != 200 { + t.Fatalf("应接受 %s: %d", value, code) + } + } + for _, raw := range []string{`{}`, `{"trashRetentionDays":null}`} { + settings := map[string]json.RawMessage{} + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + t.Fatal(err) + } + if days, err := trashRetentionDays(settings); err != nil || days != 30 { + t.Fatalf("缺省应30天: %d %v", days, err) + } + } +} + +func TestTrashClearAuthorization(t *testing.T) { + f := newFixture(t) + f.write("a.md", "content") + decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil)) + for _, scope := range []string{"read", "write"} { + token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"test","scope":"`+scope+`"}`, nil)).Token + headers := map[string]string{"Authorization": "Bearer " + token} + if scope == "read" { + if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 { + t.Fatalf("只读不能清空: %d", code) + } + } else { + result := decode[vault.TrashPurgeResult](t, f.do(&http.Client{}, "DELETE", "/api/v1/trash", "", headers)) + if len(result.Deleted) != 1 || len(result.Failed) != 0 { + t.Fatalf("写令牌清空结果异常: %+v", result) + } + } + } +} + +func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) { + f := newFixture(t) + v, err := vault.Open(f.root) + if err != nil { + t.Fatal(err) + } + f.write("old.md", "content") + item, err := v.Delete("old.md") + if err != nil { + t.Fatal(err) + } + metaPath := filepath.Join(f.root, ".rawmark/trash", item.ID, "meta.json") + item.DeletedAt = time.Now().Add(-40 * 24 * time.Hour).UnixMilli() + meta, _ := json.Marshal(item) + if err := os.WriteFile(metaPath, meta, 0600); err != nil { + t.Fatal(err) + } + s := New(v, nil, f.store, f.hub, nil) + for _, value := range []string{`{"trashRetentionDays":0}`, `{"trashRetentionDays":-1}`, `{"trashRetentionDays":"broken"}`} { + if err := f.store.SetSetting(settingsKey, value); err != nil { + t.Fatal(err) + } + err := s.cleanupTrash(time.Now()) + if value != `{"trashRetentionDays":0}` && err == nil { + t.Fatal("无效配置不可静默降级为默认删除") + } + if _, err := os.Stat(metaPath); err != nil { + t.Fatalf("禁用或无效设置不应删除: %v", err) + } + } + if err := f.store.SetSetting(settingsKey, `{}`); err != nil { + t.Fatal(err) + } + if err := s.cleanupTrash(time.Now()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(metaPath); !os.IsNotExist(err) { + t.Fatalf("缺省30天应删除旧条目: %v", err) + } +} diff --git a/internal/vault/trash.go b/internal/vault/trash.go new file mode 100644 index 0000000..da5b221 --- /dev/null +++ b/internal/vault/trash.go @@ -0,0 +1,52 @@ +package vault + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +type TrashFailure struct { + ID string `json:"id"` + Error string `json:"error"` +} + +type TrashPurgeResult struct { + Deleted []string `json:"deleted"` + Failed []TrashFailure `json:"failed"` +} + +// PurgeTrash 在与恢复相同的写锁内读取元数据并清理。days=0 清空所有; +// 自动清理的“永不删除”由调用方处理,不调用本方法。 +func (v *Vault) PurgeTrash(days int64, now time.Time) (TrashPurgeResult, error) { + v.mu.Lock() + defer v.mu.Unlock() + result := TrashPurgeResult{Deleted: []string{}, Failed: []TrashFailure{}} + if days < 0 { + return result, fmt.Errorf("保留天数不能为负数") + } + dirs, err := os.ReadDir(v.trash) + if err != nil { + return result, err + } + for _, dir := range dirs { + id := dir.Name() + if !trashIDPattern.MatchString(id) { + continue // 不删除非本程序管理的文件。 + } + item, err := v.readTrashItem(id) + if err == nil && days > 0 && (item.DeletedAt > now.UnixMilli() || (now.UnixMilli()-item.DeletedAt)/int64(24*time.Hour/time.Millisecond) < days) { + continue + } + if err == nil { + err = os.RemoveAll(filepath.Join(v.trash, id)) + } + if err != nil { + result.Failed = append(result.Failed, TrashFailure{ID: id, Error: err.Error()}) + } else { + result.Deleted = append(result.Deleted, id) + } + } + return result, nil +} diff --git a/internal/vault/trash_test.go b/internal/vault/trash_test.go new file mode 100644 index 0000000..145aaee --- /dev/null +++ b/internal/vault/trash_test.go @@ -0,0 +1,98 @@ +package vault + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func trashAt(t *testing.T, v *Vault, path string, when time.Time) TrashItem { + t.Helper() + if _, err := v.Write(path, []byte("content"), Precondition{}); err != nil { + t.Fatal(err) + } + item, err := v.Delete(path) + if err != nil { + t.Fatal(err) + } + meta, err := v.readTrashMetadata(item.ID) + if err != nil { + t.Fatal(err) + } + meta.DeletedAt = when.UnixMilli() + data, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(v.trash, item.ID, "meta.json"), data, 0600); err != nil { + t.Fatal(err) + } + return meta.TrashItem +} + +func TestTrashRetentionAndInvalidMetadata(t *testing.T) { + v := newVault(t) + now := time.Now() + old := trashAt(t, v, "old.md", now.Add(-31*24*time.Hour)) + boundary := trashAt(t, v, "boundary.md", now.Add(-30*24*time.Hour)) + fresh := trashAt(t, v, "fresh.md", now.Add(-29*24*time.Hour)) + future := trashAt(t, v, "future.md", now.Add(time.Hour)) + broken := trashAt(t, v, "broken.md", now.Add(-40*24*time.Hour)) + if err := os.WriteFile(filepath.Join(v.trash, broken.ID, "meta.json"), []byte(`{"deletedAt":0}`), 0600); err != nil { + t.Fatal(err) + } + result, err := v.PurgeTrash(30, now) + if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 || result.Failed[0].ID != broken.ID { + t.Fatalf("结果异常: %+v, %v", result, err) + } + for _, item := range []TrashItem{old, boundary} { + if _, err := os.Stat(filepath.Join(v.trash, item.ID)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("过期条目未删除: %v", err) + } + } + for _, item := range []TrashItem{fresh, future, broken} { + if _, err := os.Stat(filepath.Join(v.trash, item.ID)); err != nil { + t.Fatalf("不应删除未过期/无效条目: %v", err) + } + } + // 极大天数不应溢出成过期期限。 + result, err = v.PurgeTrash(1<<53-1, now) + if err != nil || len(result.Deleted) != 0 || len(result.Failed) != 1 { + t.Fatalf("极大期限: %+v, %v", result, err) + } + result, err = v.PurgeTrash(0, now) + if err != nil || len(result.Deleted) != 2 || len(result.Failed) != 1 { + t.Fatalf("清空全部应独立报告损坏条目: %+v, %v", result, err) + } +} + +func TestTrashRestoreAndPurgeSerialize(t *testing.T) { + for range 20 { + v := newVault(t) + item := trashAt(t, v, "note.md", time.Now().Add(-40*24*time.Hour)) + var restoreErr, purgeErr error + var result TrashPurgeResult + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _, restoreErr = v.Restore(item.ID) }() + go func() { defer wg.Done(); result, purgeErr = v.PurgeTrash(30, time.Now()) }() + wg.Wait() + if purgeErr != nil || len(result.Failed) != 0 { + t.Fatalf("并发清理失败: %+v %v", result, purgeErr) + } + if restoreErr == nil { + if len(result.Deleted) != 0 { + t.Fatal("已恢复的内容不可再次清理") + } + if body, err := os.ReadFile(filepath.Join(v.Root(), item.Path)); err != nil || string(body) != "content" { + t.Fatalf("恢复内容丢失: %v", err) + } + } else if !errors.Is(restoreErr, ErrNotFound) || len(result.Deleted) != 1 { + t.Fatalf("清理先完成时应明确不存在: %v %+v", restoreErr, result) + } + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index acb3544..122cf42 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -637,6 +637,9 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) { if err := json.Unmarshal(data, &metadata); err != nil { return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err) } + if _, err := CleanPath(metadata.Path); err != nil || metadata.ID != id || metadata.DeletedAt <= 0 || (metadata.Assets != "" && metadata.Assets != filepath.Base(AssetsDir(metadata.Path))) { + return trashMetadata{}, fmt.Errorf("回收站条目 %s 元数据无效", id) + } for p, icon := range metadata.Icons { if _, err := CleanPath(p); err != nil || !icon.valid() || !(p == metadata.Path || metadata.Dir && strings.HasPrefix(p, metadata.Path+"/") || metadata.Assets != "" && (p == AssetsDir(metadata.Path) || strings.HasPrefix(p, AssetsDir(metadata.Path)+"/"))) { return trashMetadata{}, fmt.Errorf("回收站条目 %s 图标元数据损坏", id) @@ -646,6 +649,8 @@ func (v *Vault) readTrashMetadata(id string) (trashMetadata, error) { } func (v *Vault) TrashList() ([]TrashItem, error) { + v.mu.Lock() + defer v.mu.Unlock() dirs, err := os.ReadDir(v.trash) if err != nil { return nil, err @@ -666,6 +671,8 @@ func (v *Vault) TrashList() ([]TrashItem, error) { } func (v *Vault) Restore(id string) (item TrashItem, err error) { + v.mu.Lock() + defer v.mu.Unlock() metadata, err := v.readTrashMetadata(id) item = metadata.TrashItem if err != nil { @@ -675,8 +682,6 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { if err != nil { return TrashItem{}, err } - v.mu.Lock() - defer v.mu.Unlock() icons, err := v.readTreeIcons() if err != nil { return TrashItem{}, err @@ -741,6 +746,8 @@ func (v *Vault) Restore(id string) (item TrashItem, err error) { } func (v *Vault) Purge(id string) error { + v.mu.Lock() + defer v.mu.Unlock() if _, err := v.readTrashItem(id); err != nil { return err } diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index bede109..4b48164 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -30,7 +30,7 @@ const dialog = ref() const content = ref() const heading = ref() const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null -const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts'].includes(section.value)) +const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value)) const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存') async function open(id: SettingsSection) { diff --git a/web/src/app/TrashPanel.vue b/web/src/app/TrashPanel.vue index f906850..41213d9 100644 --- a/web/src/app/TrashPanel.vue +++ b/web/src/app/TrashPanel.vue @@ -2,33 +2,67 @@ import { onMounted, ref } from 'vue' import { api, type TrashItem } from './api' import Icon from './Icon.vue' +import SettingRow from './SettingRow.vue' import { isImage } from './paths' +import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings' import { restore, toast } from './workspace' const items = ref(null) -const failed = ref(false) +const error = ref('') +const failures = ref<{ id: string; error: string }[]>([]) +const busy = ref(false) +const saving = ref(false) async function load() { try { items.value = await api.trash() - } catch { - failed.value = true + error.value = '' + } catch (cause) { + error.value = '读取回收站失败:' + String(cause) } } -async function doRestore(item: TrashItem) { - items.value = items.value!.filter((i) => i.id !== item.id) - await restore(item.id) +async function retention(event: Event) { + const input = event.target as HTMLInputElement + const days = input.valueAsNumber + if (!Number.isSafeInteger(days) || days < 0) { + input.value = String(settings.trashRetentionDays) + toast('请输入非负整数天数,0 表示永不自动删除') + return + } + if (days === settings.trashRetentionDays) return + saving.value = true + try { await updateSettings({ trashRetentionDays: days }) } + catch (cause) { + toast('保存自动删除期限失败:' + String(cause)) + if (!(cause instanceof SettingsAppearanceError)) await loadSettings().catch(cause => toast('重新读取设置失败:' + String(cause))) + } finally { saving.value = false } } -async function purge(item: TrashItem) { - if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return - items.value = items.value!.filter((i) => i.id !== item.id) +async function doRestore(item: TrashItem) { + if (busy.value) return + busy.value = true + try { await restore(item.id); await load() } + finally { busy.value = false } +} + +async function purge(item?: TrashItem) { + if (busy.value) return + if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return + busy.value = true + failures.value = [] try { - await api.purge(item.id) - } catch { - toast('删除失败') - void load() + if (item) await api.purge(item.id) + else { + const result = await api.clearTrash() + failures.value = result.failed + toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`) + } + } catch (cause) { + toast('删除失败:' + String(cause)) + } finally { + await load() + busy.value = false } } @@ -38,17 +72,23 @@ onMounted(load)