feat(settings): show categorized storage usage on demand
This commit is contained in:
1 parent
ac10de0466
commit
56145dba72
14 files changed
+440
-5
No files matched your search
@@ -42,7 +42,7 @@ docker run -d --name rawmark \
|
|||||||
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
||||||
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
|
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
|
||||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
|
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
|
||||||
- 回收站:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。
|
- 回收站与存储:回收站支持清空全部;默认按原删除时间保留 30 天,0 表示永不自动删除,服务启动及每小时执行清理,修改期限同样影响已有条目。设置「应用 → 存储空间」按需统计数据目录与外置笔记库,不跟随内部符号链接;附件与回收站包含在笔记库总量中。
|
||||||
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。
|
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认跟随 [Typora](https://support.typora.io/Shortcut-Keys/) 的 Windows/Linux 或 macOS 组合,无对应项和插件默认留空;浏览器保留键可自行重新绑定。Ctrl/Cmd+1~6 设置标题,同级再次触发恢复正文;字号可直接输入,编辑器字号随有效输入即时生效。
|
||||||
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
|
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
|
||||||
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func run(addr string) error {
|
|||||||
go cleanupSessions(ctx, st)
|
go cleanupSessions(ctx, st)
|
||||||
|
|
||||||
srv := api.New(v, am, st, hub, idx)
|
srv := api.New(v, am, st, hub, idx)
|
||||||
|
srv.DataDir = dataDir
|
||||||
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
srv.ThemesDir = filepath.Join(dataDir, "themes")
|
||||||
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
srv.PluginsDir = filepath.Join(dataDir, "plugins")
|
||||||
srv.FontsDir = filepath.Join(dataDir, "fonts")
|
srv.FontsDir = filepath.Join(dataDir, "fonts")
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Server struct {
|
|||||||
// 串行化设置的"读取-合并-写入"
|
// 串行化设置的"读取-合并-写入"
|
||||||
settingsMu sync.Mutex
|
settingsMu sync.Mutex
|
||||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||||
|
DataDir string
|
||||||
ThemesDir string
|
ThemesDir string
|
||||||
PluginsDir string
|
PluginsDir string
|
||||||
FontsDir string
|
FontsDir string
|
||||||
@@ -93,6 +94,7 @@ func (s *Server) routes() []route {
|
|||||||
{"GET /api/v1/raw/{path...}", authed, s.raw},
|
{"GET /api/v1/raw/{path...}", authed, s.raw},
|
||||||
{"GET /api/v1/trash", authed, s.trashList},
|
{"GET /api/v1/trash", authed, s.trashList},
|
||||||
{"DELETE /api/v1/trash", authed, s.trashClear},
|
{"DELETE /api/v1/trash", authed, s.trashClear},
|
||||||
|
{"GET /api/v1/storage", sessionOnly, s.getStorage},
|
||||||
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
|
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
|
||||||
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
|
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
|
||||||
{"GET /api/v1/events", authed, s.events},
|
{"GET /api/v1/events", authed, s.events},
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func newFixture(t *testing.T) *fixture {
|
|||||||
t.Cleanup(func() { idx.Close() })
|
t.Cleanup(func() { idx.Close() })
|
||||||
hub := events.NewHub()
|
hub := events.NewHub()
|
||||||
server := New(v, am, st, hub, idx)
|
server := New(v, am, st, hub, idx)
|
||||||
|
server.DataDir = dir
|
||||||
server.ThemesDir = filepath.Join(dir, "themes")
|
server.ThemesDir = filepath.Join(dir, "themes")
|
||||||
server.PluginsDir = filepath.Join(dir, "plugins")
|
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||||
server.FontsDir = filepath.Join(dir, "fonts")
|
server.FontsDir = filepath.Join(dir, "fonts")
|
||||||
|
|||||||
@@ -2185,6 +2185,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/storage": {
|
||||||
|
"get": {
|
||||||
|
"summary": "按需统计存储空间",
|
||||||
|
"description": "只接受登录会话。统计数据目录和实际笔记库的普通文件字节,嵌套去重,不跟随内部符号链接;附件/回收站是笔记库的子项,totalSize不重复累加。calculatedAt为统计完成时间(Unix毫秒)。扫描失败返回错误,不返回部分结果。",
|
||||||
|
"tags": [
|
||||||
|
"设置"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "统计结果",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StorageUsage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "只接受登录会话"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "扫描失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -2429,6 +2461,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"StorageUsage": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"totalSize",
|
||||||
|
"categories",
|
||||||
|
"assetsSize",
|
||||||
|
"trashSize",
|
||||||
|
"calculatedAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"totalSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"assetsSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"trashSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"calculatedAt": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"size"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"vault",
|
||||||
|
"databases",
|
||||||
|
"fonts",
|
||||||
|
"themes",
|
||||||
|
"plugins",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageCategory struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageUsage struct {
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
Categories []storageCategory `json:"categories"`
|
||||||
|
AssetsSize int64 `json:"assetsSize"`
|
||||||
|
TrashSize int64 `json:"trashSize"`
|
||||||
|
CalculatedAt int64 `json:"calculatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usage, err := scanStorage(r.Context(), s.DataDir, s.vault.Root())
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。
|
||||||
|
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。
|
||||||
|
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
|
||||||
|
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
|
||||||
|
if dataDir == "" {
|
||||||
|
return usage, fmt.Errorf("未配置应用数据目录")
|
||||||
|
}
|
||||||
|
dataRoot, err := filepath.EvalSymlinks(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
dataRoot, err = filepath.Abs(dataRoot)
|
||||||
|
if err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
vaultRoot, err := filepath.Abs(vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
appCategory := func(rel string) int {
|
||||||
|
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||||
|
switch top {
|
||||||
|
case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm":
|
||||||
|
return 1
|
||||||
|
case "fonts":
|
||||||
|
return 2
|
||||||
|
case "themes":
|
||||||
|
return 3
|
||||||
|
case "plugins":
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk := func(root, skip string, isVault bool) error {
|
||||||
|
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if walkErr != nil {
|
||||||
|
if path != root && errors.Is(walkErr, fs.ErrNotExist) {
|
||||||
|
return nil // 文件可能在扫描期间被移动或删除。
|
||||||
|
}
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if path != root && path == skip && entry.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
category := 0
|
||||||
|
if !isVault || dataRoot == vaultRoot {
|
||||||
|
category = appCategory(rel)
|
||||||
|
if isVault && category == 5 {
|
||||||
|
category = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size := info.Size()
|
||||||
|
usage.TotalSize += size
|
||||||
|
usage.Categories[category].Size += size
|
||||||
|
if category == 0 {
|
||||||
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||||
|
if len(parts) >= 3 && parts[0] == ".rawmark" && parts[1] == "trash" {
|
||||||
|
usage.TrashSize += size
|
||||||
|
} else {
|
||||||
|
for _, part := range parts[:len(parts)-1] {
|
||||||
|
if part == "assets" || strings.HasSuffix(part, ".assets") {
|
||||||
|
usage.AssetsSize += size
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if dataRoot != vaultRoot {
|
||||||
|
if err := walk(dataRoot, vaultRoot, false); err != nil {
|
||||||
|
return storageUsage{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := walk(vaultRoot, dataRoot, true); err != nil {
|
||||||
|
return storageUsage{}, err
|
||||||
|
}
|
||||||
|
usage.CalculatedAt = time.Now().UnixMilli()
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStorageCountsOwnedRootsOnce(t *testing.T) {
|
||||||
|
for _, layout := range []string{"nested-vault", "external-vault", "nested-data", "same-root"} {
|
||||||
|
t.Run(layout, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
data, vault := filepath.Join(root, "data"), filepath.Join(root, "data", "vault")
|
||||||
|
switch layout {
|
||||||
|
case "external-vault":
|
||||||
|
vault = filepath.Join(root, "external")
|
||||||
|
case "nested-data":
|
||||||
|
vault, data = root, filepath.Join(root, "app")
|
||||||
|
case "same-root":
|
||||||
|
vault = data
|
||||||
|
}
|
||||||
|
write := func(base, rel string, size int) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(base, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, make([]byte, size), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(vault, "note.md", 5)
|
||||||
|
write(vault, "dir/assets/a.png", 7)
|
||||||
|
write(vault, "old.assets/b.png", 11)
|
||||||
|
write(vault, ".rawmark/trash/id/meta.json", 13)
|
||||||
|
write(vault, ".rawmark/trash/id/assets/a.png", 17)
|
||||||
|
write(data, "rawmark.db", 19)
|
||||||
|
write(data, "index.db-wal", 23)
|
||||||
|
write(data, "fonts/a/file.woff2", 29)
|
||||||
|
write(data, "themes/a/theme.css", 31)
|
||||||
|
write(data, "plugins/a/main.js", 37)
|
||||||
|
write(data, "misc.bin", 41)
|
||||||
|
outside := t.TempDir()
|
||||||
|
write(outside, "outside.md", 1000)
|
||||||
|
if err := os.Symlink(outside, filepath.Join(vault, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
usage, err := scanStorage(context.Background(), data, vault)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]int64{"vault": 53, "databases": 42, "fonts": 29, "themes": 31, "plugins": 37, "other": 41}
|
||||||
|
if layout == "same-root" {
|
||||||
|
want["vault"] += 41
|
||||||
|
want["other"] = 0
|
||||||
|
}
|
||||||
|
for _, category := range usage.Categories {
|
||||||
|
if category.Size != want[category.ID] {
|
||||||
|
t.Errorf("%s = %d, want %d", category.ID, category.Size, want[category.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usage.TotalSize != 233 || usage.AssetsSize != 18 || usage.TrashSize != 30 || usage.CalculatedAt <= 0 {
|
||||||
|
t.Fatalf("总量或子项不符: %+v", usage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorageDoesNotReturnPartialSuccess(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if _, err := scanStorage(context.Background(), root, filepath.Join(root, "missing")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("仓库缺失应报错: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := scanStorage(ctx, root, root); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("请求取消应停止扫描: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,13 +34,16 @@ func TestTrashRetentionSettingsValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrashClearAuthorization(t *testing.T) {
|
func TestStorageAndTrashClearAuthorization(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
f.write("a.md", "content")
|
f.write("a.md", "content")
|
||||||
decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil))
|
decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil))
|
||||||
for _, scope := range []string{"read", "write"} {
|
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
|
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}
|
headers := map[string]string{"Authorization": "Bearer " + token}
|
||||||
|
if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", headers); code != 403 {
|
||||||
|
t.Fatalf("存储统计仅接受会话: %d", code)
|
||||||
|
}
|
||||||
if scope == "read" {
|
if scope == "read" {
|
||||||
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
|
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
|
||||||
t.Fatalf("只读不能清空: %d", code)
|
t.Fatalf("只读不能清空: %d", code)
|
||||||
@@ -52,6 +55,13 @@ func TestTrashClearAuthorization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usage := decode[storageUsage](t, f.do(f.admin, "GET", "/api/v1/storage", "", nil))
|
||||||
|
if usage.TotalSize <= 0 || len(usage.Categories) != 6 {
|
||||||
|
t.Fatalf("会话统计: %+v", usage)
|
||||||
|
}
|
||||||
|
if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", nil); code != 401 {
|
||||||
|
t.Fatalf("匿名不能查看: %d", code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {
|
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './s
|
|||||||
import ShortcutSettings from './ShortcutSettings.vue'
|
import ShortcutSettings from './ShortcutSettings.vue'
|
||||||
import TokensPanel from './TokensPanel.vue'
|
import TokensPanel from './TokensPanel.vue'
|
||||||
import TrashPanel from './TrashPanel.vue'
|
import TrashPanel from './TrashPanel.vue'
|
||||||
|
import StoragePanel from './StoragePanel.vue'
|
||||||
import { toast, ws, type SettingsSection } from './workspace'
|
import { toast, ws, type SettingsSection } from './workspace'
|
||||||
|
|
||||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
||||||
@@ -204,6 +205,7 @@ async function changePassword() {
|
|||||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||||
<TokensPanel v-else-if="section === 'tokens'" />
|
<TokensPanel v-else-if="section === 'tokens'" />
|
||||||
<TrashPanel v-else-if="section === 'trash'" />
|
<TrashPanel v-else-if="section === 'trash'" />
|
||||||
|
<StoragePanel v-else-if="section === 'application'" />
|
||||||
<template v-else-if="section === 'about'">
|
<template v-else-if="section === 'about'">
|
||||||
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
|
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
|
||||||
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
|
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { api, type StorageUsage } from './api'
|
||||||
|
import SettingRow from './SettingRow.vue'
|
||||||
|
|
||||||
|
const usage = ref<StorageUsage | null>(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
let mounted = true
|
||||||
|
onBeforeUnmount(() => { mounted = false })
|
||||||
|
|
||||||
|
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
|
||||||
|
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
|
||||||
|
const segments = computed(() => {
|
||||||
|
let offset = 0
|
||||||
|
return (usage.value?.categories ?? []).map((category, index) => {
|
||||||
|
const percent = usage.value!.totalSize ? category.size / usage.value!.totalSize * 100 : 0
|
||||||
|
const segment = { ...category, percent, offset, color: colors[index] }
|
||||||
|
offset += percent
|
||||||
|
return segment
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function size(value: number): string {
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
|
||||||
|
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
|
||||||
|
}
|
||||||
|
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
|
||||||
|
async function scan() {
|
||||||
|
if (busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const next = await api.storage()
|
||||||
|
if (mounted) usage.value = next
|
||||||
|
} catch (cause) {
|
||||||
|
if (mounted) error.value = '统计失败:' + String(cause)
|
||||||
|
} finally { if (mounted) busy.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : usage ? '刷新' : '查看' }}</button></SettingRow>
|
||||||
|
<p class="rm-share-note">统计普通文件实际字节;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</p>
|
||||||
|
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
|
||||||
|
<div v-if="usage" class="rm-storage" :aria-busy="busy">
|
||||||
|
<div class="rm-storage-summary">
|
||||||
|
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比">
|
||||||
|
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
|
||||||
|
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
|
||||||
|
</svg>
|
||||||
|
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div>
|
||||||
|
</div>
|
||||||
|
<dl class="rm-storage-list">
|
||||||
|
<template v-for="segment in segments" :key="segment.id">
|
||||||
|
<div><dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small></dd></div>
|
||||||
|
<template v-if="segment.id === 'vault'">
|
||||||
|
<div class="rm-storage-sub"><dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
||||||
|
<div class="rm-storage-sub"><dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rm-storage-summary { display: flex; align-items: center; gap: 20px; margin: 20px 0; }
|
||||||
|
.rm-storage-summary svg { width: 128px; height: 128px; flex-shrink: 0; }
|
||||||
|
.rm-storage-summary strong { display: block; font-size: var(--rm-ui-font-heading); margin: 6px 0; }
|
||||||
|
.rm-storage-summary small { display: block; color: var(--rm-text-muted); }
|
||||||
|
.rm-storage-list { margin: 0; }
|
||||||
|
.rm-storage-list > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
|
||||||
|
.rm-storage-list dt { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.rm-storage-list i { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.rm-storage-list dd { margin: 0; text-align: right; }
|
||||||
|
.rm-storage-list small { display: inline-block; min-width: 52px; color: var(--rm-text-muted); }
|
||||||
|
.rm-storage-list .rm-storage-sub { padding-left: 18px; color: var(--rm-text-muted); }
|
||||||
|
@media (max-width: 480px) { .rm-storage-summary { gap: 12px; flex-wrap: wrap; } .rm-storage-summary svg { width: 100px; height: 100px; } }
|
||||||
|
</style>
|
||||||
@@ -57,6 +57,14 @@ export interface TrashItem {
|
|||||||
deletedAt: number
|
deletedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageUsage {
|
||||||
|
totalSize: number
|
||||||
|
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[]
|
||||||
|
assetsSize: number
|
||||||
|
trashSize: number
|
||||||
|
calculatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrashPurgeResult {
|
export interface TrashPurgeResult {
|
||||||
deleted: string[]
|
deleted: string[]
|
||||||
failed: { id: string; error: string }[]
|
failed: { id: string; error: string }[]
|
||||||
@@ -221,6 +229,7 @@ export const api = {
|
|||||||
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
|
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
|
||||||
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
|
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
|
||||||
|
|
||||||
|
storage: () => json<StorageUsage>(request('GET', '/api/v1/storage')),
|
||||||
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
|
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
|
||||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('设置查找与重置', () => {
|
|||||||
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
|
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
|
||||||
ws.settings = 'appearance'
|
ws.settings = 'appearance'
|
||||||
mount(SettingsDialog)
|
mount(SettingsDialog)
|
||||||
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
|
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9)
|
||||||
expect(host!.textContent).not.toContain('分享')
|
expect(host!.textContent).not.toContain('分享')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -320,7 +320,28 @@ describe('快捷键查找', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('回收站', () => {
|
describe('应用存储与回收站', () => {
|
||||||
|
const usage = { totalSize: 1024, categories: [{ id: 'vault' as const, size: 1024 }], assetsSize: 512, trashSize: 256, calculatedAt: 1700000000000 }
|
||||||
|
it('按需统计,防重复请求,刷新失败保留上次结果并展示错误', async () => {
|
||||||
|
let resolve!: (value: typeof usage) => void
|
||||||
|
const storage = vi.spyOn(api, 'storage').mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||||
|
ws.settings = 'application'
|
||||||
|
mount(SettingsDialog)
|
||||||
|
expect(storage).not.toHaveBeenCalled()
|
||||||
|
const button = host!.querySelector<HTMLButtonElement>('#rm-setting-storage button')!
|
||||||
|
button.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(button.disabled).toBe(true)
|
||||||
|
button.click()
|
||||||
|
expect(storage).toHaveBeenCalledTimes(1)
|
||||||
|
resolve(usage)
|
||||||
|
await vi.waitFor(() => expect(host!.textContent).toContain('1 KiB'))
|
||||||
|
expect(host!.textContent).toContain('附件')
|
||||||
|
storage.mockRejectedValueOnce(new Error('磁盘读取错误'))
|
||||||
|
button.click()
|
||||||
|
await vi.waitFor(() => expect(host!.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
|
||||||
|
expect(host!.textContent).toContain('1 KiB')
|
||||||
|
})
|
||||||
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
|
it('清空必须确认;防止重复操作,部分失败后重读并保留错误详情', async () => {
|
||||||
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
|
const item = { id: 'one', path: 'broken.md', dir: false, deletedAt: Date.now() }
|
||||||
vi.spyOn(api, 'trash').mockResolvedValue([item])
|
vi.spyOn(api, 'trash').mockResolvedValue([item])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { IconName } from './Icon.vue'
|
|||||||
import type { SettingsSection } from './workspace'
|
import type { SettingsSection } from './workspace'
|
||||||
|
|
||||||
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
|
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
|
||||||
|
{ id: 'application', title: '应用', icon: 'grid', description: '查看存储空间及各类文件占用' },
|
||||||
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
|
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
|
||||||
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
||||||
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
||||||
@@ -13,6 +14,7 @@ export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: Icon
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const SETTING_ITEMS = {
|
export const SETTING_ITEMS = {
|
||||||
|
'storage': { section: 'application', title: '存储空间', description: '按需统计笔记库、数据库、字体、主题、插件及其他文件的大小;包含外置笔记库。', keywords: '容量 占用 大小 附件 图片 回收站' },
|
||||||
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
|
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
|
||||||
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
|
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
|
||||||
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
|
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface Tab {
|
|||||||
conflictRev?: string
|
conflictRev?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
|
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about' | 'application'
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in new issue
Block a user