feat(ui): streamline workspace settings and remove sharing

Unify compact workspace controls and panel transitions, move directory actions into the context menu, and remove backlink UI.

Reorganize settings with searchable sections, per-setting resets, shortcut search, and accurate save feedback.

Remove sharing UI, APIs, authentication helpers, and storage code; provide a transactional cleanup script with retirement tests. Reject directories during orphan image cleanup and cover mixed-batch preservation.
This commit is contained in:
eezd committed 2026-10-24 12:32:23 +08:00
1 parent a716794e41
commit 19aab631ec
63 files changed
+1047 -3313

No files matched your search

+9 -7
View File
@@ -13,6 +13,7 @@ docker compose --env-file /docker/main/.env up -d --build --wait
``` ```
持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。 持久化数据位于 `deploy/data/`,与 `.dev-data/` 独立。首次打开网页时直接设置登录密码。
升级已有实例移除分享时,先备份数据与旧镜像、停止旧服务,再对 `rawmark.db` 执行一次 `scripts/remove-sharing.sql`,然后启动新版本。脚本删除分享表、专用密钥及分享快捷键;不会删除笔记或图片。旧分享页面和接口返回 404。回滚必须配套恢复数据库和旧镜像,不能只恢复旧程序。
宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。 宿主机 Nginx 配置为 `deploy/nginx/raw.134333.xyz.conf`,复用现有通配证书及 WebSocket 升级映射;安装到 `/etc/nginx/conf.d/134333.xyz/` 后,先执行 `nginx -t` 再重载。
域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。 域名 `raw.134333.xyz` 在 DNSPod 设置 CNAME 指向 `cdn.134333.xyz`(与 AlphaPulse 一致);还需在 Cloudflare for SaaS 的 `unino.top` 区域添加该自定义域名,并等待域名与托管证书均为 `active`。仅设置 CNAME 或旧区域中的 A 记录不足以完成 CDN 接入。访问地址为 `https://raw.134333.xyz`。修改端口时需同步端口登记和 Nginx 上游。
@@ -31,16 +32,17 @@ docker run -d --name rawmark \
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
- 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 - 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 - 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 16px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 - 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。公开阅读页仅提供分享范围内的导航、大纲、阅读进度、复制与打印,不加载编辑器。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观、编辑器和日记与模板分别管理,安装入口位于「关于」。
- 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 - 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。
- 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
- 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。 - 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
-1
View File
@@ -83,7 +83,6 @@ func run(addr string) error {
srv := api.New(v, am, st, hub, idx) srv := api.New(v, am, st, hub, idx)
srv.ThemesDir = filepath.Join(dataDir, "themes") srv.ThemesDir = filepath.Join(dataDir, "themes")
srv.PluginsDir = filepath.Join(dataDir, "plugins") srv.PluginsDir = filepath.Join(dataDir, "plugins")
go srv.FollowMoves(ctx)
httpServer := &http.Server{ httpServer := &http.Server{
Addr: addr, Addr: addr,
Handler: srv.Handler(web.Dist()), Handler: srv.Handler(web.Dist()),
+2 -12
View File
@@ -54,7 +54,7 @@ const (
public access = iota public access = iota
// 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口) // 登录会话或 API 令牌(只读令牌可用 GET 和只读导出接口)
authed authed
// 只允许登录会话:管理分享与 API 令牌,避免令牌给自己扩权 // 只允许登录会话:管理 API 令牌,避免令牌给自己扩权
sessionOnly sessionOnly
) )
@@ -106,17 +106,9 @@ func (s *Server) routes() []route {
{"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData}, {"PUT /api/v1/plugins/{id}/data", sessionOnly, s.putPluginData},
{"GET /api/v1/themes", authed, s.listThemes}, {"GET /api/v1/themes", authed, s.listThemes},
{"GET /api/v1/themes/{id}", authed, s.getTheme}, {"GET /api/v1/themes/{id}", authed, s.getTheme},
{"GET /api/v1/shares", sessionOnly, s.listShares},
{"POST /api/v1/shares", sessionOnly, s.createShare},
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
{"GET /api/v1/tokens", sessionOnly, s.listTokens}, {"GET /api/v1/tokens", sessionOnly, s.listTokens},
{"POST /api/v1/tokens", sessionOnly, s.createToken}, {"POST /api/v1/tokens", sessionOnly, s.createToken},
{"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken}, {"DELETE /api/v1/tokens/{id}", sessionOnly, s.deleteToken},
{"GET /api/v1/public/shares/{token}", public, s.publicShare},
{"GET /api/v1/public/shares/{token}/appearance", public, s.publicAppearance},
{"POST /api/v1/public/shares/{token}/unlock", public, s.unlockShare},
{"GET /api/v1/public/shares/{token}/assets/{path...}", public, s.publicShareAsset},
} }
} }
@@ -355,9 +347,7 @@ func (s *Server) move(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return return
} }
rewritten, err := s.vault.Move(body.From, body.To, func() error { rewritten, err := s.vault.Move(body.From, body.To)
return s.store.MoveShares(body.From, body.To)
})
if err != nil { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
return return
-287
View File
@@ -3,14 +3,12 @@ package api
import ( import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -65,36 +63,6 @@ func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
} }
} }
func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
dir := t.TempDir()
v, err := vault.Open(filepath.Join(dir, "vault"))
if err != nil {
t.Fatal(err)
}
if _, err := v.Write("from.md", []byte("原文"), vault.Precondition{}); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
s := New(v, nil, st, nil, nil)
res := httptest.NewRecorder()
s.move(res, httptest.NewRequest("POST", "/api/v1/move", strings.NewReader(`{"from":"from.md","to":"to.md"}`)))
if res.Code != 500 {
t.Fatalf("数据库失败应返回错误: %d", res.Code)
}
if content, _, err := v.Read("from.md"); err != nil || string(content) != "原文" {
t.Fatalf("原笔记未恢复: %q %v", content, err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "to.md")); !os.IsNotExist(err) {
t.Fatalf("失败后目标仍存在: %v", err)
}
}
func TestEventsRejectRevokedCredentials(t *testing.T) { func TestEventsRejectRevokedCredentials(t *testing.T) {
for _, kind := range []string{"session", "token"} { for _, kind := range []string{"session", "token"} {
t.Run(kind, func(t *testing.T) { t.Run(kind, func(t *testing.T) {
@@ -282,260 +250,6 @@ func TestCompressedManifestContentType(t *testing.T) {
} }
} }
func TestShareAccess(t *testing.T) {
f := newFixture(t)
f.write("目录/笔记.md", "# 标题\n\n![](笔记.assets/图.png)\n")
f.write("目录/笔记.assets/图.png", "PNG")
f.write("目录/其他.png", "OTHER")
f.write("目录/其他.assets/x.png", "X")
res := f.do(f.admin, "POST", "/api/v1/shares", `{"path":"目录/笔记.md","password":"访问密码","expiresIn":0}`, nil)
if res.StatusCode != 200 {
t.Fatalf("创建分享: %d", res.StatusCode)
}
sh := decode[shareView](t, res)
if !sh.HasPassword || !sh.Exists || sh.ExpiresAt != 0 || len(sh.Token) < 20 {
t.Fatalf("分享信息: %+v", sh)
}
base := "/api/v1/public/shares/" + sh.Token
guest := newClient()
if code := f.status(guest, "GET", base, "", nil); code != 401 {
t.Errorf("未输入密码应为 401,得到 %d", code)
}
if code := f.status(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil); code != 401 {
t.Errorf("未输入密码读图片应为 401,得到 %d", code)
}
if code := f.status(guest, "POST", base+"/unlock", `{"password":"错的"}`, nil); code != 401 {
t.Errorf("错误密码应为 401,得到 %d", code)
}
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
t.Fatalf("正确密码: %d", code)
}
got := decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
if got["title"] != "笔记" || got["content"] != "# 标题\n\n![](笔记.assets/图.png)\n" || got["path"] != nil {
t.Errorf("分享内容: %v", got)
}
res = f.do(guest, "GET", base+"/assets/笔记.assets/图.png", "", nil)
if b, _ := io.ReadAll(res.Body); res.StatusCode != 200 || string(b) != "PNG" {
t.Errorf("本篇图片: %d %q", res.StatusCode, b)
}
for _, p := range []string{"其他.png", "笔记.assets/../其他.png", "其他.assets/x.png", "../目录/其他.png", "笔记.md"} {
if code := f.status(guest, "GET", base+"/assets/"+p, "", nil); code != 404 {
t.Errorf("图片目录之外的 %s 应为 404,得到 %d", p, code)
}
}
// 改名后分享跟随
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"目录","to":"新目录"}`, nil); code != 200 {
t.Fatalf("移动文件夹: %d", code)
}
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"新目录/笔记.md","to":"新目录/改名.md"}`, nil); code != 200 {
t.Fatalf("改名: %d", code)
}
got = decode[map[string]any](t, f.do(guest, "GET", base, "", nil))
if got["title"] != "改名" || !strings.Contains(got["content"].(string), "改名.assets/") {
t.Errorf("改名后分享内容: %v", got)
}
if code := f.status(guest, "GET", base+"/assets/改名.assets/图.png", "", nil); code != 200 {
t.Errorf("改名后图片: %d", code)
}
// 其他分享的凭据不能用于本分享
res = f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md"}`, nil)
if res.StatusCode != 404 {
t.Errorf("分享不存在的笔记应为 404,得到 %d", res.StatusCode)
}
f.write("新目录/其他.md", "其他")
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"新目录/其他.md","password":"另一个"}`, nil))
if code := f.status(guest, "GET", "/api/v1/public/shares/"+other.Token, "", nil); code != 401 {
t.Errorf("凭据只对对应分享有效,得到 %d", code)
}
// 列表、删除笔记、撤销
list := decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares?path=新目录/改名.md", "", nil))
if len(list.Shares) != 1 || list.Shares[0].Path != "新目录/改名.md" {
t.Errorf("按笔记筛选: %+v", list.Shares)
}
if code := f.status(f.admin, "DELETE", "/api/v1/files/新目录/改名.md", "", nil); code != 200 {
t.Fatal(code)
}
list = decode[struct{ Shares []shareView }](t, f.do(f.admin, "GET", "/api/v1/shares", "", nil))
if len(list.Shares) != 2 || list.Shares[1].Exists {
t.Errorf("笔记删除后分享标记为不存在: %+v", list.Shares)
}
if code := f.status(guest, "GET", base, "", nil); code != 404 {
t.Errorf("笔记已删除应为 404,得到 %d", code)
}
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil); code != 200 {
t.Fatal(code)
}
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 404 {
t.Errorf("撤销后应为 404,得到 %d", code)
}
if code := f.status(newClient(), "GET", "/api/v1/shares", "", nil); code != 401 {
t.Errorf("未登录不能管理分享,得到 %d", code)
}
}
func TestShareRandomToken(t *testing.T) {
f := newFixture(t)
name := "计划 #1? 50% +;\""
p := "目录/" + name + ".md"
f.write(p, "正文")
f.write("目录/"+name+".assets/图.png", "PNG")
body, err := json.Marshal(map[string]string{"path": p, "password": "访问密码"})
if err != nil {
t.Fatal(err)
}
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
random, err := base64.RawURLEncoding.DecodeString(sh.Token)
if err != nil || len(random) != 16 {
t.Fatal("分享标识应为 128 位随机字符,不包含文件名称或目录")
}
other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil))
if other.Token == sh.Token {
t.Fatal("同一笔记的分享链接不能重复")
}
base := "/api/v1/public/shares/" + url.PathEscape(sh.Token)
guest := newClient()
if code := f.status(guest, "GET", base, "", nil); code != 401 {
t.Fatalf("未解锁应拒绝访问,得到 %d", code)
}
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
t.Fatalf("解锁失败:%d", code)
}
if code := f.status(guest, "GET", base, "", nil); code != 200 {
t.Fatalf("带保留字符的链接应可读取正文,得到 %d", code)
}
asset := base + "/assets/" + url.PathEscape(name+".assets") + "/图.png"
if code := f.status(guest, "GET", asset, "", nil); code != 200 {
t.Fatalf("解锁凭据应覆盖图片路径,得到 %d", code)
}
if code := f.status(guest, "GET", "/api/v1/public/shares/"+url.PathEscape(other.Token), "", nil); code != 401 {
t.Fatalf("凭据不能解锁另一分享,得到 %d", code)
}
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+url.PathEscape(sh.Token), "", nil); code != 200 {
t.Fatalf("撤销带保留字符的链接失败:%d", code)
}
if code := f.status(guest, "GET", base, "", nil); code != 404 {
t.Fatalf("撤销后应返回 404,得到 %d", code)
}
}
func TestFolderShareTracksItsCurrentContentsAndSubject(t *testing.T) {
f := newFixture(t)
f.write("公开/子目录/文章.md", "第一版")
f.write("公开/子目录/文章.assets/图.png", "PNG")
f.write("公开/资料.txt", "资料")
f.write("公开/.私密.md", "不能公开")
f.write("公开/.git/config", "不能公开")
f.write("其他/私密.md", "不能公开")
if err := os.Symlink(filepath.Join(f.root, "其他"), filepath.Join(f.root, "公开", "别名")); err != nil {
t.Fatal(err)
}
folder := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开","password":"访问密码"}`, nil))
note := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"公开/子目录/文章.md"}`, nil))
base := "/api/v1/public/shares/" + folder.Token
noteBase := "/api/v1/public/shares/" + note.Token
guest := newClient()
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 401 {
t.Fatalf("目录内的文章也必须解锁,得到 %d", code)
}
if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 {
t.Fatal(code)
}
type directory struct {
Title string
Dir bool
Entries []vault.Entry
}
listed := decode[directory](t, f.do(guest, "GET", base, "", nil))
if listed.Title != "公开" || !listed.Dir || len(listed.Entries) != 5 {
t.Fatalf("应递归列出全部普通文件和子目录: %+v", listed)
}
for _, e := range listed.Entries {
if strings.Contains(e.Path, "公开/") || strings.Contains(e.Path, "私密") || strings.Contains(e.Path, "别名") || strings.Contains(e.Path, ".git") {
t.Fatalf("目录列表越界: %s", e.Path)
}
}
f.write("公开/子目录/文章.md", "最新正文")
article := decode[map[string]any](t, f.do(guest, "GET", base+"?path=子目录/文章.md", "", nil))
if article["content"] != "最新正文" || article["path"] != nil {
t.Fatalf("应提供当前正文且不暴露仓库路径: %v", article)
}
for _, rel := range []string{"../其他/私密.md", ".私密.md", ".git/config", "别名/私密.md"} {
if code := f.status(guest, "GET", base+"?path="+url.QueryEscape(rel), "", nil); code != 404 {
t.Fatalf("不允许读取 %s,得到 %d", rel, code)
}
if code := f.status(guest, "GET", base+"/assets/"+url.PathEscape(rel), "", nil); code != 404 {
t.Fatalf("资源接口不允许读取 %s,得到 %d", rel, code)
}
}
if code := f.status(guest, "GET", base+"/assets/子目录/文章.assets/图.png", "", nil); code != 200 {
t.Fatalf("目录中的图片不可读: %d", code)
}
res := f.do(guest, "GET", base+"/assets/资料.txt?download=1", "", nil)
content, err := io.ReadAll(res.Body)
if err != nil || res.StatusCode != 200 || string(content) != "资料" || !strings.HasPrefix(res.Header.Get("Content-Disposition"), "attachment;") {
t.Fatalf("普通文件下载失败: %d %v", res.StatusCode, err)
}
f.write("公开/新增.md", "新内容")
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
if len(listed.Entries) != 6 {
t.Fatal("新增文件应自动进入目录分享")
}
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"公开","to":"移动后的目录"}`, nil); code != 200 {
t.Fatal(code)
}
listed = decode[directory](t, f.do(guest, "GET", base, "", nil))
if listed.Title != "移动后的目录" {
t.Fatal("目录主体移动后原分享应跟随")
}
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"移动后的目录/子目录/文章.md","to":"其他/移出.md"}`, nil); code != 200 {
t.Fatal(code)
}
if code := f.status(guest, "GET", base+"?path=子目录/文章.md", "", nil); code != 404 {
t.Fatalf("移出的文章不能继续通过目录分享读取: %d", code)
}
article = decode[map[string]any](t, f.do(guest, "GET", noteBase, "", nil))
if article["content"] != "最新正文" || article["title"] != "移出" {
t.Fatal("单篇分享应跟随文章移出目录")
}
file := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"移动后的目录/资料.txt"}`, nil))
if code := f.status(guest, "GET", "/api/v1/public/shares/"+file.Token+"/assets/", "", nil); code != 200 {
t.Fatalf("应能分享普通文件: %d", code)
}
if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+folder.Token, "", nil); code != 200 {
t.Fatal(code)
}
if code := f.status(guest, "GET", base+"/assets/资料.txt", "", nil); code != 404 {
t.Fatalf("撤销后资源也必须失效: %d", code)
}
}
func TestShareWithoutPasswordAndExpiry(t *testing.T) {
f := newFixture(t)
f.write("a.md", "公开")
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"a.md","expiresIn":3600}`, nil))
if sh.HasPassword || sh.ExpiresAt-time.Now().UnixMilli() < 3590_000 {
t.Fatalf("分享信息: %+v", sh)
}
if code := f.status(newClient(), "GET", "/api/v1/public/shares/"+sh.Token, "", nil); code != 200 {
t.Errorf("无密码分享直接可读,得到 %d", code)
}
if err := f.store.CreateShare(store.Share{Token: "expired", Path: "a.md", CreatedAt: 1, ExpiresAt: time.Now().Add(-time.Second).UnixMilli()}); err != nil {
t.Fatal(err)
}
if code := f.status(newClient(), "GET", "/api/v1/public/shares/expired", "", nil); code != 404 {
t.Errorf("过期分享应为 404,得到 %d", code)
}
for _, body := range []string{`{"path":"../a.md"}`, `{"path":"a.md","expiresIn":-1}`} {
if code := f.status(f.admin, "POST", "/api/v1/shares", body, nil); code != 400 {
t.Errorf("%s 应为 400,得到 %d", body, code)
}
}
}
func TestAPITokenScopes(t *testing.T) { func TestAPITokenScopes(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("a.md", "内容") f.write("a.md", "内容")
@@ -561,7 +275,6 @@ func TestAPITokenScopes(t *testing.T) {
{"只读令牌不能写", read, "PUT", "/api/v1/files/a.md", "改", 403}, {"只读令牌不能写", read, "PUT", "/api/v1/files/a.md", "改", 403},
{"读写令牌可写", write, "PUT", "/api/v1/files/b.md", "新", 200}, {"读写令牌可写", write, "PUT", "/api/v1/files/b.md", "新", 200},
{"读写令牌也不能管理令牌", write, "GET", "/api/v1/tokens", "", 403}, {"读写令牌也不能管理令牌", write, "GET", "/api/v1/tokens", "", 403},
{"令牌不能管理分享", write, "POST", "/api/v1/shares", `{"path":"a.md"}`, 403},
{"无效令牌", "rm_wrong", "GET", "/api/v1/tree", "", 401}, {"无效令牌", "rm_wrong", "GET", "/api/v1/tree", "", 401},
{"格式不符的令牌", "abc", "GET", "/api/v1/tree", "", 401}, {"格式不符的令牌", "abc", "GET", "/api/v1/tree", "", 401},
} }
+2 -28
View File
@@ -1,11 +1,6 @@
package api package api
import ( import "net/http"
"errors"
"net/http"
"rawmark/internal/vault"
"time"
)
func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
@@ -18,28 +13,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
writeError(w, 400, "请选择 1~1000 张待清理图片") writeError(w, 400, "请选择 1~1000 张待清理图片")
return return
} }
result, err := s.vault.CleanupImages(body.Paths, func() ([]string, error) { result, err := s.vault.CleanupImages(body.Paths)
shares, err := s.store.Shares()
if err != nil {
return nil, err
}
scopes := []string{}
now := time.Now().UnixMilli()
for _, share := range shares {
if share.ExpiresAt != 0 && share.ExpiresAt <= now {
continue
}
for _, item := range share.Items {
if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) {
continue
} else if err != nil {
return nil, err
}
scopes = append(scopes, item.Path)
}
}
return scopes, nil
})
if err != nil { if err != nil {
vaultError(w, r, err) vaultError(w, r, err)
return return
+30 -17
View File
@@ -1,35 +1,48 @@
package api package api
import ( import (
"io"
"net/http" "net/http"
"rawmark/internal/store"
"rawmark/internal/vault" "rawmark/internal/vault"
"testing" "testing"
"time"
) )
func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) { func TestImageCleanupHonorsReferences(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("assets/active.png", "active") f.write("assets/orphan.png", "orphan")
f.write("assets/expired.png", "expired") f.write("assets/unused.png", "unused")
f.write("assets/used.png", "used") f.write("assets/used.png", "used")
f.write("note.md", "[image](assets/used.png)") f.write("note.md", "[image](assets/used.png)")
for _, share := range []store.Share{ if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/unused.png"]}`, nil); code != http.StatusUnauthorized {
{Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()},
{Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()},
} {
if err := f.store.CreateShare(share); err != nil {
t.Fatal(err)
}
}
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized {
t.Fatal(code) t.Fatal(code)
} }
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil)) result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/orphan.png","assets/unused.png","assets/used.png"]}`, nil))
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 { if len(result.Deleted) != 2 || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/used.png" {
t.Fatalf("引用或分享保护不符:%+v", result) t.Fatalf("引用保护不符:%+v", result)
} }
if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized { if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized {
t.Fatal(code) t.Fatal(code)
} }
} }
func TestImageCleanupRejectsDirectories(t *testing.T) {
f := newFixture(t)
f.write("assets/folder.png/keep.md", "保留目录内的笔记")
f.write("assets/orphan.png", "image")
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/folder.png","assets/orphan.png"]}`, nil)
if res.StatusCode != http.StatusOK {
t.Fatalf("清理应逐项汇报:%d", res.StatusCode)
}
result := decode[vault.CleanupResult](t, res)
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "不是图片文件" {
t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result)
}
kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil)
if kept.StatusCode != http.StatusOK {
t.Fatalf("目录内笔记被误移:%d", kept.StatusCode)
}
got, err := io.ReadAll(kept.Body)
if err != nil || string(got) != "保留目录内的笔记" {
t.Fatalf("目录内笔记内容被改变:%q %v", got, err)
}
}
-94
View File
@@ -1,94 +0,0 @@
package api
import (
"fmt"
"net/http"
"strings"
"testing"
)
func TestCollectionShareFollowsEachSubjectAndKeepsItsScope(t *testing.T) {
f := newFixture(t)
f.write("私有/文章.md", "最新正文")
f.write("私有/秘密.md", "不能公开")
f.write("文件夹/内部.md", "内部正文")
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["文件夹/内部.md","私有/文章.md","文件夹"]}`, nil))
if len(sh.Items) != 2 || sh.Items[0].Path != "文件夹" {
t.Fatalf("未消除重叠:%+v", sh)
}
base := "/api/v1/public/shares/" + sh.Token
guest := newClient()
root := decode[struct {
Collection bool
Items []struct {
ID int
Title string
Exists bool
}
}](t, f.do(guest, "GET", base, "", nil))
if !root.Collection || len(root.Items) != 2 {
t.Fatalf("集合首页不符:%+v", root)
}
for _, item := range root.Items {
if strings.Contains(item.Title, "私有/") {
t.Fatal("公开了主体的父目录")
}
}
for _, endpoint := range []string{base + "?path=秘密.md", base + "?item=99", base + "?item=1&path=秘密.md", base + "/assets/../秘密.md?item=1", base + "/assets/秘密.md?item=1"} {
response := f.do(guest, "GET", endpoint, "", nil)
response.Body.Close()
if response.StatusCode == http.StatusOK {
t.Fatalf("越权访问成功:%s", endpoint)
}
}
if code := f.status(f.admin, "POST", "/api/v1/move", `{"from":"私有/文章.md","to":"移动后.md"}`, nil); code != 200 {
t.Fatalf("移动失败:%d", code)
}
content := decode[struct{ Content string }](t, f.do(guest, "GET", base+"?item=1", "", nil))
if content.Content != "最新正文" {
t.Fatalf("未跟随主体:%+v", content)
}
// 内部文件不是独立主体,移出文件夹后不能继续从该主体读取。
f.do(f.admin, "POST", "/api/v1/move", `{"from":"文件夹/内部.md","to":"移出.md"}`, nil).Body.Close()
if code := f.status(guest, "GET", base+"?item=0&path=内部.md", "", nil); code != 404 {
t.Fatalf("移出后仍公开:%d", code)
}
f.do(f.admin, "DELETE", "/api/v1/files/移动后.md", "", nil).Body.Close()
root = decode[struct {
Collection bool
Items []struct {
ID int
Title string
Exists bool
}
}](t, f.do(guest, "GET", base, "", nil))
if !root.Items[0].Exists || root.Items[1].Exists {
t.Fatalf("部分删除状态不符:%+v", root)
}
if code := f.status(guest, "GET", base+"?item=0", "", nil); code != 200 {
t.Fatalf("部分删除影响其余主体:%d", code)
}
}
func TestCollectionPasswordAssetsAndRevocation(t *testing.T) {
f := newFixture(t)
f.write("a.md", "文章")
f.write("目录/b.md", "文章二")
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"paths":["a.md","目录"],"password":"访问密码","expiresIn":86400}`, nil))
base := "/api/v1/public/shares/" + sh.Token
guest := newClient()
if code := f.status(guest, "GET", base, "", nil); code != 401 {
t.Fatal(code)
}
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil).Body.Close()
if code := f.status(guest, "GET", base+"?item=1&path=b.md", "", nil); code != 200 {
t.Fatal(code)
}
if code := f.status(guest, "GET", base+"/assets/b.md?item=1", "", nil); code != 200 {
t.Fatal(code)
}
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil).Body.Close()
if code := f.status(guest, "GET", fmt.Sprintf("%s?item=1", base), "", nil); code != 404 {
t.Fatal(code)
}
}
+3 -591
View File
@@ -3,7 +3,7 @@
"info": { "info": {
"title": "RawMark API", "title": "RawMark API",
"version": "1", "version": "1",
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。" "description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
}, },
"servers": [ "servers": [
{ {
@@ -617,7 +617,7 @@
"tags": [ "tags": [
"笔记" "笔记"
], ],
"description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。分享链接随主体更新。不改写其他笔记指向笔记或文件夹的链接。", "description": "笔记的 .assets 目录随之移动,改名时改写本篇图片目录链接;图片移动或改名保持扩展名,并更新仓库笔记中的本地引用。维护笔记中的本地入链及相对出链。",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -1114,193 +1114,6 @@
} }
} }
}, },
"/shares": {
"get": {
"summary": "分享列表",
"tags": [
"分享"
],
"security": [
{
"cookie": []
}
],
"parameters": [
{
"name": "path",
"in": "query",
"description": "只列出该笔记的分享",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "分享",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"shares": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Share"
}
}
}
}
}
}
}
}
},
"post": {
"summary": "创建文件或文件夹的只读分享",
"tags": [
"分享"
],
"security": [
{
"cookie": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "仓库内的文件或文件夹;文件夹递归公开当前所有普通文件和子目录,含后续新增或移入的内容"
},
"password": {
"type": "string",
"description": "访问密码,留空为不设密码"
},
"expiresIn": {
"type": "integer",
"description": "有效时长(秒),0 为永久"
},
"paths": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
},
"description": "与 path 二选一;重叠路径只保留外层目录"
}
},
"required": [],
"oneOf": [
{
"required": [
"path"
],
"not": {
"required": [
"paths"
]
}
},
{
"required": [
"paths"
],
"not": {
"required": [
"path"
]
}
}
]
}
}
}
},
"responses": {
"200": {
"description": "新分享,token 为 128 位随机数的 URL 安全编码,访问地址为 /s/{token};移动分享主体不改变链接",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Share"
}
}
}
},
"400": {
"description": "路径或参数无效",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "笔记不存在",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"description": "创建一个随机只读链接。path 创建单主体分享;paths 创建集合。内容实时读取,主体移动后跟随,部分主体删除不影响其余项。"
}
},
"/shares/{token}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"delete": {
"summary": "撤销分享",
"tags": [
"分享"
],
"security": [
{
"cookie": []
}
],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ok"
}
}
}
},
"404": {
"description": "不存在",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/tokens": { "/tokens": {
"get": { "get": {
"summary": "API 令牌列表", "summary": "API 令牌列表",
@@ -1446,261 +1259,6 @@
} }
} }
}, },
"/public/shares/{token}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "path",
"in": "query",
"description": "文件夹分享中相对主体的子路径;省略时读取主体本身,不允许跳出分享范围",
"schema": {
"type": "string"
}
}
],
"get": {
"summary": "访客读取分享主体或其目录中的当前内容",
"tags": [
"分享(公开)"
],
"security": [
{}
],
"responses": {
"200": {
"description": "目录返回 dir=true 及递归 entries(路径相对此目录);Markdown 返回 dir=false、content 与 updatedAt;其他文件返回 dir=false,内容经 assets 接口读取或下载",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"content": {
"type": "string"
},
"updatedAt": {
"type": "integer"
},
"dir": {
"type": "boolean"
},
"entries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Entry"
}
}
}
}
}
}
},
"401": {
"description": "需要访问密码",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"passwordRequired": {
"type": "boolean"
}
}
}
}
}
},
"404": {
"description": "分享不存在、已过期、主体已删除或子路径不在分享范围内",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"parameters": [
{
"name": "item",
"in": "query",
"schema": {
"type": "integer"
},
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
}
],
"description": "单主体沿用原响应。集合首页未指定 item 时返回 collection=true 及 items(仅含 id、title、dir、exists,不暴露仓库父路径);指定 item 后读取该主体,path 为其内部相对路径。"
}
},
"/public/shares/{token}/unlock": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"summary": "输入分享密码,成功后设置访客 Cookie",
"tags": [
"分享(公开)"
],
"security": [
{}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
},
"required": [
"password"
]
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Ok"
}
}
}
},
"401": {
"description": "密码错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "分享不存在或已失效",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "失败次数过多",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/public/shares/{token}/assets/{path}": {
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "path",
"in": "path",
"required": true,
"description": "文件夹分享中相对主体的文件路径;单文件分享仅允许其 .assets 图片,空路径读取该文件本身",
"schema": {
"type": "string"
}
},
{
"name": "download",
"in": "query",
"description": "设为 1 时以附件下载;其他情况下以带沙箱 CSP 的原始文件返回",
"schema": {
"type": "string",
"enum": [
"1"
]
}
}
],
"get": {
"summary": "访客读取或下载分享范围内的普通文件",
"tags": [
"分享(公开)"
],
"security": [
{}
],
"responses": {
"200": {
"description": "文件内容",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"404": {
"description": "不存在或不在允许范围内",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"parameters": [
{
"name": "item",
"in": "query",
"schema": {
"type": "integer"
},
"description": "集合分享的稳定成员标识;单主体分享可省略。子路径及附件始终限制在该主体原有范围内。"
}
]
}
},
"/themes": { "/themes": {
"get": { "get": {
"summary": "列出主题(含内置默认主题)", "summary": "列出主题(含内置默认主题)",
@@ -1774,98 +1332,6 @@
} }
} }
}, },
"/public/shares/{token}/appearance": {
"get": {
"summary": "读取有效分享使用的外观配置",
"description": "仅返回明暗模式、字号、正文宽度、主题 CSS 和启用的 CSS 片段;密码分享需先解锁,不返回其他设置,不加载插件。",
"tags": [
"分享"
],
"security": [
{}
],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "公开的外观配置",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"colorMode": {
"type": "string",
"enum": [
"system",
"light",
"dark"
]
},
"uiFontSize": {
"type": "integer",
"minimum": 14,
"maximum": 20,
"default": 16,
"description": "界面字号,与正文排版字号独立"
},
"fontSize": {
"type": "integer"
},
"contentWidth": {
"type": "integer"
},
"themeCSS": {
"type": "string"
},
"cssSnippets": {
"type": [
"array",
"null"
],
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"css": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
},
"theme": {
"type": "string"
}
}
}
}
}
},
"401": {
"description": "需要访问密码"
},
"404": {
"description": "分享不存在、已过期或已撤销"
}
}
}
},
"/plugins": { "/plugins": {
"get": { "get": {
"summary": "列出已安装插件及启用状态", "summary": "列出已安装插件及启用状态",
@@ -2468,7 +1934,7 @@
}, },
"/assets/cleanup": { "/assets/cleanup": {
"post": { "post": {
"summary": "重新核对全库引用和有效分享后,将孤立图片移入回收站", "summary": "重新核对全库引用后,将孤立图片移入回收站",
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -2614,60 +2080,6 @@
} }
} }
}, },
"Share": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "新分享使用完全随机的 URL 安全标识,不包含文件名称或目录"
},
"path": {
"type": "string",
"description": "单主体分享的路径;集合分享不返回此字段"
},
"createdAt": {
"type": "integer"
},
"expiresAt": {
"type": "integer",
"description": "Unix 毫秒,0 为永久"
},
"hasPassword": {
"type": "boolean"
},
"exists": {
"type": "boolean",
"description": "分享的文件或文件夹是否仍存在"
},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"id",
"path",
"exists"
],
"properties": {
"id": {
"type": "integer",
"description": "稳定的分享成员标识"
},
"path": {
"type": "string"
},
"exists": {
"type": "boolean"
}
}
}
}
},
"required": [
"items"
]
},
"APIToken": { "APIToken": {
"type": "object", "type": "object",
"properties": { "properties": {
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
)
func TestRemovedShareAPIsReturnNotFound(t *testing.T) {
f := newFixture(t)
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"automation","scope":"write"}`, nil)).Token
for _, caller := range []struct {
name string
client *http.Client
headers map[string]string
}{
{"guest", newClient(), nil},
{"session", f.admin, nil},
{"token", newClient(), map[string]string{"Authorization": "Bearer " + token}},
} {
t.Run(caller.name, func(t *testing.T) {
for _, endpoint := range []struct{ method, path string }{
{"GET", "/api/v1/shares"},
{"POST", "/api/v1/shares"},
{"DELETE", "/api/v1/shares/old-token"},
{"GET", "/api/v1/public/shares/old-token"},
{"HEAD", "/api/v1/public/shares/old-token"},
{"GET", "/api/v1/public/shares/old-token/appearance"},
{"POST", "/api/v1/public/shares/old-token/unlock"},
{"GET", "/api/v1/public/shares/old-token/assets/image.png"},
} {
if code := f.status(caller.client, endpoint.method, endpoint.path, "{}", caller.headers); code != http.StatusNotFound {
t.Errorf("%s %s:得到 %d,期望 404", endpoint.method, endpoint.path, code)
}
}
})
}
}
func TestRemovedSharePagesDoNotFallBackToApplication(t *testing.T) {
handler := newStaticHandler(fstest.MapFS{
"index.html": {Data: []byte("app entry")},
"assets/app.js": {Data: []byte("app script")},
})
for _, method := range []string{"GET", "HEAD"} {
for _, path := range []string{"/s", "/s/", "/s/old-token", "/s/old-token/child", "/share.html"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(method, path, nil))
if recorder.Code != http.StatusNotFound {
t.Errorf("%s %s:得到 %d,期望 404", method, path, recorder.Code)
}
}
}
for _, path := range []string{"/", "/assets/app.js"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest("GET", path, nil))
if recorder.Code != http.StatusOK {
t.Errorf("%s:应用资源无法读取:%d", path, recorder.Code)
}
}
}
-426
View File
@@ -1,426 +0,0 @@
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"mime"
"net/http"
"path"
"sort"
"strconv"
"strings"
"time"
"rawmark/internal/auth"
"rawmark/internal/events"
"rawmark/internal/store"
"rawmark/internal/vault"
)
// 输入分享密码后的凭据 Cookie,按分享限定 Path,同一浏览器可同时打开多个分享
const shareCookieName = "rawmark_share"
// 设有密码的永久分享,访客凭据的有效期
const shareCookieTTL = 30 * 24 * time.Hour
type shareItemView struct {
store.ShareItem
Exists bool `json:"exists"`
}
type shareView struct {
store.Share
Items []shareItemView `json:"items"`
HasPassword bool `json:"hasPassword"`
Exists bool `json:"exists"`
}
func (s *Server) viewOf(sh store.Share) (shareView, error) {
view := shareView{Share: sh, HasPassword: sh.PasswordHash != "", Items: []shareItemView{}}
for _, item := range sh.Items {
_, err := s.vault.Stat(item.Path)
if err != nil && !errors.Is(err, vault.ErrNotFound) {
return view, err
}
exists := err == nil
view.Exists = view.Exists || exists
view.Items = append(view.Items, shareItemView{ShareItem: item, Exists: exists})
}
return view, nil
}
// listShares 列出全部分享;带 ?path= 时只列出该笔记的
func (s *Server) listShares(w http.ResponseWriter, r *http.Request) {
all, err := s.store.Shares()
if err != nil {
internalError(w, r, err)
return
}
filter := r.URL.Query().Get("path")
out := []shareView{}
for _, sh := range all {
matches := filter == ""
for _, item := range sh.Items {
if item.Path == filter {
matches = true
}
}
if matches {
view, err := s.viewOf(sh)
if err != nil {
vaultError(w, r, err)
return
}
out = append(out, view)
}
}
writeJSON(w, 200, map[string]any{"shares": out})
}
func (s *Server) createShare(w http.ResponseWriter, r *http.Request) {
var body struct {
Path string
Paths []string
Password string
// 有效时长(秒),0 为永久
ExpiresIn int64
}
if !readJSON(w, r, &body) {
return
}
if body.Path != "" && len(body.Paths) != 0 {
writeError(w, 400, "path 和 paths 不能同时指定")
return
}
paths := body.Paths
if body.Path != "" {
paths = []string{body.Path}
}
if len(paths) == 0 {
writeError(w, 400, "请选择分享主体")
return
}
sort.Strings(paths)
roots := map[string]bool{}
items := []store.ShareItem{}
for _, p := range paths {
if _, err := vault.CleanPath(p); err != nil {
writeError(w, 400, "分享路径无效")
return
}
if _, err := s.vault.Stat(p); err != nil {
vaultError(w, r, err)
return
}
covered := roots[p]
for parent := path.Dir(p); parent != "."; parent = path.Dir(parent) {
if roots[parent] {
covered = true
break
}
}
if !covered {
roots[p] = true
items = append(items, store.ShareItem{ID: len(items), Path: p})
}
}
if body.ExpiresIn < 0 {
writeError(w, 400, "有效期无效")
return
}
now := time.Now()
sh := store.Share{
Token: auth.NewShareToken(),
Items: items,
PasswordHash: auth.HashSharePassword(body.Password),
CreatedAt: now.UnixMilli(),
}
if body.ExpiresIn > 0 {
sh.ExpiresAt = now.Add(time.Duration(body.ExpiresIn) * time.Second).UnixMilli()
}
if err := s.store.CreateShare(sh); err != nil {
internalError(w, r, err)
return
}
if len(sh.Items) == 1 {
sh.Path = sh.Items[0].Path
}
view, err := s.viewOf(sh)
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, view)
}
func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request) {
ok, err := s.store.DeleteShare(r.PathValue("token"))
if err != nil {
internalError(w, r, err)
return
}
if !ok {
writeError(w, 404, "分享不存在")
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// ---- 公开访问(无需登录) ----
// openShare 取出有效的分享并校验访客凭据;失败时已写好响应
func (s *Server) openShare(w http.ResponseWriter, r *http.Request) (store.Share, bool) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Robots-Tag", "noindex")
sh, ok, err := s.loadShare(r.PathValue("token"))
if err != nil {
internalError(w, r, err)
return sh, false
}
if !ok {
writeError(w, 404, "分享不存在或已失效")
return sh, false
}
if sh.PasswordHash != "" {
valid := false
if c, err := r.Cookie(shareCookieName); err == nil {
if valid, err = s.auth.ValidShareCookie(c.Value, sh.Token, sh.PasswordHash); err != nil {
internalError(w, r, err)
return sh, false
}
}
if !valid {
writeJSON(w, 401, map[string]any{"error": "需要访问密码", "passwordRequired": true})
return sh, false
}
}
return sh, true
}
// loadShare 返回未过期的分享
func (s *Server) loadShare(token string) (store.Share, bool, error) {
sh, ok, err := s.store.GetShare(token)
if err != nil || !ok {
return sh, false, err
}
if sh.ExpiresAt != 0 && time.Now().UnixMilli() >= sh.ExpiresAt {
return sh, false, nil
}
return sh, true, nil
}
// publicShare 只公开主体本身或分享目录内的内容,不返回主体在仓库中的路径。
func (s *Server) publicShare(w http.ResponseWriter, r *http.Request) {
sh, ok := s.openShare(w, r)
if !ok {
return
}
if len(sh.Items) > 1 && r.URL.Query().Get("item") == "" {
if r.URL.Query().Get("path") != "" {
writeError(w, 400, "请选择分享主体")
return
}
items := []map[string]any{}
for _, item := range sh.Items {
info, err := s.vault.Stat(item.Path)
if err != nil && !errors.Is(err, vault.ErrNotFound) {
vaultError(w, r, err)
return
}
items = append(items, map[string]any{"id": item.ID, "title": path.Base(item.Path), "exists": err == nil, "dir": err == nil && info.IsDir()})
}
writeJSON(w, 200, map[string]any{"title": "分享的文件", "collection": true, "items": items})
return
}
subject, ok := shareSubject(w, r, sh)
if !ok {
return
}
info, err := s.vault.Stat(subject)
if err != nil {
vaultError(w, r, err)
return
}
p := subject
if rel := r.URL.Query().Get("path"); rel != "" {
if !info.IsDir() {
writeError(w, 404, "文件不存在")
return
}
p, err = s.vault.ScopedPath(subject, rel)
if err == nil {
info, err = s.vault.Stat(p)
}
if err != nil {
vaultError(w, r, err)
return
}
}
if info.IsDir() {
entries, err := s.vault.TreeAt(p)
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": true, "entries": entries})
return
}
if !vault.IsNote(p) {
writeJSON(w, 200, map[string]any{"title": path.Base(p), "dir": false})
return
}
data, _, err := s.vault.Read(p)
if err != nil {
vaultError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{
"title": strings.TrimSuffix(path.Base(p), vault.NoteExt),
"content": string(data),
"updatedAt": info.ModTime().UnixMilli(),
"dir": false,
})
}
func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
var body struct{ Password string }
if !readJSON(w, r, &body) {
return
}
sh, ok, err := s.loadShare(r.PathValue("token"))
if err != nil {
internalError(w, r, err)
return
}
if !ok {
writeError(w, 404, "分享不存在或已失效")
return
}
if sh.PasswordHash != "" {
switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); {
case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error())
return
case errors.Is(err, auth.ErrBadCredentials):
writeError(w, 401, "密码错误")
return
case err != nil:
internalError(w, r, err)
return
}
}
value, err := s.auth.ShareCookie(sh.Token, sh.PasswordHash)
if err != nil {
internalError(w, r, err)
return
}
ttl := shareCookieTTL
if sh.ExpiresAt != 0 {
ttl = time.Until(time.UnixMilli(sh.ExpiresAt))
}
http.SetCookie(w, &http.Cookie{
Name: shareCookieName,
Value: value,
// 默认 Path 为 /unlock 所在目录,避免非 ASCII 文件名违反 Cookie Path 格式。
MaxAge: int(ttl / time.Second),
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteStrictMode,
})
writeJSON(w, 200, map[string]bool{"ok": true})
}
// publicShareAsset 提供主体允许范围内的普通文件;单文件分享额外允许其 .assets。
func (s *Server) publicShareAsset(w http.ResponseWriter, r *http.Request) {
sh, ok := s.openShare(w, r)
if !ok {
return
}
subject, ok := shareSubject(w, r, sh)
if !ok {
return
}
info, err := s.vault.Stat(subject)
if err != nil {
vaultError(w, r, err)
return
}
rel := r.PathValue("path")
var target string
if info.IsDir() {
target, err = s.vault.ScopedPath(subject, rel)
} else if rel == "" {
target = subject
} else {
assets := vault.AssetsDir(subject)
prefix := path.Base(assets) + "/"
if !vault.IsNote(subject) || !strings.HasPrefix(rel, prefix) {
writeError(w, 404, "文件不存在")
return
}
target, err = s.vault.ScopedPath(assets, strings.TrimPrefix(rel, prefix))
}
if err != nil {
vaultError(w, r, err)
return
}
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(target)}))
}
s.serveRaw(w, r, target)
}
func shareSubject(w http.ResponseWriter, r *http.Request, sh store.Share) (string, bool) {
raw := r.URL.Query().Get("item")
if raw == "" && len(sh.Items) == 1 {
return sh.Items[0].Path, true
}
id, err := strconv.Atoi(raw)
if err == nil {
for _, item := range sh.Items {
if item.ID == id {
return item.Path, true
}
}
}
writeError(w, 404, "分享主体不存在")
return "", false
}
// FollowMoves 让分享跟随在外部(如 Typora)完成的改名与移动;经 API 的移动已在 move 接口中处理,
// 这里再次处理时原路径已无分享,不会重复修改
func (s *Server) FollowMoves(ctx context.Context) {
for ctx.Err() == nil {
ch, cancel := s.hub.Subscribe()
s.followMoves(ctx, ch)
cancel()
}
}
func (s *Server) followMoves(ctx context.Context, ch <-chan []byte) {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
// 处理不及时被断开,重新订阅
return
}
var evs []events.Event
if err := json.Unmarshal(msg, &evs); err != nil {
slog.Error("解析变更事件失败", "err", err)
continue
}
for _, ev := range evs {
if ev.Type == "moved" {
if err := s.store.MoveShares(ev.From, ev.Path); err != nil {
slog.Warn("更新分享路径失败", "from", ev.From, "to", ev.Path, "err", err)
}
}
}
}
}
}
+4 -7
View File
@@ -42,10 +42,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// 分享页 /s/{token} 是独立的入口,只加载渲染器 // 已移除的公开入口不能回退到应用首页。
share := strings.HasPrefix(name, "s/") if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
if share { writeError(w, 404, "页面不存在")
name = "share.html" return
} }
f, err := h.load(name) f, err := h.load(name)
if err != nil { if err != nil {
@@ -65,9 +65,6 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(name, ".html") { if strings.HasSuffix(name, ".html") {
hd.Set("Content-Security-Policy", appCSP) hd.Set("Content-Security-Policy", appCSP)
} }
if share {
hd.Set("X-Robots-Tag", "noindex")
}
if path.Ext(name) == ".webmanifest" { if path.Ext(name) == ".webmanifest" {
// Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。 // Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。
hd.Set("Content-Type", "application/manifest+json") hd.Set("Content-Type", "application/manifest+json")
-54
View File
@@ -142,57 +142,3 @@ func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
CSS string `json:"css"` CSS string `json:"css"`
}{manifest, css}) }{manifest, css})
} }
// 只有有效分享的访客可读取外观;不返回账户、插件和其他应用设置。
func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) {
if _, ok := s.openShare(w, r); !ok {
return
}
settings, err := s.readSettings()
if err != nil {
internalError(w, r, err)
return
}
out := map[string]any{}
for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} {
if value, ok := settings[key]; ok {
out[key] = value
}
}
var snippets []struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
Enabled bool `json:"enabled"`
}
if value, ok := settings["cssSnippets"]; ok {
if err := json.Unmarshal(value, &snippets); err != nil {
internalError(w, r, err)
return
}
}
active := snippets[:0]
for _, snippet := range snippets {
if snippet.Enabled {
active = append(active, snippet)
}
}
out["cssSnippets"] = active
var theme string
if value, ok := settings["theme"]; ok {
if err := json.Unmarshal(value, &theme); err != nil {
internalError(w, r, err)
return
}
}
if theme != "" && theme != "default" {
_, css, err := s.readTheme(theme)
if err != nil {
internalError(w, r, err)
return
}
out["themeCSS"] = css
}
out["theme"] = theme
writeJSON(w, 200, out)
}
+1 -2
View File
@@ -24,7 +24,6 @@
/* 绿色按钮使用深色文字,保持按钮的文字对比度。 */ /* 绿色按钮使用深色文字,保持按钮的文字对比度。 */
.rm-btn-primary, .rm-btn-primary,
.rm-btn-primary:hover, .rm-btn-primary:hover,
.rm-segmented button.on, .rm-segmented button.on {
.rm-share-password button {
color: #101b16; color: #101b16;
} }
+1 -30
View File
@@ -1,8 +1,6 @@
package api package api
import ( import (
"encoding/json"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,7 +8,7 @@ import (
"testing" "testing"
) )
func TestThemeAndShareAppearanceBoundaries(t *testing.T) { func TestThemeBoundaries(t *testing.T) {
f := newFixture(t) f := newFixture(t)
dir := filepath.Join(filepath.Dir(f.root), "themes", "paper") dir := filepath.Join(filepath.Dir(f.root), "themes", "paper")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -42,33 +40,6 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) {
if !strings.Contains(theme["css"], "#faf6ed") { if !strings.Contains(theme["css"], "#faf6ed") {
t.Fatal("未读取到主题 CSS") t.Fatal("未读取到主题 CSS")
} }
f.write("笔记.md", "正文")
f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"uiFontSize":20,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil)
sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil))
base := "/api/v1/public/shares/" + sh.Token
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 {
t.Fatalf("未解锁不能读取外观,得到 %d", code)
}
f.do(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil)
res := f.do(guest, "GET", base+"/appearance", "", nil)
raw, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
var appearance map[string]any
if err := json.Unmarshal(raw, &appearance); err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 || appearance["colorMode"] != "dark" || appearance["uiFontSize"] != float64(20) || appearance["fontSize"] != float64(18) || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") {
t.Fatalf("分享外观错误:%s", raw)
}
if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 {
t.Fatalf("分享泄露了其他设置或停用片段:%s", raw)
}
f.do(f.admin, "DELETE", "/api/v1/shares/"+sh.Token, "", nil)
if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 404 {
t.Fatalf("撤销分享后不能读取外观,得到 %d", code)
}
// CSS 符号链接不能读取主题目录之外的应用数据。 // CSS 符号链接不能读取主题目录之外的应用数据。
if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil { if err := os.Remove(filepath.Join(dir, "theme.css")); err != nil {
t.Fatal(err) t.Fatal(err)
+2 -75
View File
@@ -1,8 +1,7 @@
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。 // Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及 API 令牌的校验。
package auth package auth
import ( import (
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
@@ -21,7 +20,6 @@ import (
const ( const (
passwordKey = "auth.password" passwordKey = "auth.password"
shareSecretKey = "share.secret"
apiTokenPrefix = "rm_" apiTokenPrefix = "rm_"
SessionTTL = 30 * 24 * time.Hour SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8 MinPasswordLen = 8
@@ -32,7 +30,7 @@ const (
argonThreads = 4 argonThreads = 4
argonKeyLen = 32 argonKeyLen = 32
// 同一来源在窗口内累计失败 maxFailures 次(登录与分享密码合计)即封禁 lockout // 同一来源在窗口内累计失败 maxFailures 次即封禁 lockout
maxFailures = 30 maxFailures = 30
failureWindow = 15 * time.Minute failureWindow = 15 * time.Minute
lockout = 15 * time.Minute lockout = 15 * time.Minute
@@ -149,70 +147,6 @@ func (m *Manager) ChangePassword(ip, keepToken, current, next string) error {
return m.store.DeleteOtherSessions(hashToken(keepToken)) return m.store.DeleteOtherSessions(hashToken(keepToken))
} }
// ---- 分享 ----
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
func HashSharePassword(password string) string {
if password == "" {
return ""
}
return hashPassword(password)
}
// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速。
// 成功不清零失败计数:否则知道某个分享密码的人可以借此反复重置计数,无限尝试登录密码
func (m *Manager) CheckSharePassword(ip, hash, password string) error {
if !m.allow(ip) {
return ErrRateLimited
}
ok, err := verifyPassword(hash, password)
if err != nil {
return err
}
if !ok {
m.recordFailure(ip)
return ErrBadCredentials
}
return nil
}
// ShareCookie 生成输入分享密码后发给访客的凭据:绑定分享令牌与密码哈希,撤销分享即失效
func (m *Manager) ShareCookie(token, passwordHash string) (string, error) {
secret, err := m.shareSecret()
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(token + "\x00" + passwordHash))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (m *Manager) ValidShareCookie(cookie, token, passwordHash string) (bool, error) {
want, err := m.ShareCookie(token, passwordHash)
if err != nil {
return false, err
}
return hmac.Equal([]byte(cookie), []byte(want)), nil
}
// shareSecret 读取签名密钥,首次使用时生成并保存
func (m *Manager) shareSecret() ([]byte, error) {
v, ok, err := m.store.Setting(shareSecretKey)
if err != nil {
return nil, err
}
if !ok {
if _, err := m.store.SetSettingIfAbsent(shareSecretKey, randomToken()); err != nil {
return nil, err
}
// 并发首次生成时以先写入的为准
if v, _, err = m.store.Setting(shareSecretKey); err != nil {
return nil, err
}
}
return base64.RawURLEncoding.DecodeString(v)
}
// ---- API 令牌 ---- // ---- API 令牌 ----
// CreateAPIToken 创建令牌,返回只显示这一次的明文 // CreateAPIToken 创建令牌,返回只显示这一次的明文
@@ -316,13 +250,6 @@ func randomToken() string {
return base64.RawURLEncoding.EncodeToString(b) return base64.RawURLEncoding.EncodeToString(b)
} }
// NewShareToken 生成分享链接中的令牌(128 位随机数,比会话令牌短,便于复制)
func NewShareToken() string {
b := make([]byte, 16)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(token string) string { func hashToken(token string) string {
sum := sha256.Sum256([]byte(token)) sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
+2 -16
View File
@@ -25,34 +25,20 @@ func newManager(t *testing.T) (*Manager, *time.Time) {
return m, &clock return m, &clock
} }
// 登录与分享密码合计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝 // 登录密码累计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝
func TestRateLimit(t *testing.T) { func TestRateLimit(t *testing.T) {
m, clock := newManager(t) m, clock := newManager(t)
share := HashSharePassword("share-pass")
for i := 0; i < maxFailures-1; i++ { for i := 0; i < maxFailures-1; i++ {
var err error if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) {
if i%2 == 0 {
_, err = m.Login("1.1.1.1", "wrong")
} else {
err = m.CheckSharePassword("1.1.1.1", share, "wrong")
}
if !errors.Is(err, ErrBadCredentials) {
t.Fatalf("第 %d 次失败: %v", i+1, err) t.Fatalf("第 %d 次失败: %v", i+1, err)
} }
} }
// 分享密码正确不清零计数
if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); err != nil {
t.Fatal(err)
}
if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) { if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) {
t.Fatalf("第 30 次失败: %v", err) t.Fatalf("第 30 次失败: %v", err)
} }
if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) { if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("封禁期间正确密码也应被拒绝: %v", err) t.Fatalf("封禁期间正确密码也应被拒绝: %v", err)
} }
if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("封禁同样作用于分享密码: %v", err)
}
if _, err := m.Login("2.2.2.2", "password123"); err != nil { if _, err := m.Login("2.2.2.2", "password123"); err != nil {
t.Fatalf("其他来源不受影响: %v", err) t.Fatalf("其他来源不受影响: %v", err)
} }
+109
View File
@@ -0,0 +1,109 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestRemoveSharingPreservesApplicationData(t *testing.T) {
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
if err != nil {
t.Fatal(err)
}
for _, layout := range []string{"current", "legacy", "fresh"} {
t.Run(layout, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "rawmark.db")
st, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if _, err := st.db.Exec(`
INSERT INTO settings VALUES ('auth.password','password-hash'), ('plugin.setting','keep'),
('share.secret','old-secret'),
('app.settings','{"fontSize":18,"shortcuts":{"note.share":["Alt+S"],"app.shares":[],"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}');
INSERT INTO sessions VALUES ('session-hash',123,456);
INSERT INTO api_tokens VALUES ('token-id','automation','token-hash','read',123,456);`); err != nil {
t.Fatal(err)
}
switch layout {
case "current":
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
CREATE TABLE share_items(share_token TEXT REFERENCES shares(token),item_id INTEGER,path TEXT);
CREATE INDEX share_items_path ON share_items(path);
INSERT INTO shares VALUES ('old-token','hash',123,0);
INSERT INTO share_items VALUES ('old-token',0,'note.md');`)
case "legacy":
_, err = st.db.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT,password_hash TEXT,created_at INTEGER,expires_at INTEGER);
CREATE INDEX shares_path ON shares(path);
INSERT INTO shares VALUES ('old-token','note.md','hash',123,0);`)
}
if err != nil {
t.Fatal(err)
}
for range 2 {
if _, err := st.db.Exec(string(script)); err != nil {
t.Fatal(err)
}
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = Open(path)
if err != nil {
t.Fatal(err)
}
var remaining int
if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name IN ('shares','share_items','share_items_path','shares_path')`).Scan(&remaining); err != nil || remaining != 0 {
t.Fatalf("重启后不应创建分享结构:%d %v", remaining, err)
}
if _, ok, err := st.Setting("share.secret"); err != nil || ok {
t.Fatalf("分享密钥未清除:%v", err)
}
for key, want := range map[string]string{
"auth.password": "password-hash", "plugin.setting": "keep",
"app.settings": `{"fontSize":18,"shortcuts":{"note.open":["Ctrl+O"],"plugin.command":[]},"plugin.data":{"keep":true}}`,
} {
if got, ok, err := st.Setting(key); err != nil || !ok || got != want {
t.Fatalf("非分享设置被改变:%s %v", key, err)
}
}
var sessions, tokens int
if err := st.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash='session-hash' AND created_at=123 AND expires_at=456`).Scan(&sessions); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRow(`SELECT count(*) FROM api_tokens WHERE id='token-id' AND name='automation' AND token_hash='token-hash' AND scope='read' AND created_at=123 AND last_used_at=456`).Scan(&tokens); err != nil || sessions != 1 || tokens != 1 {
t.Fatalf("会话或 API 令牌被改变:%d %d %v", sessions, tokens, err)
}
})
}
}
func TestRemoveSharingRollsBackOnInvalidSettings(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "rawmark.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.db.Exec(`CREATE TABLE shares(token TEXT); INSERT INTO shares VALUES ('old-token'); INSERT INTO settings VALUES ('share.secret','old-secret'),('app.settings','invalid-json');`); err != nil {
t.Fatal(err)
}
script, err := os.ReadFile("../../scripts/remove-sharing.sql")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(string(script)); err == nil {
t.Fatal("设置损坏时必须报告错误")
}
if _, err := st.db.Exec("ROLLBACK"); err != nil {
t.Fatal(err)
}
var count int
if err := st.db.QueryRow(`SELECT count(*) FROM shares WHERE token='old-token'`).Scan(&count); err != nil || count != 1 {
t.Fatalf("失败后分享数据未恢复:%v", err)
}
if got, ok, err := st.Setting("share.secret"); err != nil || !ok || got != "old-secret" {
t.Fatalf("失败后分享密钥未恢复:%v", err)
}
}
-43
View File
@@ -1,43 +0,0 @@
package store
import "database/sql"
const shareSchema = `
-- 分享令牌是可复制的访问凭据,与笔记同等敏感。
CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY,
password_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS share_items (
share_token TEXT NOT NULL REFERENCES shares(token) ON DELETE CASCADE,
item_id INTEGER NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (share_token,item_id)
);
CREATE INDEX IF NOT EXISTS share_items_path ON share_items(path);
`
// 仅迁移项目已有的单路径表;事务保证中断或失败时仍保留原格式。
func migrateShares(db *sql.DB) error {
var legacy bool
if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM pragma_table_info('shares') WHERE name = 'path')`).Scan(&legacy); err != nil {
return err
}
if !legacy {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`ALTER TABLE shares RENAME TO shares_legacy;` + shareSchema + `
INSERT INTO shares(token,password_hash,created_at,expires_at) SELECT token,password_hash,created_at,expires_at FROM shares_legacy;
INSERT INTO share_items(share_token,item_id,path) SELECT token,0,path FROM shares_legacy;
DROP TABLE shares_legacy;`); err != nil {
return err
}
return tx.Commit()
}
-50
View File
@@ -1,50 +0,0 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
func TestMigrateSingleSharesAndPreserveLinks(t *testing.T) {
file := filepath.Join(t.TempDir(), "app.db")
legacy, err := sql.Open("sqlite", file)
if err != nil {
t.Fatal(err)
}
_, err = legacy.Exec(`CREATE TABLE shares(token TEXT PRIMARY KEY,path TEXT NOT NULL,password_hash TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL DEFAULT 0);
CREATE INDEX shares_path ON shares(path);
INSERT INTO shares VALUES ('old-token','资料/原文.md','hash',123,456);`)
if err != nil {
t.Fatal(err)
}
legacy.Close()
st, err := Open(file)
if err != nil {
t.Fatal(err)
}
sh, ok, err := st.GetShare("old-token")
if err != nil || !ok || sh.Path != "资料/原文.md" || len(sh.Items) != 1 || sh.Items[0].ID != 0 || sh.PasswordHash != "hash" || sh.CreatedAt != 123 || sh.ExpiresAt != 456 {
t.Fatalf("迁移改变了旧分享:%+v %v", sh, err)
}
if err := st.MoveShares("资料", "归档"); err != nil {
t.Fatal(err)
}
st.Close()
st, err = Open(file)
if err != nil {
t.Fatal(err)
}
defer st.Close()
sh, ok, err = st.GetShare("old-token")
if err != nil || !ok || sh.Path != "归档/原文.md" {
t.Fatalf("重启或移动未保留链接:%+v %v", sh, err)
}
if _, err := st.DeleteShare(sh.Token); err != nil {
t.Fatal(err)
}
var count int
if err := st.db.QueryRow(`SELECT count(*) FROM share_items`).Scan(&count); err != nil || count != 0 {
t.Fatalf("撤销未清理主体:%d %v", count, err)
}
}
+1 -126
View File
@@ -1,4 +1,4 @@
// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。 // Package store 保存应用自身的数据(设置、会话、API 令牌)。笔记内容不在这里,见 vault。
package store package store
import ( import (
@@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS sessions (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
); );
` + shareSchema + `
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -38,10 +37,6 @@ func Open(path string) (*Store, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := migrateShares(db); err != nil {
db.Close()
return nil, fmt.Errorf("迁移分享数据失败: %w", err)
}
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("初始化数据库失败: %w", err) return nil, fmt.Errorf("初始化数据库失败: %w", err)
@@ -108,126 +103,6 @@ func (s *Store) DeleteExpiredSessions() error {
return err return err
} }
// ---- 分享 ----
// Share 为一个只读分享链接,Items 标识各个独立跟随移动的主体。
// Path 仅用于已有单主体调用与响应,不单独存储。
type Share struct {
Token string `json:"token"`
Path string `json:"path,omitempty"`
Items []ShareItem `json:"items"`
PasswordHash string `json:"-"`
CreatedAt int64 `json:"createdAt"`
ExpiresAt int64 `json:"expiresAt"`
}
type ShareItem struct {
ID int `json:"id"`
Path string `json:"path"`
}
func (s *Store) CreateShare(sh Share) error {
items := sh.Items
if len(items) == 0 && sh.Path != "" {
items = []ShareItem{{ID: 0, Path: sh.Path}}
}
if len(items) == 0 {
return fmt.Errorf("分享缺少主体")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`INSERT INTO shares (token, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?)`, sh.Token, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt); err != nil {
return err
}
for _, item := range items {
if _, err = tx.Exec(`INSERT INTO share_items (share_token, item_id, path) VALUES (?, ?, ?)`, sh.Token, item.ID, item.Path); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) GetShare(token string) (sh Share, ok bool, err error) {
err = s.db.QueryRow(`SELECT token, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token).Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return Share{}, false, nil
}
if err != nil {
return sh, false, err
}
err = s.shareItems(&sh)
return sh, err == nil, err
}
func (s *Store) shareItems(sh *Share) error {
rows, err := s.db.Query(`SELECT item_id, path FROM share_items WHERE share_token = ? ORDER BY item_id`, sh.Token)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var item ShareItem
if err := rows.Scan(&item.ID, &item.Path); err != nil {
return err
}
sh.Items = append(sh.Items, item)
}
if err := rows.Err(); err != nil {
return err
}
if len(sh.Items) == 0 {
return fmt.Errorf("分享缺少主体")
}
if len(sh.Items) == 1 {
sh.Path = sh.Items[0].Path
}
return nil
}
func (s *Store) Shares() ([]Share, error) {
rows, err := s.db.Query(`SELECT token, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC, token`)
if err != nil {
return nil, err
}
out := []Share{}
for rows.Next() {
var sh Share
if err := rows.Scan(&sh.Token, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil {
rows.Close()
return nil, err
}
out = append(out, sh)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
for i := range out {
if err := s.shareItems(&out[i]); err != nil {
return nil, err
}
}
return out, nil
}
func (s *Store) DeleteShare(token string) (bool, error) {
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n == 1, err
}
func (s *Store) MoveShares(from, to string) error {
_, err := s.db.Exec(`UPDATE share_items SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END
WHERE path = ?1 OR substr(path,1,length(?1)+1) = ?1 || '/'`, from, to)
return err
}
// ---- API 令牌 ---- // ---- API 令牌 ----
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write // APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write
+7 -15
View File
@@ -1,9 +1,6 @@
package vault package vault
import ( import "fmt"
"fmt"
"strings"
)
type CleanupResult struct { type CleanupResult struct {
Deleted []TrashItem `json:"deleted"` Deleted []TrashItem `json:"deleted"`
@@ -15,8 +12,7 @@ type CleanupSkip struct {
} }
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。 // CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
// protected 返回当前有效分享的主体路径,由业务数据拥有者在检查时读取。 func (v *Vault) CleanupImages(paths []string) (CleanupResult, error) {
func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)) (CleanupResult, error) {
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}} result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
@@ -41,10 +37,6 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
references[ref.Target] = true references[ref.Target] = true
} }
} }
scopes, err := protected()
if err != nil {
return result, err
}
seen := map[string]bool{} seen := map[string]bool{}
for _, p := range paths { for _, p := range paths {
if seen[p] { if seen[p] {
@@ -58,11 +50,11 @@ func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)
case references[p]: case references[p]:
reason = "仍被笔记引用" reason = "仍被笔记引用"
default: default:
for _, scope := range scopes { info, err := v.Stat(p)
if p == scope || strings.HasPrefix(p, scope+"/") || IsNote(scope) && strings.HasPrefix(p, AssetsDir(scope)+"/") { if err != nil {
reason = "仍在有效分享范围内" reason = err.Error()
break } else if !info.Mode().IsRegular() {
} reason = "不是图片文件"
} }
} }
if reason != "" { if reason != "" {
+6 -17
View File
@@ -8,12 +8,12 @@ import (
"testing" "testing"
) )
func TestCleanupChecksFreshReferencesAndShares(t *testing.T) { func TestCleanupChecksFreshReferences(t *testing.T) {
v := newVault(t) v := newVault(t)
if _, err := v.Write("a.md", []byte("[图片](assets/used.png)"), Precondition{}); err != nil { if _, err := v.Write("a.md", []byte("[图片](assets/used.png)"), Precondition{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png"} { for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png"} {
full := filepath.Join(v.Root(), p) full := filepath.Join(v.Root(), p)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -22,8 +22,8 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png", "../outside.png"}, func() ([]string, error) { return []string{"public", "a.md"}, nil }) result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/orphan.png", "a.assets/attached.png", "../outside.png"})
if err != nil || len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 4 { if err != nil || len(result.Deleted) != 3 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 2 {
t.Fatalf("清理结果不符:%+v %v", result, err) t.Fatalf("清理结果不符:%+v %v", result, err)
} }
if _, err := v.Restore(result.Deleted[0].ID); err != nil { if _, err := v.Restore(result.Deleted[0].ID); err != nil {
@@ -32,24 +32,13 @@ func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
if _, err := v.Write("a.md", []byte("---\ntags: [broken\n---"), Precondition{}); err != nil { if _, err := v.Write("a.md", []byte("---\ntags: [broken\n---"), Precondition{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, nil }) result, err = v.CleanupImages([]string{"assets/orphan.png"})
if err == nil || len(result.Deleted) != 0 { if err == nil || len(result.Deleted) != 0 {
t.Fatal("不完整的引用分析不能删除图片", result, err) t.Fatal("不完整的引用分析不能删除图片", result, err)
} }
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil { if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Write("a.md", []byte(""), Precondition{}); err != nil {
t.Fatal(err)
}
cause := errors.New("分享读取失败")
_, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, cause })
if !errors.Is(err, cause) {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
t.Fatal("分享校验失败后不应删除", err)
}
} }
func TestFolderUploadReusesDeduplication(t *testing.T) { func TestFolderUploadReusesDeduplication(t *testing.T) {
@@ -86,7 +75,7 @@ func TestCleanupProtectsLinkedImages(t *testing.T) {
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link { if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link {
t.Fatalf("链接内图片未记录引用:%+v", metadata) t.Fatalf("链接内图片未记录引用:%+v", metadata)
} }
result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil }) result, err := v.CleanupImages([]string{link})
if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" { if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" {
t.Fatalf("链接内图片未受保护:%+v %v", result, err) t.Fatalf("链接内图片未受保护:%+v %v", result, err)
} }
+3 -42
View File
@@ -20,7 +20,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil { if _, err := v.Write("notes/a.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png", nil); err != nil { if _, err := v.Move("assets/原图.png", "elsewhere/新图 #.png"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, _, err := v.Read("notes/a.md") out, _, err := v.Read("notes/a.md")
@@ -33,7 +33,7 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
if string(out) != want { if string(out) != want {
t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want) t.Fatalf("链接改写不符:\n%s\n期望:\n%s", out, want)
} }
if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg", nil); !errors.Is(err, ErrInvalidPath) { if _, err := v.Move("elsewhere/新图 #.png", "elsewhere/新图.jpg"); !errors.Is(err, ErrInvalidPath) {
t.Fatalf("不能用重命名改变图片格式:%v", err) t.Fatalf("不能用重命名改变图片格式:%v", err)
} }
item, err := v.Delete("elsewhere/新图 #.png") item, err := v.Delete("elsewhere/新图 #.png")
@@ -62,7 +62,7 @@ func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T)
if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil { if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Move("old.png", "new.png", nil); err != nil { if _, err := v.Move("old.png", "new.png"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
body, _, err := v.Read("文章.md") body, _, err := v.Read("文章.md")
@@ -138,42 +138,3 @@ func TestImagesDoNotExposeOrModifySymlinks(t *testing.T) {
t.Fatalf("不能替换仓库外文件:%v", err) t.Fatalf("不能替换仓库外文件:%v", err)
} }
} }
func TestMoveCommitFailureRestoresExactReferences(t *testing.T) {
for _, source := range []string{"from.png", "from.md"} {
t.Run(source, func(t *testing.T) {
v := newVault(t)
content := "![](from.assets/a.png)"
if _, err := v.Write("from.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(v.Root(), "from.assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(v.Root(), "from.png"), []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
refs := "![](from.png)\n![](to.png)"
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
t.Fatal(err)
}
failure := errors.New("关联数据提交失败")
target := strings.Replace(source, "from", "to", 1)
if _, err := v.Move(source, target, func() error { return failure }); !errors.Is(err, failure) {
t.Fatal(err)
}
for name, want := range map[string]string{"from.md": content, "refs.md": refs} {
got, _, err := v.Read(name)
if err != nil || string(got) != want {
t.Fatalf("回滚须保留原始正文:%s %q %v", name, got, err)
}
}
if _, err := os.Stat(filepath.Join(v.Root(), target)); !os.IsNotExist(err) {
t.Fatalf("失败后目标仍存在:%v", err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "from.assets")); err != nil {
t.Fatal(err)
}
})
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ func TestImportZipAssetsAndMove(t *testing.T) {
if err := v.Mkdir("移动目标"); err != nil { if err := v.Mkdir("移动目标"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil { if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
moved, _, err := v.Read("移动目标/改名.md") moved, _, err := v.Read("移动目标/改名.md")
+3 -16
View File
@@ -1,7 +1,6 @@
package vault package vault
import ( import (
"errors"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -42,7 +41,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil { if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.Move("dir/a.md", "new/renamed.md", nil); err != nil { if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
body, _, _ := v.Read("new/renamed.md") body, _, _ := v.Read("new/renamed.md")
@@ -53,25 +52,13 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
if string(body) != "[入链](../new/renamed.md?q=1#section)" { if string(body) != "[入链](../new/renamed.md?q=1#section)" {
t.Fatalf("入链错误:%s", body) t.Fatalf("入链错误:%s", body)
} }
if _, err := v.Move("new", "deep/folder", nil); err != nil { if _, err := v.Move("new", "deep/folder"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
body, _, _ = v.Read("deep/folder/renamed.md") body, _, _ = v.Read("deep/folder/renamed.md")
if !strings.Contains(string(body), "../../other.md") { if !strings.Contains(string(body), "../../other.md") {
t.Fatalf("目录出链错误:%s", body) t.Fatalf("目录出链错误:%s", body)
} }
original, _, _ := v.Read("other.md")
failure := errors.New("提交失败")
if _, err := v.Move("deep/folder", "rollback", func() error { return failure }); !errors.Is(err, failure) {
t.Fatal(err)
}
body, _, _ = v.Read("other.md")
if string(body) != string(original) {
t.Fatalf("入链未回滚:%s", body)
}
if _, _, err := v.Read("deep/folder/renamed.md"); err != nil {
t.Fatal("移动主体未回滚", err)
}
} }
func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) { func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) {
@@ -102,7 +89,7 @@ func TestMovePreservesEntityShapedFilenames(t *testing.T) {
} }
} }
const target = "a&copy;.md" const target = "a&copy;.md"
if _, err := v.Move("target.md", target, nil); err != nil { if _, err := v.Move("target.md", target); err != nil {
t.Fatal(err) t.Fatal(err)
} }
body, _, err := v.Read("source.md") body, _, err := v.Read("source.md")
+1 -47
View File
@@ -362,46 +362,11 @@ func (v *Vault) TreeAt(rel string) ([]Entry, error) {
return entries, err return entries, err
} }
// ScopedPath 校验公开访问的子路径,并阻止符号链接跳出已分享的目录。
func (v *Vault) ScopedPath(root, rel string) (string, error) {
if _, err := CleanPath(rel); err != nil {
return "", ErrNotFound
}
base, err := v.abs(root)
if err != nil {
return "", err
}
base, err = filepath.EvalSymlinks(base)
if errors.Is(err, fs.ErrNotExist) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
p := path.Join(root, rel)
full, err := v.abs(p)
if err != nil {
return "", err
}
full, err = filepath.EvalSymlinks(full)
if errors.Is(err, fs.ErrNotExist) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if full != filepath.Join(base, filepath.FromSlash(rel)) {
return "", ErrNotFound
}
return p, nil
}
// ---- 移动 / 重命名 ---- // ---- 移动 / 重命名 ----
// Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。 // Move 移动或重命名笔记、图片或文件夹,返回是否原子改写了移动主体(改变文件身份)。
// 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。 // 笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接。
// commit 用于提交关联元数据;失败时在释放写锁前恢复文件及原始正文,nil 表示无关联元数据。 func (v *Vault) Move(from, to string) (rewritten bool, err error) {
func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err error) {
src, err := v.abs(from) src, err := v.abs(from)
if err != nil { if err != nil {
return false, err return false, err
@@ -451,7 +416,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
if err := os.Rename(src, dst); err != nil { if err := os.Rename(src, dst); err != nil {
return false, err return false, err
} }
assetsMoved := false
writtenLinks := 0 writtenLinks := 0
defer func() { defer func() {
if err == nil { if err == nil {
@@ -462,11 +426,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback)) err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback))
} }
} }
if assetsMoved {
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚图片移动失败: %w", rollback))
}
}
if rollback := os.Rename(dst, src); rollback != nil { if rollback := os.Rename(dst, src); rollback != nil {
err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback)) err = errors.Join(err, fmt.Errorf("回滚笔记移动失败: %w", rollback))
} }
@@ -484,11 +443,6 @@ func (v *Vault) Move(from, to string, commit func() error) (rewritten bool, err
if err := os.Rename(srcAssets, dstAssets); err != nil { if err := os.Rename(srcAssets, dstAssets); err != nil {
return false, fmt.Errorf("移动图片目录失败: %w", err) return false, fmt.Errorf("移动图片目录失败: %w", err)
} }
assetsMoved = true
}
if commit != nil {
err = commit()
} }
return rewritten, err return rewritten, err
} }
+13 -6
View File
@@ -62,7 +62,12 @@ func TestAssetSymlinkEscapeRejected(t *testing.T) {
func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) { func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
v := newVault(t) v := newVault(t)
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil { content := "原文 ![](n.assets/x.png)"
if _, err := v.Write("n.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
refs := "[原文](n.md) [目标](" + strings.Repeat("a", 250) + ".md)"
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil { if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
@@ -70,11 +75,13 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
} }
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。 // 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
destination := strings.Repeat("a", 250) + ".md" destination := strings.Repeat("a", 250) + ".md"
if _, err := v.Move("n.md", destination, nil); err == nil { if _, err := v.Move("n.md", destination); err == nil {
t.Fatal("图片目录移动应失败") t.Fatal("图片目录移动应失败")
} }
if content, _, err := v.Read("n.md"); err != nil || string(content) != "原文" { for name, want := range map[string]string{"n.md": content, "refs.md": refs} {
t.Fatalf("原笔记未恢复: %q %v", content, err) if got, _, err := v.Read(name); err != nil || string(got) != want {
t.Fatalf("原笔记与引用未恢复: %s %q %v", name, got, err)
}
} }
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil { if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -170,7 +177,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
if !strings.HasPrefix(link, "笔记%20A.assets/") { if !strings.HasPrefix(link, "笔记%20A.assets/") {
t.Fatalf("资源链接不符: %s", link) t.Fatalf("资源链接不符: %s", link)
} }
if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md", nil); err != nil || !rewritten { if rewritten, err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil || !rewritten {
t.Fatalf("应报告正文改写: %v %v", rewritten, err) t.Fatalf("应报告正文改写: %v %v", rewritten, err)
} }
data, _, err := v.Read("other/B.md") data, _, err := v.Read("other/B.md")
@@ -184,7 +191,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil { if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
t.Fatal("图片目录未随笔记移动:", err) t.Fatal("图片目录未随笔记移动:", err)
} }
if _, err := v.Move("other", "other/sub", nil); !errors.Is(err, ErrInvalidPath) { if _, err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
t.Fatal("文件夹移入自身应被拒绝") t.Fatal("文件夹移入自身应被拒绝")
} }
} }
+1 -1
View File
@@ -170,7 +170,7 @@ func (w *Watcher) scan() (map[string]fileState, error) {
return next, nil return next, nil
} }
// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。 // diff 按文件系统身份识别移动,内容相同不代表同一个文件。
func diff(prev, next map[string]fileState) []events.Event { func diff(prev, next map[string]fileState) []events.Event {
var evs []events.Event var evs []events.Event
structural := false structural := false
+12
View File
@@ -0,0 +1,12 @@
-- 一次性部署清理:先停止旧服务,并保留数据库与镜像配套的回滚备份。
-- 不由应用启动流程执行;原始笔记和图片不在此脚本的清理范围内。
BEGIN IMMEDIATE;
DROP TABLE IF EXISTS share_items;
DROP TABLE IF EXISTS shares;
DELETE FROM settings WHERE key = 'share.secret';
UPDATE settings
SET value = json_remove(value, '$.shortcuts."note.share"', '$.shortcuts."app.shares"')
WHERE key = 'app.settings'
AND (json_type(value, '$.shortcuts."note.share"') IS NOT NULL
OR json_type(value, '$.shortcuts."app.shares"') IS NOT NULL);
COMMIT;
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<title>RawMark</title>
</head>
<body>
<main id="share" class="rm-share"></main>
<script type="module" src="/src/share/main.ts"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
<script setup lang="ts">
import Icon from './Icon.vue'
import { createNote, ws } from './workspace'
</script>
<template>
<header class="rm-global-header">
<button class="rm-wordmark" @click="ws.settings = null; ws.images = null; ws.view = 'notes'"><Icon name="edit" :size="22" /><span>RawMark</span></button>
<span class="rm-edition">个人知识库</span>
<nav class="rm-primary-nav" aria-label="工作区"><button :class="{ on: ws.view === 'notes' && ws.images === null }" @click="ws.view = 'notes'; ws.images = null"><Icon name="folder" />笔记</button><button :class="{ on: ws.view === 'graph' && ws.images === null }" @click="ws.view = 'graph'; ws.images = null"><Icon name="graph" />图谱</button><button :class="{ on: ws.images !== null }" @click="ws.imagesAll = true; ws.images = ''"><Icon name="image" />图片库</button></nav>
<button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button>
<div class="rm-header-actions">
<button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button>
<button class="rm-btn" :disabled="!ws.active" @click="ws.sharing = ws.active"><Icon name="share" />分享</button>
<button class="rm-btn rm-btn-primary" @click="createNote('')"><Icon name="plus" />新建笔记</button>
<span class="rm-avatar" aria-label="个人工作区">RM</span>
</div>
</header>
</template>
+15 -17
View File
@@ -3,7 +3,8 @@ import { computed, onMounted, ref } from 'vue'
import { api, ApiError, type Theme } from './api' import { api, ApiError, type Theme } from './api'
import { DISPLAY_DEFAULTS } from '../themes/typography' import { DISPLAY_DEFAULTS } from '../themes/typography'
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance' import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
import { loadSettings, reloadTheme, settings, updateSettings, type Settings } from './settings' import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { toast } from './workspace' import { toast } from './workspace'
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }]) const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
@@ -30,6 +31,7 @@ async function change(patch: Partial<Settings>): Promise<boolean> {
await updateSettings(patch) await updateSettings(patch)
return true return true
} catch (err) { } catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return true }
toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接')) toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误'))) void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误')))
return false return false
@@ -68,32 +70,28 @@ async function remove(snippet: CssSnippet) {
async function reset() { async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS,
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
toast('已恢复默认外观,CSS 片段已停用并保留') toast('已恢复默认外观与排版,CSS 片段已停用并保留')
} }
</script> </script>
<template> <template>
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p> <p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
<div class="rm-theme-cards"><button v-for="mode in [{ id: 'light', title: '纯净浅色', subtitle: 'Serene · 日间写作' }, { id: 'dark', title: '沉静深色', subtitle: '沿用你的深色主题' }, { id: 'system', title: '跟随系统', subtitle: '随系统明暗切换' }]" :key="mode.id" :class="[mode.id, { on: (fixedMode ?? settings.colorMode) === mode.id }]" :disabled="busy" @click="change({ theme: 'default', colorMode: mode.id as Settings['colorMode'] })"><span class="rm-theme-sample"><i /><i /><i /></span><strong>{{ mode.title }}</strong><small>{{ mode.subtitle }}</small></button></div> <SettingRow id="theme-select" control="rm-theme-select">
<details class="rm-appearance-advanced"><summary>自定义主题与 CSS 片段</summary>
<div class="rm-setting">
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
<select id="rm-color-mode" :value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
<option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option>
</select>
</div>
<div class="rm-setting">
<label for="rm-theme-select" class="rm-setting-label">主题</label>
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })"> <select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
<option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option> <option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option>
<option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option> <option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option>
</select> </select>
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button> <button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
</SettingRow>
<SettingRow id="color-mode">
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">{{ mode.title }}</button>
</div> </div>
<p v-if="error" class="rm-appearance-note rm-auth-error">{{ error }}</p> </SettingRow>
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后使用原来的明暗设置。</p> <p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">{{ error }}</p>
<p class="rm-appearance-note">可选择内置主题,或从服务器数据目录的 themes 文件夹添加主题;主题和已启用的 CSS 也会应用到分享页。</p> <p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。</p>
<div class="rm-setting"><span class="rm-setting-label">CSS 片段</span><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></div> <details class="rm-settings-details rm-appearance-advanced"><summary>自定义 CSS 与恢复</summary>
<SettingRow id="css-snippets"><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row"> <div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label> <label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button> <button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
@@ -104,7 +102,7 @@ async function reset() {
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label> <label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div> <div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
</form> </form>
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div> <SettingRow id="reset-appearance"><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观与排版</button></SettingRow>
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p> <p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
</details> </details>
</template> </template>
+6 -27
View File
@@ -1,34 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename } from './paths' import { toggleSourceMode, ws } from './workspace'
import { readingStats } from './reading'
import { activeTab, editorView, onEditorUpdate, toggleSourceMode, ws } from './workspace'
const stats = ref(readingStats(''))
const tab = computed(activeTab)
const parts = computed(() => ws.active?.split('/') ?? [])
const modified = computed(() => {
const value = ws.entries.find(e => e.path === ws.active)?.mtime
return value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(value) : ''
})
function refresh() { stats.value = readingStats(editorView()?.state.doc.toString() ?? '') }
let unsubscribe: (() => void) | undefined
onMounted(() => { unsubscribe = onEditorUpdate(refresh); requestAnimationFrame(refresh) })
onBeforeUnmount(() => unsubscribe?.())
watch(() => [ws.active, tab.value?.status], () => requestAnimationFrame(refresh))
</script> </script>
<template> <template>
<section v-if="ws.active" class="rm-editor-chrome"> <section v-if="ws.active" class="rm-editor-chrome" aria-label="笔记操作">
<div class="rm-breadcrumb"><span>知识库</span><template v-for="(part, i) in parts" :key="i"><span class="rm-crumb-separator">/</span><span :class="{ current: i === parts.length - 1 }">{{ part }}</span></template></div> <button class="rm-icon-btn" title="即时预览" aria-label="即时预览" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()"><Icon name="eye" /></button>
<div class="rm-document-meta"><span>字数统计 <strong>{{ stats.words.toLocaleString('zh-CN') }}</strong> 字</span><span>预计阅读 {{ stats.minutes }} 分钟</span><span v-if="modified">更新于 {{ modified }}</span><span :class="{ 'rm-share-bad': tab?.saveError }">{{ tab?.saveError ? '保存失败' : tab?.status === 'conflict' ? '存在冲突' : tab?.dirty ? '正在保存…' : '已保存' }}</span></div> <button class="rm-icon-btn" title="源码模式" aria-label="源码模式" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()"><Icon name="source" /></button>
<div class="rm-document-actions"> <button class="rm-icon-btn" title="显示 / 隐藏大纲" aria-label="显示 / 隐藏大纲" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" /></button>
<div class="rm-mode-switch"><button :class="{ on: !ws.sourceMode }" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()"><Icon name="eye" />即时预览</button><button :class="{ on: ws.sourceMode }" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()"><Icon name="source" />源码模式</button></div> <button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="ws.focusMode = !ws.focusMode"><Icon name="expand" /></button>
<button class="rm-icon-btn" :aria-pressed="ws.outlineOpen" title="显示 / 隐藏大纲" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" /></button>
<button class="rm-icon-btn" :aria-pressed="ws.focusMode" title="专注写作" @click="ws.focusMode = !ws.focusMode"><Icon name="expand" /></button>
<button class="rm-btn" @click="ws.sharing = ws.active"><Icon name="share" />分享</button>
<span class="rm-document-filename" :title="ws.active">{{ basename(ws.active) }}</span>
</div>
</section> </section>
</template> </template>
+4 -1
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// 线性图标(24×24 视框,描边绘制) // 线性图标(24×24 视框,描边绘制)
const paths = { const paths = {
share: 'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 10.5l6.8-4M8.6 13.5l6.8 4',
plus: 'M12 5v14M5 12h14', plus: 'M12 5v14M5 12h14',
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5', expand: 'M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5',
@@ -9,6 +8,10 @@ const paths = {
calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16', calendar: 'M4 5h16v16H4zM8 2v6M16 2v6M4 11h16',
tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01', tag: 'M3 3h8l10 10-8 8L3 11zM7 7h.01',
copy: 'M9 9h12v12H9zM5 15H3V3h12v2', copy: 'M9 9h12v12H9zM5 15H3V3h12v2',
task: 'M3 3h7v7H3zM5 6l1 1 2-2M14 6h7M3 14h7v7H3zM14 18h7',
quote: 'M10 11H4V5h6v8a6 6 0 0 1-6 6M20 11h-6V5h6v8a6 6 0 0 1-6 6',
undo: 'm8 4-5 5 5 5M3 9h10a6 6 0 0 1 0 12h-2',
redo: 'm16 4 5 5-5 5M21 9H11a6 6 0 0 0 0 12h2',
print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z', print: 'M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6z',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z', grid: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',
+18 -29
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { api, type Entry, type Share } from './api' import { type Entry } from './api'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { metadata, refreshMetadata } from './metadata' import { metadata, refreshMetadata } from './metadata'
import { basename, hiddenAssetPath, imageInFolder, isImage } from './paths' import { basename, hiddenAssetPath, imageInFolder, isImage } from './paths'
@@ -20,9 +20,6 @@ const busy = ref(false)
const report = ref<string[]>([]) const report = ref<string[]>([])
const cleanupCandidates = ref<Entry[] | null>(null) const cleanupCandidates = ref<Entry[] | null>(null)
const cleanupSelected = ref<string[]>([]) const cleanupSelected = ref<string[]>([])
const shares = ref<Share[]>([])
const shareError = ref('')
const sharesReady = ref(false)
const uploadDir = ref(props.dir) const uploadDir = ref(props.dir)
const directories = computed(() => ws.entries.filter(entry => entry.dir && !hiddenAssetPath(entry.path))) const directories = computed(() => ws.entries.filter(entry => entry.dir && !hiddenAssetPath(entry.path)))
const allImages = computed(() => ws.entries.filter(entry => !entry.dir && isImage(entry.path))) const allImages = computed(() => ws.entries.filter(entry => !entry.dir && isImage(entry.path)))
@@ -37,9 +34,6 @@ const references = computed(() => {
return result return result
}) })
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length)) const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
function shared(path: string) {
return shares.value.some(share => (!share.expiresAt || share.expiresAt > Date.now()) && share.items.some(item => item.exists && (path === item.path || path.startsWith(item.path + '/') || item.path.endsWith('.md') && path.startsWith(item.path.slice(0,-3) + '.assets/'))))
}
const images = computed(() => scopeImages.value.filter(entry => { const images = computed(() => scopeImages.value.filter(entry => {
const count = references.value.get(entry.path)?.length ?? 0 const count = references.value.get(entry.path)?.length ?? 0
return entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()) && (!format.value || entry.path.toLowerCase().endsWith('.' + format.value)) && (usage.value === 'all' || certain.value && (usage.value === 'used' ? count > 0 : count === 0)) return entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()) && (!format.value || entry.path.toLowerCase().endsWith('.' + format.value)) && (usage.value === 'all' || certain.value && (usage.value === 'used' ? count > 0 : count === 0))
@@ -48,16 +42,11 @@ const types = computed(() => [...new Set(scopeImages.value.map(entry => entry.pa
const totalSize = computed(() => scopeImages.value.reduce((sum, entry) => sum + entry.size, 0)) const totalSize = computed(() => scopeImages.value.reduce((sum, entry) => sum + entry.size, 0))
const current = computed(() => scopeImages.value.find(entry => entry.path === selected.value)) const current = computed(() => scopeImages.value.find(entry => entry.path === selected.value))
const currentRefs = computed(() => references.value.get(selected.value) ?? []) const currentRefs = computed(() => references.value.get(selected.value) ?? [])
const orphans = computed(() => certain.value && sharesReady.value ? scopeImages.value.filter(entry => !references.value.has(entry.path) && !shared(entry.path)) : []) const orphans = computed(() => certain.value ? scopeImages.value.filter(entry => !references.value.has(entry.path)) : [])
function size(bytes: number) { return bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB' } function size(bytes: number) { return bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB' }
function select(path: string) { selected.value = path; dimensions.value = ''; broken.value = false } function select(path: string) { selected.value = path; dimensions.value = ''; broken.value = false }
function loaded(event: Event) { const image = event.target as HTMLImageElement; dimensions.value = image.naturalWidth + ' × ' + image.naturalHeight } function loaded(event: Event) { const image = event.target as HTMLImageElement; dimensions.value = image.naturalWidth + ' × ' + image.naturalHeight }
async function loadShares() { onMounted(() => { void refreshMetadata() })
shareError.value = ''
try { shares.value = await api.shares(); sharesReady.value = true }
catch (error) { sharesReady.value = false; shareError.value = error instanceof Error ? error.message : '分享范围读取失败' }
}
onMounted(() => { void refreshMetadata(); void loadShares() })
async function upload(event: Event) { async function upload(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]; input.value = '' const files = [...(input.files ?? [])]; input.value = ''
@@ -71,8 +60,8 @@ async function upload(event: Event) {
finally { busy.value = false } finally { busy.value = false }
} }
async function proposeCleanup() { async function proposeCleanup() {
await refreshMetadata(); await loadShares() await refreshMetadata()
if (!certain.value || !sharesReady.value) { toast('引用或分享状态尚未确认,不能列出清理候选'); return } if (!certain.value) { toast('引用状态尚未确认,不能列出清理候选'); return }
cleanupCandidates.value = [...orphans.value] cleanupCandidates.value = [...orphans.value]
cleanupSelected.value = orphans.value.map(entry => entry.path) cleanupSelected.value = orphans.value.map(entry => entry.path)
} }
@@ -89,37 +78,37 @@ async function clean() {
<template> <template>
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)"> <section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)">
<header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header> <header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header>
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain || !sharesReady" @click="proposeCleanup">清理孤立图片</button></div> <div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">清理孤立图片</button></div>
<div class="rm-media-workspace"> <div class="rm-media-workspace">
<aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain || !sharesReady" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用和有效分享,图片移入回收站,可恢复。</p></aside> <aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用,图片移入回收站,可恢复。</p></aside>
<div class="rm-media-content"> <div class="rm-media-content">
<div class="rm-media-toolbar"><label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片" /></label><select v-model="format" aria-label="图片格式"><option value="">所有格式</option><option v-for="type in types" :key="type" :value="type">{{ type.toUpperCase() }}</option></select><select v-model="sort" aria-label="图片排序"><option value="modified">最近修改</option><option value="name">名称</option><option value="size">文件大小</option></select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list"><Icon :name="list ? 'grid' : 'outline'" /></button></div> <div class="rm-media-toolbar"><label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片" /></label><select v-model="format" aria-label="图片格式"><option value="">所有格式</option><option v-for="type in types" :key="type" :value="type">{{ type.toUpperCase() }}</option></select><select v-model="sort" aria-label="图片排序"><option value="modified">最近修改</option><option value="name">名称</option><option value="size">文件大小</option></select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list"><Icon :name="list ? 'grid' : 'outline'" /></button></div>
<div class="rm-media-upload"><label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload" /><button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()"><Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}</button></div> <div class="rm-media-upload"><label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload" /><button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()"><Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}</button></div>
<p v-if="metadata.error || shareError || !certain" class="rm-media-warning" role="status">{{ metadata.error || shareError || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata(); loadShares()">刷新状态</button></p> <p v-if="metadata.error || !certain" class="rm-media-warning" role="status">{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata()">刷新状态</button></p>
<div v-if="report.length" class="rm-media-report" role="status"><p v-for="(line,i) in report" :key="i">{{ line }}</p></div> <div v-if="report.length" class="rm-media-report" role="status"><p v-for="(line,i) in report" :key="i">{{ line }}</p></div>
<div class="rm-media-grid" :class="{ list }"> <div class="rm-media-grid" :class="{ list }">
<button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path"><span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" /></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}{{ shared(image.path) ? ' · 已分享' : '' }}</small></button> <button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path"><span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" /></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}</small></button>
</div> </div>
<p v-if="!images.length" class="rm-tree-empty">没有符合条件的图片</p> <p v-if="!images.length" class="rm-tree-empty">没有符合条件的图片</p>
</div> </div>
<aside v-if="current" class="rm-media-inspector"><button class="rm-icon-btn rm-media-deselect" title="关闭详情" @click="selected = ''"><Icon name="close" /></button><div class="rm-media-detail-image"><img v-if="!broken" :src="imageUrl(current.path)" :alt="basename(current.path)" @load="loaded" @error="broken = true" /><p v-else>无法预览此图片</p></div><h2>{{ basename(current.path) }}</h2><p class="rm-media-path">{{ current.path }}</p><dl><dt>尺寸</dt><dd>{{ dimensions || (broken ? '无法读取' : '加载中…') }}</dd><dt>大小</dt><dd>{{ size(current.size) }}</dd><dt>修改时间</dt><dd>{{ new Date(current.mtime).toLocaleDateString('zh-CN') }}</dd><dt>引用</dt><dd>{{ certain ? currentRefs.length + ' 篇笔记' : '尚未确认' }}</dd></dl><button class="rm-btn rm-btn-primary" @click="ws.image = current.path"><Icon name="edit" />预览与编辑</button><h3>引用此图片的笔记</h3><button v-for="ref in currentRefs" :key="ref.path" class="rm-media-reference" @click="ws.images = null; openReference(ref.path, ref.line)"><Icon name="note" /><span>{{ ref.title }}<small>{{ ref.path }}</small></span></button><p v-if="certain && !currentRefs.length">没有笔记引用</p></aside> <aside v-if="current" class="rm-media-inspector"><button class="rm-icon-btn rm-media-deselect" title="关闭详情" @click="selected = ''"><Icon name="close" /></button><div class="rm-media-detail-image"><img v-if="!broken" :src="imageUrl(current.path)" :alt="basename(current.path)" @load="loaded" @error="broken = true" /><p v-else>无法预览此图片</p></div><h2>{{ basename(current.path) }}</h2><p class="rm-media-path">{{ current.path }}</p><dl><dt>尺寸</dt><dd>{{ dimensions || (broken ? '无法读取' : '加载中…') }}</dd><dt>大小</dt><dd>{{ size(current.size) }}</dd><dt>修改时间</dt><dd>{{ new Date(current.mtime).toLocaleDateString('zh-CN') }}</dd><dt>引用</dt><dd>{{ certain ? currentRefs.length + ' 篇笔记' : '尚未确认' }}</dd></dl><button class="rm-btn rm-btn-primary" @click="ws.image = current.path"><Icon name="edit" />预览与编辑</button><h3>引用此图片的笔记</h3><button v-for="ref in currentRefs" :key="ref.path" class="rm-media-reference" @click="ws.images = null; openReference(ref.path, ref.line)"><Icon name="note" /><span>{{ ref.title }}<small>{{ ref.path }}</small></span></button><p v-if="certain && !currentRefs.length">没有笔记引用</p></aside>
</div> </div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记和分享范围;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div> <div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: 28px; overflow: hidden; } .rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: var(--rm-space-6); overflow: hidden; }
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 10px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; } .rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title)/var(--rm-ui-line-height) var(--rm-font-text); margin: var(--rm-space-2) 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); } .rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); }
.rm-media-filters { flex: none; width: 190px; padding: 20px 12px; border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; } .rm-media-filters { flex: none; width: 190px; padding: var(--rm-space-4) var(--rm-space-2); border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: var(--rm-space-2); min-height: var(--rm-control-size); margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: 20px; } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: var(--rm-space-4); } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
.rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); }
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; } .rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
.rm-media-inspector { flex: none; width: 230px; padding: 20px; border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; } .rm-media-inspector { flex: none; width: 230px; padding: var(--rm-space-4); border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; } .rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: 20px; } .rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: var(--rm-space-4); }
.rm-media-mobile-filters { display: none; } .rm-media-mobile-filters { display: none; }
@media(max-width:1199px) { .rm-media-library { padding: 20px; } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: 14px; } } @media(max-width:1199px) { .rm-media-library { padding: var(--rm-space-4); } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: var(--rm-space-4); } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: 18px 12px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: 20px; } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } } @media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: var(--rm-space-4); } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } } @media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style> </style>
+5 -5
View File
@@ -74,12 +74,12 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
</section> </section>
</template> </template>
<style scoped> <style scoped>
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; } .rm-knowledge-panel { flex: 1; overflow: auto; padding: var(--rm-space-6); }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--rm-space-4); } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-text); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); } .rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: var(--rm-space-6) 0 var(--rm-space-4); } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; } .rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; } .rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); } .rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: var(--rm-space-3); border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } } @media(max-width:767px) { .rm-knowledge-panel { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } } @media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style> </style>
+17 -25
View File
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { indentLess, indentMore, redo, undo } from '@codemirror/commands' import { redo, undo } from '@codemirror/commands'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format' import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleInline } from '../editor/format'
import Icon from './Icon.vue' import Icon, { type IconName } from './Icon.vue'
import { settings } from './settings' import { settings } from './settings'
import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } from './workspace' import { activeTab, onEditorUpdate, ws, editorView, insertImagesFromPicker } from './workspace'
// 手机键盘上方的 Markdown 工具条:只在触屏设备上、编辑器获得焦点(软键盘弹出)时显示。 // 底部格式栏与选区工具条;触屏设备仅在编辑器获得焦点时显示底栏。
// 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起 // 按钮在 mousedown 时阻止默认行为,焦点留在编辑器中,键盘不会收起
// (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click) // (不能在 pointerdown 上阻止:WebKit 会因此不再触发 click)
const visible = ref(false) const visible = ref(false)
@@ -16,23 +16,23 @@ const floating = ref<{ left: number; top: number } | null>(null)
let unsubscribe: (() => void) | undefined let unsubscribe: (() => void) | undefined
const coarse = matchMedia('(pointer: coarse)') const coarse = matchMedia('(pointer: coarse)')
const BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [ const FORMAT_BUTTONS: { icon: IconName; title: string; run: (v: EditorView) => unknown }[] = [
{ icon: 'task', title: '任务列表', run: toggleBlock('task') },
{ icon: 'quote', title: '引用', run: toggleBlock('quote') },
{ icon: 'link', title: '链接', run: insertLink },
{ icon: 'image', title: '插入图片', run: () => insertImagesFromPicker() },
{ icon: 'undo', title: '撤销', run: undo },
{ icon: 'redo', title: '重做', run: redo },
]
const SELECTION_BUTTONS: { label: string; title: string; run: (v: EditorView) => unknown; cls?: string }[] = [
{ label: 'H', title: '标题', run: cycleHeading, cls: 'bold' }, { label: 'H', title: '标题', run: cycleHeading, cls: 'bold' },
{ label: 'B', title: '粗体', run: toggleBold, cls: 'bold' }, { label: 'B', title: '粗体', run: toggleBold, cls: 'bold' },
{ label: 'I', title: '斜体', run: toggleItalic, cls: 'italic' }, { label: 'I', title: '斜体', run: toggleItalic, cls: 'italic' },
{ label: 'S', title: '删除线', run: toggleStrike, cls: 'strike' }, { label: 'S', title: '删除线', run: toggleStrike, cls: 'strike' },
{ label: 'M', title: '高亮', run: toggleInline('==') }, { label: 'M', title: '高亮', run: toggleInline('==') },
{ label: '•', title: '无序列表', run: toggleBlock('bullet') },
{ label: '1.', title: '有序列表', run: toggleBlock('ordered') },
{ label: '☐', title: '任务列表', run: toggleBlock('task') },
{ label: '❝', title: '引用', run: toggleBlock('quote') },
{ label: '`', title: '行内代码', run: toggleCode, cls: 'mono' }, { label: '`', title: '行内代码', run: toggleCode, cls: 'mono' },
{ label: '↗', title: '链接', run: insertLink }, { label: '↗', title: '链接', run: insertLink },
{ label: '▧', title: '插入图片', run: () => insertImagesFromPicker() },
{ label: '⇥', title: '缩进', run: indentMore },
{ label: '⇤', title: '取消缩进', run: indentLess },
{ label: '↶', title: '撤销', run: undo },
{ label: '↷', title: '重做', run: redo },
] ]
function run(fn: (v: EditorView) => unknown) { function run(fn: (v: EditorView) => unknown) {
@@ -40,10 +40,6 @@ function run(fn: (v: EditorView) => unknown) {
if (v) fn(v) if (v) fn(v)
} }
function hideKeyboard() {
editorView()?.contentDOM.blur()
}
// 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断 // 焦点从编辑器移到别处时,focusout 先于新元素获得焦点,等一帧再判断
function sync() { function sync() {
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
@@ -89,24 +85,20 @@ watch(() => [ws.active, activeTab()?.status, settings.selectionToolbar, settings
<div v-if="visible" class="rm-format-bar" role="toolbar" aria-label="格式"> <div v-if="visible" class="rm-format-bar" role="toolbar" aria-label="格式">
<div class="rm-format-scroll"> <div class="rm-format-scroll">
<button <button
v-for="b in BUTTONS" v-for="b in FORMAT_BUTTONS"
:key="b.title" :key="b.title"
type="button" type="button"
tabindex="-1" tabindex="-1"
:class="b.cls"
:title="b.title"
:aria-label="b.title" :aria-label="b.title"
@mousedown.prevent @mousedown.prevent
@click="run(b.run)" @click="run(b.run)"
> >
{{ b.label }} <Icon :name="b.icon" class="rm-format-icon" />
<span class="rm-format-label" aria-hidden="true">{{ b.title }}</span>
</button> </button>
</div> </div>
<button type="button" tabindex="-1" v-if="coarse.matches" class="rm-format-hide" title="收起键盘" aria-label="收起键盘" @mousedown.prevent @click="hideKeyboard">
<Icon name="chevron" class="down" />
</button>
</div> </div>
<div ref="floatingToolbar" v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }"> <div ref="floatingToolbar" v-if="floating && ws.view === 'notes'" class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式" :style="{ left: floating.left + 'px', top: floating.top + 'px' }">
<button v-for="b in BUTTONS.slice(0, 5).concat(BUTTONS.filter(button => button.title === '行内代码' || button.title === '链接'))" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button> <button v-for="b in SELECTION_BUTTONS" :key="b.title" :title="b.title" :aria-label="b.title" :class="b.cls" @mousedown.prevent @click="run(b.run)">{{ b.label }}</button>
</div> </div>
</template> </template>
-26
View File
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { metadata } from './metadata'
import { openReference, ws } from './workspace'
const incoming = computed(() => metadata.notes.flatMap(note => note.links.filter(link => link.target === ws.active).map(link => ({ ...link, path: note.path, title: note.title }))))
const note = computed(() => metadata.notes.find(note => note.path === ws.active))
</script>
<template>
<section class="rm-note-relations" aria-label="笔记关联">
<div v-if="note?.tags.length" class="rm-note-tags"><span v-for="tag in note.tags" :key="tag">#{{ tag }}</span></div>
<h3>反向链接 <small>{{ incoming.length }}</small></h3>
<p v-if="metadata.error" role="alert">{{ metadata.error }}</p>
<p v-else-if="!metadata.ready">关系索引准备中…</p>
<button v-for="link in incoming" :key="link.path + ':' + link.line" @click="openReference(link.path, link.line)"><strong>{{ link.title }}</strong><small>{{ link.path }} · 第 {{ link.line }} 行</small></button>
<p v-if="metadata.ready && !incoming.length">暂无其他笔记链接到这里</p>
<p v-for="warning in note?.warnings" :key="warning" role="alert">{{ warning }}</p>
</section>
</template>
<style scoped>
.rm-note-relations { padding: 20px 16px; border-top: 1px solid var(--rm-divider); overflow: auto; max-height: 40%; }
h3 { font-size: var(--rm-ui-font-size); margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
p { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); line-height: 1.8; }
button { display: flex; flex-direction: column; gap: 5px; width: 100%; text-align: left; padding: 9px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
button:hover { background: var(--rm-hover); } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: var(--rm-ui-font-caption); padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
</style>
-2
View File
@@ -4,7 +4,6 @@ import type { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import NoteRelations from './NoteRelations.vue'
import { activeTab, editorView, onEditorUpdate, ws } from './workspace' import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节 // 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
@@ -103,7 +102,6 @@ const minLevel = () => Math.min(...headings.value.map((h) => h.level))
</li> </li>
</ul> </ul>
<p v-else class="rm-tree-empty">没有标题</p> <p v-else class="rm-tree-empty">没有标题</p>
<NoteRelations />
</aside> </aside>
</div> </div>
</template> </template>
+1 -1
View File
@@ -47,7 +47,7 @@ function toggle(plugin: InstalledPlugin) {
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button> <button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" /> <input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
</div> </div>
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。分享页不加载外部插件。</p> <p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。</p>
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p> <p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">{{ error || pluginState.error }}</p>
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p> <p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">尚未安装插件。</p>
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row"> <div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from './Icon.vue'
import { SETTING_ITEMS, type SettingId } from './settingsCatalog'
const props = defineProps<{ id: SettingId; control?: string; resettable?: boolean }>()
defineEmits<{ reset: [] }>()
const item = computed(() => SETTING_ITEMS[props.id])
</script>
<template>
<div :id="'rm-setting-' + id" class="rm-setting" tabindex="-1">
<div class="rm-setting-info">
<component :is="control ? 'label' : 'span'" :for="control" class="rm-setting-label">{{ item.title }}</component>
<p>{{ item.description }}</p>
</div>
<div class="rm-setting-control">
<slot />
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="'重置' + item.title" :aria-label="'重置' + item.title" @click="$emit('reset')"><Icon name="restore" /></button>
</div>
</div>
</template>
+104 -111
View File
@@ -1,31 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api' import { api, ApiError } from './api'
import { runCommand } from './commands' import { runCommand } from './commands'
import Icon, { type IconName } from './Icon.vue' import Icon from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue' import AppearanceSettings from './AppearanceSettings.vue'
import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography' import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography'
import PluginsPanel from './PluginsPanel.vue' import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa' import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings' import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './settingsCatalog'
import ShortcutSettings from './ShortcutSettings.vue' import ShortcutSettings from './ShortcutSettings.vue'
import SharePanel from './SharePanel.vue'
import TokensPanel from './TokensPanel.vue' import TokensPanel from './TokensPanel.vue'
import TrashPanel from './TrashPanel.vue' import TrashPanel from './TrashPanel.vue'
import { toast, ws, type SettingsSection } from './workspace' import { toast, ws, type SettingsSection } from './workspace'
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
{ id: 'appearance', title: '常规与外观', icon: 'palette' },
{ id: 'editor', title: '编辑器与模板', icon: 'edit' },
{ id: 'plugins', title: '插件', icon: 'command' },
{ id: 'account', title: '账户', icon: 'user' },
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
{ id: 'shares', title: '分享链接', icon: 'link' },
{ id: 'tokens', title: 'API 令牌', icon: 'key' },
{ id: 'trash', title: '回收站', icon: 'trash' },
{ id: 'about', title: '关于', icon: 'info' },
]
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
if (!buildVersion) throw new Error('缺少应用构建版本信息') if (!buildVersion) throw new Error('缺少应用构建版本信息')
@@ -34,11 +23,44 @@ const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.sett
const dailyTemplates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.endsWith('.md'))) const dailyTemplates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.endsWith('.md')))
const missingDailyTemplate = computed(() => settings.dailyTemplate && !dailyTemplates.value.some(entry => entry.path === settings.dailyTemplate)) const missingDailyTemplate = computed(() => settings.dailyTemplate && !dailyTemplates.value.some(entry => entry.path === settings.dailyTemplate))
const showList = ref(ws.settings === 'index') const showList = ref(ws.settings === 'index')
const current = computed(() => SECTIONS.find((s) => s.id === section.value)!) const current = computed(() => SETTINGS_SECTIONS.find((s) => s.id === section.value)!)
const query = ref('')
const searching = computed(() => !!query.value.trim())
const results = computed(() => searchSettings(query.value))
const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const preferenceSection = computed(() => ['appearance', 'editor', 'templates', 'shortcuts'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
function open(id: SettingsSection) { async function open(id: SettingsSection) {
query.value = ''
ws.settings = id ws.settings = id
showList.value = false showList.value = false
await nextTick()
content.value!.scrollTop = 0
heading.value!.focus({ preventScroll: true })
}
async function locate(result: SettingSearchResult) {
await open(result.section)
if (!result.id) return
const target = document.getElementById('rm-setting-' + result.id)!
// 设置可能位于 CSS 或排版预览的折叠区内。
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
if (parent instanceof HTMLDetailsElement) parent.open = true
}
if (target instanceof HTMLDetailsElement) target.open = true
await nextTick()
target.focus({ preventScroll: true })
target.scrollIntoView({ block: 'center' })
}
function back() {
query.value = ''
showList.value = true
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
} }
function close() { function close() {
@@ -46,15 +68,25 @@ function close() {
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !e.isComposing) close() if (e.isComposing) return
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close() }
if (e.key !== 'Tab') return
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
const first = controls[0], last = controls.at(-1)
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); last?.focus()
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); first?.focus()
} }
onMounted(() => window.addEventListener('keydown', onKey)) }
onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) onMounted(() => dialog.value!.focus({ preventScroll: true }))
onBeforeUnmount(() => { if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }) })
async function change(patch: Partial<Settings>) { async function change(patch: Partial<Settings>) {
try { try {
await updateSettings(patch) await updateSettings(patch)
} catch (err) { } catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return }
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接')) toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误'))) void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误')))
} }
@@ -114,126 +146,87 @@ async function changePassword() {
</script> </script>
<template> <template>
<div class="rm-dialog-backdrop" @click.self="close"> <div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
<div class="rm-dialog rm-settings" :class="{ 'show-list': showList }" role="dialog" aria-label="设置"> <div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
<nav class="rm-settings-nav"> <nav class="rm-settings-nav" aria-label="设置分类">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<h2>偏好设置中心<small>Personal Preferences Vault</small></h2> <h2>设置</h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭" @click="close"><Icon name="close" /></button> <button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close"><Icon name="close" /></button>
</div> </div>
<button v-for="s in SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section }" @click="open(s.id)"> <label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置" /><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
<Icon :name="s.icon" />{{ s.title }} <div class="rm-settings-categories">
</button> <button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)"><Icon :name="s.icon" />{{ s.title }}</button>
<div class="rm-settings-nav-foot"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装到桌面</button><small>RawMark · {{ buildVersion }}</small></div> </div>
<div class="rm-settings-nav-foot"><span>RawMark</span><small>{{ buildVersion }}</small></div>
</nav> </nav>
<section class="rm-settings-body"> <section class="rm-settings-body">
<div class="rm-dialog-head"> <div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回" @click="showList = true"><Icon name="chevron" class="flip" /></button> <button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back"><Icon name="chevron" class="flip" /></button>
<div class="rm-settings-heading"><small>首选项 / {{ current.title }}</small><h2>{{ current.title }}</h2><p>让工作区适合你的写作习惯,修改即时保存。</p></div> <h2 ref="heading" tabindex="-1">{{ searching ? '搜索结果' : current.title }}</h2>
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button> <span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || '偏好设置保存在服务器上,所有设备共用'" role="status" aria-live="polite">{{ saveLabel }}</span>
<button class="rm-icon-btn" title="关闭设置" @click="close"><Icon name="close" /></button>
</div> </div>
<div class="rm-settings-content"> <div ref="content" class="rm-settings-content">
<div v-if="searching" class="rm-settings-results">
<p class="rm-settings-result-count" role="status">{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }}</p>
<button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)"><span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" /></button>
</div>
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" /> <PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'"> <template v-else-if="section === 'appearance'">
<h3 class="rm-settings-group-title">界面显示</h3> <SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<div class="rm-setting">
<label for="rm-ui-font-size" class="rm-setting-label">界面字号</label>
<div class="rm-ui-font-control">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label> <label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
<button class="rm-btn" aria-label="重置界面字号" @click="change({ uiFontSize: UI_FONT_SIZE.default })">重置</button> </SettingRow>
</div> <SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })"><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output></SettingRow>
</div>
<p class="rm-share-note">调整菜单、侧栏与按钮文字,不影响正文和代码字号。</p>
<AppearanceSettings /> <AppearanceSettings />
<div class="rm-setting"><label for="rm-glass-opacity" class="rm-setting-label">磨砂玻璃通透度</label><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><span>{{ Math.round(settings.glassOpacity * 100) }}%</span></div>
<h3 class="rm-settings-group-title">编辑与阅读排版</h3>
<div class="rm-setting"><label for="rm-text-font" class="rm-setting-label">正文主要字体</label><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting"><label for="rm-code-font" class="rm-setting-label">代码字体</label><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></div>
<div class="rm-setting">
<span class="rm-setting-label">编辑器字号</span>
<label class="rm-font-input"><input type="number" step="any" :value="settings.fontSize" aria-label="编辑器字号" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</div>
<div class="rm-setting">
<span class="rm-setting-label">正文宽度</span>
<div class="rm-segmented" role="radiogroup" aria-label="正文宽度">
<button
v-for="w in CONTENT_WIDTHS"
:key="w.value"
role="radio"
:aria-checked="settings.contentWidth === w.value"
:class="{ on: settings.contentWidth === w.value }"
@click="change({ contentWidth: w.value })"
>
{{ w.label }}
</button>
</div>
</div>
<div class="rm-setting"><label for="rm-line-height" class="rm-setting-label">正文行高</label><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><span>{{ settings.lineHeight.toFixed(2) }}</span></div>
<div class="rm-setting"><label for="rm-paragraph-spacing" class="rm-setting-label">段落间距</label><input id="rm-paragraph-spacing" type="range" min="0" max="40" step="2" :value="settings.paragraphSpacing" @change="change({ paragraphSpacing: Number(($event.target as HTMLInputElement).value) })" /><span>{{ settings.paragraphSpacing }}px</span></div>
<div class="rm-setting"><label for="rm-selection-toolbar" class="rm-setting-label">选中文本浮动工具栏</label><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></div>
<article class="rm-typography-preview"><small>LIVE TYPOGRAPHY PREVIEW</small><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template> </template>
<template v-else-if="section === 'editor'"> <template v-else-if="section === 'editor'">
<div class="rm-setting"> <h3 class="rm-settings-group-title">正文与代码</h3>
<label for="rm-spellcheck" class="rm-setting-label">拼写检查</label> <SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><select id="rm-text-font" :value="settings.textFont" @change="change({ textFont: ($event.target as HTMLSelectElement).value as Settings['textFont'] })"><option v-for="(font, id) in TEXT_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
<input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" /> <SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><select id="rm-code-font" :value="settings.codeFont" @change="change({ codeFont: ($event.target as HTMLSelectElement).value as Settings['codeFont'] })"><option v-for="(font, id) in CODE_FONTS" :key="id" :value="id">{{ font.label }}</option></select></SettingRow>
</div> <SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
<p class="rm-share-note">使用浏览器的拼写检查,检查语言取决于浏览器设置。</p> <SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
<div class="rm-setting"> <SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
<label for="rm-tab-size" class="rm-setting-label">制表符宽度</label> <SettingRow id="paragraph-spacing" control="rm-paragraph-spacing" resettable @reset="change({ paragraphSpacing: DEFAULTS.paragraphSpacing })"><input id="rm-paragraph-spacing" type="range" min="0" max="40" step="2" :value="settings.paragraphSpacing" @change="change({ paragraphSpacing: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-paragraph-spacing">{{ settings.paragraphSpacing }}px</output></SettingRow>
<select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"> <details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1"><summary>排版预览</summary><article class="rm-typography-preview"><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article></details>
<option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option> <h3 class="rm-settings-group-title">编辑行为</h3>
</select> <SettingRow id="selection-toolbar" control="rm-selection-toolbar"><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></SettingRow>
</div> <SettingRow id="spellcheck" control="rm-spellcheck"><input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" /></SettingRow>
<p class="rm-share-note">仅调整 Tab 字符的显示宽度,不改写正文。Tab 键仍按两个空格缩进。</p> <SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"><select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"><option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option></select></SettingRow>
<h3 class="rm-settings-group-title">日记与模板</h3> </template>
<div class="rm-setting"><label for="rm-daily-folder" class="rm-setting-label">日记目录</label><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></div>
<div class="rm-setting"><label for="rm-templates-folder" class="rm-setting-label">模板目录</label><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></div> <template v-else-if="section === 'templates'">
<div class="rm-setting"><label for="rm-daily-template" class="rm-setting-label">日记模板</label><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></div> <SettingRow id="daily-folder" control="rm-daily-folder"><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></SettingRow>
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p> <SettingRow id="templates-folder" control="rm-templates-folder"><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></SettingRow>
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p> <SettingRow id="daily-template" control="rm-daily-template"><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></SettingRow>
<p class="rm-share-note">模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
</template> </template>
<template v-else-if="section === 'account'"> <template v-else-if="section === 'account'">
<form class="rm-auth-card rm-settings-form" @submit.prevent="changePassword"> <form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
<h3>修改登录密码</h3> <h3>修改登录密码</h3>
<p class="rm-share-note">修改后其他设备需要重新登录。</p>
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label> <label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label> <label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label> <label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
<p v-if="pwError" class="rm-auth-error">{{ pwError }}</p> <p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button> <button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
</form> </form>
<div class="rm-setting"> <SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
<span class="rm-setting-label">退出登录</span>
<button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button>
</div>
</template> </template>
<ShortcutSettings v-else-if="section === 'shortcuts'" /> <ShortcutSettings v-else-if="section === 'shortcuts'" />
<SharePanel v-else-if="section === 'shares'" :path="null" />
<TokensPanel v-else-if="section === 'tokens'" /> <TokensPanel v-else-if="section === 'tokens'" />
<TrashPanel v-else-if="section === 'trash'" /> <TrashPanel v-else-if="section === 'trash'" />
<template v-else-if="section === 'about'"> <template v-else-if="section === 'about'">
<div class="rm-setting"><h3>RawMark</h3></div> <p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
<p class="rm-share-note">纯 Markdown 笔记编辑器,文件即真源。</p> <SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
<div v-if="installPrompt" class="rm-setting"> <SettingRow id="install"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装 RawMark</button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span></SettingRow>
<span class="rm-setting-label">安装应用</span> <SettingRow id="openapi"><a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a></SettingRow>
<button class="rm-btn" @click="installApp">安装 RawMark</button>
</div>
<div class="rm-setting">
<span class="rm-setting-label">构建版本</span>
<code>{{ buildVersion }}</code>
</div>
<div class="rm-setting">
<span class="rm-setting-label">API 文档</span>
<a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a>
</div>
<p class="rm-share-note">在「API 令牌」中创建令牌后,可通过 REST API 读取或写入笔记。</p>
</template> </template>
</div> </div>
</div>
</section> </section>
</div> </div>
</div> </div>
-22
View File
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import SharePanel from './SharePanel.vue'
const props = defineProps<{ path: string | string[] }>()
const title = computed(() => typeof props.path === 'string' ? basename(props.path) : `${props.path.length} 项内容`)
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${title}”`">
<div class="rm-dialog-head">
<h2>分享“{{ title }}”</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<SharePanel :path="path" />
</div>
</div>
</template>
-105
View File
@@ -1,105 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { copyText } from '../clipboard'
import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
// path 为空时列出全部分享,否则管理此文件或文件夹的分享。
const props = defineProps<{ path: string | string[] | null }>()
const EXPIRY_OPTIONS = [
{ label: '永久有效', seconds: 0 },
{ label: '1 天', seconds: 86400 },
{ label: '7 天', seconds: 7 * 86400 },
{ label: '30 天', seconds: 30 * 86400 },
]
const shares = ref<Share[] | null>(null)
const password = ref('')
const expiresIn = ref(0)
const busy = ref(false)
const failed = ref(false)
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
const linkOf = (s: Share) => `${location.origin}/s/${encodeURIComponent(s.token)}`
const expired = (s: Share) => s.expiresAt !== 0 && s.expiresAt <= Date.now()
function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
}
async function load() {
try {
const selected = props.path
const all = await api.shares(typeof selected === 'string' ? selected : undefined)
shares.value = Array.isArray(selected) ? all.filter((share) => share.items.length === selected.length && share.items.every((item) => selected.includes(item.path))) : all
} catch {
failed.value = true
}
}
async function create() {
busy.value = true
try {
const s = await api.createShare(props.path!, password.value, expiresIn.value)
shares.value = [s, ...(shares.value ?? [])]
password.value = ''
await copy(s)
} catch (err) {
toast('创建分享失败:' + errorText(err))
} finally {
busy.value = false
}
}
async function copy(s: Share) {
toast((await copyText(linkOf(s))) ? '链接已复制' : '复制失败,请手动复制链接')
}
async function revoke(s: Share) {
if (!confirm('撤销后该链接立即失效,确定撤销?')) return
try {
await api.deleteShare(s.token)
shares.value = shares.value!.filter((x) => x.token !== s.token)
} catch (err) {
toast('撤销失败:' + errorText(err))
}
}
onMounted(load)
</script>
<template>
<div>
<form v-if="path" class="rm-share-form" @submit.prevent="create">
<input v-model="password" type="text" autocomplete="off" placeholder="访问密码(可选)" aria-label="访问密码" />
<select v-model="expiresIn" aria-label="有效期">
<option v-for="o in EXPIRY_OPTIONS" :key="o.seconds" :value="o.seconds">{{ o.label }}</option>
</select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
</form>
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。多选生成一个集合链接,部分主体删除不影响其他项。</p>
<p v-if="failed" class="rm-tree-empty">读取分享失败</p>
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
<ul v-else class="rm-trash-list">
<li v-for="s in shares ?? []" :key="s.token">
<Icon name="link" />
<div class="rm-trash-info">
<span v-if="!path" class="rm-trash-path">{{ s.items.map((item) => item.path).join('、') }}</span>
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
<span class="rm-trash-time">
{{ fmt.format(s.createdAt) }} 创建
<template v-if="s.hasPassword"> · 有密码</template>
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
<template v-if="s.exists && s.items.some((item) => !item.exists)"> · <span class="rm-share-bad">部分主体已不存在</span></template>
<template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
</span>
</div>
<button class="rm-btn" @click="copy(s)">复制链接</button>
<button class="rm-btn rm-btn-danger" @click="revoke(s)">撤销</button>
</li>
</ul>
</div>
</template>
+16 -5
View File
@@ -1,21 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands' import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands'
import { recordedKeys } from '../keys' import { recordedKeys } from '../keys'
import { loadSettings, settings, updateSettings } from './settings' import { loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
import { toast } from './workspace' import { toast } from './workspace'
import Icon from './Icon.vue' import Icon from './Icon.vue'
const commands = computed(allCommands) const commands = computed(allCommands)
const query = ref('')
const filteredCommands = computed(() => {
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
return commands.value.filter(command => {
const text = [command.title, command.id, ...commandKeys(command).map(formatKeys)].join(' ').toLocaleLowerCase()
return terms.every(term => text.includes(term))
})
})
const recording = ref<string | null>(null) const recording = ref<string | null>(null)
const recorder = ref<HTMLInputElement>() const recorder = ref<HTMLInputElement>()
const error = ref('') const error = ref('')
const busy = ref(false) const busy = ref(false)
watch(query, () => { recording.value = null; error.value = '' })
async function save(shortcuts: Record<string, string[]>) { async function save(shortcuts: Record<string, string[]>) {
busy.value = true busy.value = true
try { await updateSettings({ shortcuts }) } try { await updateSettings({ shortcuts }) }
catch (cause) { catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return }
toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause))) toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause)))
await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause))) await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause)))
} finally { busy.value = false } } finally { busy.value = false }
@@ -51,11 +61,12 @@ async function reset(command: Command) {
<template> <template>
<div class="rm-shortcut-settings"> <div class="rm-shortcut-settings">
<button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button> <div class="rm-shortcut-toolbar"><input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button></div>
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。</p> <p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。</p>
<p class="rm-settings-result-count" role="status">{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}</p>
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p> <p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
<div v-for="command in commands" :key="command.id" class="rm-shortcut-row"> <div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
<span>{{ command.title }}</span> <span class="rm-shortcut-title">{{ command.title }}<small>{{ command.id }}</small></span>
<div class="rm-shortcut-bindings"> <div class="rm-shortcut-bindings">
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip"> <span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
<kbd>{{ formatKeys(key) }}</kbd> <kbd>{{ formatKeys(key) }}</kbd>
+46 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename } from './paths' import { basename } from './paths'
import { commandTitle } from './commands' import { commandTitle } from './commands'
@@ -9,15 +9,44 @@ import SearchPanel from './SearchPanel.vue'
import { createFolder, createNote, move, openSearch, ws } from './workspace' import { createFolder, createNote, move, openSearch, ws } from './workspace'
import { sidebarViews } from '../plugins/registry' import { sidebarViews } from '../plugins/registry'
import PluginMount from './PluginMount.vue' import PluginMount from './PluginMount.vue'
import { openDailyNote } from './templates'
const emit = defineEmits<{ logout: [] }>() const emit = defineEmits<{ logout: [] }>()
const rootDrop = ref(false) const rootDrop = ref(false)
const rootMenu = ref<{ x: number; y: number } | null>(null) const rootMenu = ref<{ x: number; y: number } | null>(null)
function rootContext(event: MouseEvent) { const menuElement = ref<HTMLElement>()
async function rootContext(event: MouseEvent) {
if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return
event.preventDefault() event.preventDefault()
rootMenu.value = { x: Math.max(4, Math.min(event.clientX, innerWidth - 170)), y: Math.max(4, Math.min(event.clientY, innerHeight - 50)) } const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect()
rootMenu.value = event.type === 'click' ? { x: anchor.left, y: anchor.bottom } : { x: event.clientX, y: event.clientY }
await nextTick()
if (!rootMenu.value || !menuElement.value) return
const { width, height } = menuElement.value.getBoundingClientRect()
rootMenu.value.x = Math.max(4, Math.min(rootMenu.value.x, innerWidth - width - 4))
rootMenu.value.y = Math.max(4, Math.min(rootMenu.value.y, innerHeight - height - 4))
}
watch(rootMenu, (menu, _previous, onCleanup) => {
if (!menu) return
const close = () => { rootMenu.value = null }
const onKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') close() }
window.addEventListener('resize', close)
window.addEventListener('keydown', onKeydown)
onCleanup(() => {
window.removeEventListener('resize', close)
window.removeEventListener('keydown', onKeydown)
})
})
function runRootMenu(action: 'search' | 'tags' | 'note' | 'folder' | 'import' | 'images') {
rootMenu.value = null
if (action === 'search') openSearch()
else if (action === 'tags') { ws.view = 'tags'; ws.images = null; ws.sidebarOpen = false }
else if (action === 'note' || action === 'folder') {
selectedView.value = ''
ws.searchOpen = false
void (action === 'note' ? createNote('') : createFolder(''))
} else if (action === 'import') ws.importing = ''
else { ws.imagesAll = false; ws.images = '' }
} }
const selectedView = ref('') const selectedView = ref('')
const currentView = computed(() => sidebarViews.get(selectedView.value)) const currentView = computed(() => sidebarViews.get(selectedView.value))
@@ -41,23 +70,9 @@ function onRootDrop(e: DragEvent) {
<template> <template>
<div class="rm-sidebar-inner"> <div class="rm-sidebar-inner">
<div class="rm-vault-heading"> <div class="rm-vault-heading">
<span class="rm-vault-icon"><Icon name="folder" :size="22" /></span> <button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
<div><strong>个人知识库 · Vault</strong><small>Personal Knowledge Vault</small></div>
<button class="rm-icon-btn" title="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false"><Icon name="menu" /></button>
</div> </div>
<div class="rm-sidebar-head"> <button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">文件目录</button>
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" :title="commandTitle('app.search')" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()"><Icon name="search" /><span>搜索</span></button>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /><span>新建笔记</span></button>
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /><span>新建目录</span></button>
<button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /><span>导入文档</span></button>
</div>
<nav class="rm-knowledge-nav" aria-label="知识工具">
<button :class="{ on: ws.view === 'graph' }" @click="ws.view = 'graph'; ws.sidebarOpen = false"><Icon name="graph" />关系图谱</button>
<button :class="{ on: ws.view === 'tags' }" @click="ws.view = 'tags'; ws.sidebarOpen = false"><Icon name="tag" />标签索引</button>
<button @click="openDailyNote"><Icon name="calendar" />今日日记</button>
<button @click="ws.templateOpen = true"><Icon name="noteAdd" />从模板新建</button>
</nav>
<div class="rm-tree-label">文件目录 <span>· VAULT EXPLORER</span></div>
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图"> <nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button> <button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
<button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button> <button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">{{ pluginView.title }}</button>
@@ -70,11 +85,17 @@ function onRootDrop(e: DragEvent) {
</ul> </ul>
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p> <p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
</div> </div>
<template v-if="rootMenu"> <Teleport v-if="rootMenu" to="body">
<div class="rm-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" /> <div class="rm-menu-backdrop rm-context-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
<div class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }"><button @click="ws.imagesAll = false; ws.images = ''; rootMenu = null"><Icon name="image" />图片管理</button></div> <div ref="menuElement" class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }">
</template> <button :title="commandTitle('app.search')" @click="runRootMenu('search')"><Icon name="search" />搜索</button>
<div class="rm-vault-status"><span class="rm-avatar">RM</span><div><strong>个人工作区</strong><small>Markdown 文件 · 自动保存</small></div></div> <button @click="runRootMenu('tags')"><Icon name="tag" />标签索引</button>
<button @click="runRootMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runRootMenu('folder')"><Icon name="folderAdd" />新建目录</button>
<button @click="runRootMenu('import')"><Icon name="import" />导入文档</button>
<button @click="runRootMenu('images')"><Icon name="image" />图片管理</button>
</div>
</Teleport>
<div class="rm-sidebar-foot"> <div class="rm-sidebar-foot">
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button> <button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button> <button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
+2 -5
View File
@@ -3,7 +3,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { basename, inAssetDir, isImage, joinPath } from './paths' import { basename, inAssetDir, isImage, joinPath } from './paths'
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree' import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
import { minimalPaths } from './selection'
import { batchFiles, exportFiles } from './batch' import { batchFiles, exportFiles } from './batch'
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace' import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
@@ -80,7 +79,7 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target) if (target && target !== props.node.path) void move(props.node.path, target)
} }
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete' | 'export') { function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'delete' | 'export') {
menuOpen.value = false menuOpen.value = false
const path = props.node.path const path = props.node.path
if (action === 'note' || action === 'folder') { if (action === 'note' || action === 'folder') {
@@ -89,7 +88,6 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
} else if (action === 'import') ws.importing = path } else if (action === 'import') ws.importing = path
else if (action === 'images') { ws.imagesAll = false; ws.images = path } else if (action === 'images') { ws.imagesAll = false; ws.images = path }
else if (action === 'rename') ws.renaming = path else if (action === 'rename') ws.renaming = path
else if (action === 'share') ws.sharing = minimalPaths(selectedPaths)
else if (action === 'move') ws.moving = [...selectedPaths] else if (action === 'move') ws.moving = [...selectedPaths]
else if (action === 'export') void exportFiles([...selectedPaths]) else if (action === 'export') void exportFiles([...selectedPaths])
else void batchFiles('delete', [...selectedPaths]) else void batchFiles('delete', [...selectedPaths])
@@ -177,7 +175,7 @@ function onDrop(e: DragEvent) {
/> />
<Icon v-if="image" name="image" :size="18" /> <Icon v-if="image" name="image" :size="18" />
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span> <span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()"> <button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" /> <Icon name="more" />
</button> </button>
</div> </div>
@@ -193,7 +191,6 @@ function onDrop(e: DragEvent) {
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button> <button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button> <button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button> <button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button @click="runMenu('share')"><Icon name="link" />分享</button>
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button> <button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button> <button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div> </div>
+16 -17
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api' import { api, ApiError } from './api'
import { registerCommand } from './commands' import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue' import EditorPane from './EditorPane.vue'
import AppHeader from './AppHeader.vue'
import KnowledgePanel from './KnowledgePanel.vue' import KnowledgePanel from './KnowledgePanel.vue'
import TemplateDialog from './TemplateDialog.vue' import TemplateDialog from './TemplateDialog.vue'
import EditorChrome from './EditorChrome.vue' import EditorChrome from './EditorChrome.vue'
@@ -17,21 +16,21 @@ import NoteBanner from './NoteBanner.vue'
import OutlinePanel from './OutlinePanel.vue' import OutlinePanel from './OutlinePanel.vue'
import QuickSwitcher from './QuickSwitcher.vue' import QuickSwitcher from './QuickSwitcher.vue'
import SettingsDialog from './SettingsDialog.vue' import SettingsDialog from './SettingsDialog.vue'
import ShareDialog from './ShareDialog.vue'
import { noteTitle } from './paths' import { noteTitle } from './paths'
import { SIDEBAR_WIDTH, settings, updateSettings } from './settings' import { SIDEBAR_WIDTH, settings, SettingsAppearanceError, updateSettings } from './settings'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue' import TabBar from './TabBar.vue'
import { activeTab, flushAll, readyToReload, startWorkspace, stopWorkspace, toast, toggleSourceMode, ws } from './workspace' import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>() const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark')) const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const saving = computed(() => activeTab()?.dirty) const resizingSidebar = ref(false)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置 // 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) { function startResize(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId) handle.setPointerCapture(e.pointerId)
resizingSidebar.value = true
const startX = e.clientX const startX = e.clientX
const startWidth = settings.sidebarWidth const startWidth = settings.sidebarWidth
let width = startWidth let width = startWidth
@@ -42,10 +41,13 @@ function startResize(e: PointerEvent) {
const onUp = () => { const onUp = () => {
handle.removeEventListener('pointermove', onMove) handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp) handle.removeEventListener('pointerup', onUp)
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(() => toast('保存侧栏宽度失败')) handle.removeEventListener('lostpointercapture', onUp)
resizingSidebar.value = false
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(error => toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败'))
} }
handle.addEventListener('pointermove', onMove) handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp) handle.addEventListener('pointerup', onUp)
handle.addEventListener('lostpointercapture', onUp)
} }
async function logout() { async function logout() {
@@ -100,11 +102,10 @@ onBeforeUnmount(() => {
<template> <template>
<div <div
class="rm-shell" class="rm-shell"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'focus-mode': ws.focusMode }" :class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart" @touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove" @touchmove.passive="onTouchMove"
> >
<AppHeader />
<div class="rm-workspace"> <div class="rm-workspace">
<aside class="rm-sidebar"> <aside class="rm-sidebar">
<Sidebar @logout="logout" /> <Sidebar @logout="logout" />
@@ -113,13 +114,10 @@ onBeforeUnmount(() => {
<div class="rm-backdrop" @click="ws.sidebarOpen = false" /> <div class="rm-backdrop" @click="ws.sidebarOpen = false" />
<main class="rm-main"> <main class="rm-main">
<header class="rm-mobile-bar"> <header class="rm-mobile-bar">
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button> <button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" /></button>
<button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">{{ title }}</button> <button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">{{ title }}</button>
<span class="rm-save-state" :class="{ on: saving }" /> <button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" /></button>
<button v-if="ws.active" class="rm-icon-btn" :title="ws.sourceMode ? '关闭源码编辑' : '开启源码编辑'" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="toggleSourceMode"><Icon name="source" :size="20" /></button> <button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" /></button>
<button v-if="ws.active" class="rm-icon-btn" title="大纲" @click="ws.outlineOpen = !ws.outlineOpen"><Icon name="outline" :size="20" /></button>
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" :size="20" /></button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" :size="20" /></button>
</header> </header>
<div v-show="ws.view === 'notes' && ws.images === null" class="rm-notes-view"> <div v-show="ws.view === 'notes' && ws.images === null" class="rm-notes-view">
<TabBar /> <TabBar />
@@ -131,12 +129,13 @@ onBeforeUnmount(() => {
<KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" /> <KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" />
<ImageGallery v-if="ws.images !== null" :dir="ws.images" /> <ImageGallery v-if="ws.images !== null" :dir="ws.images" />
</main> </main>
<OutlinePanel v-if="ws.outlineOpen && ws.active && ws.view === 'notes' && ws.images === null" /> <Transition name="rm-outline-panel">
<OutlinePanel v-if="ws.outlineOpen && !ws.focusMode && ws.active && ws.view === 'notes' && ws.images === null" />
</Transition>
</div> </div>
<TemplateDialog v-if="ws.templateOpen" /> <TemplateDialog v-if="ws.templateOpen" />
<QuickSwitcher v-if="ws.switcher !== null" /> <QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" /> <SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" /> <ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageDialog v-if="ws.image" :path="ws.image" /> <ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" /> <MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
-19
View File
@@ -53,18 +53,6 @@ export interface SearchResult {
snippet: string snippet: string
} }
export interface Share {
items: { id: number; path: string; exists: boolean }[]
token: string
path?: string
createdAt: number
// 0 为永久
expiresAt: number
hasPassword: boolean
// 分享的文件或文件夹是否仍存在
exists: boolean
}
export interface ApiToken { export interface ApiToken {
id: string id: string
name: string name: string
@@ -203,13 +191,6 @@ export const api = {
importImage: async (note: string, url: string): Promise<string> => importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link, (await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
shares: async (path?: string) =>
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
// expiresIn 为有效时长(秒),0 为永久
createShare: (path: string | string[], password: string, expiresIn: number) =>
json<Share>(request('POST', '/api/v1/shares', { json: { ...(typeof path === 'string' ? { path } : { paths: path }), password, expiresIn } })),
deleteShare: (token: string) => request('DELETE', '/api/v1/shares/' + encodeURIComponent(token)),
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens, tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
// 返回的 token 明文只有这一次 // 返回的 token 明文只有这一次
createToken: (name: string, scope: ApiToken['scope']) => createToken: (name: string, scope: ApiToken['scope']) =>
+202 -194
View File
@@ -94,38 +94,52 @@ button {
.rm-shell { display: flex; flex-direction: column; height: 100%; } .rm-shell { display: flex; flex-direction: column; height: 100%; }
.rm-workspace { display: flex; flex: 1; min-height: 0; } .rm-workspace { display: flex; flex: 1; min-height: 0; }
.rm-sidebar { .rm-sidebar {
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
position: relative; position: relative;
box-sizing: border-box;
width: var(--rm-sidebar-width); width: var(--rm-sidebar-width);
flex: none; flex: none;
overflow: clip;
background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent); background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent);
backdrop-filter: blur(18px) saturate(150%); backdrop-filter: blur(18px) saturate(150%);
border-right: 1px solid var(--rm-divider); border-right: 1px solid var(--rm-divider);
transition: width var(--rm-panel-duration) var(--rm-panel-easing), border-width var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s;
} }
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-sidebar-resize { position: absolute; top: 0; right: -3px; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; } .rm-sidebar-resize { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
.rm-sidebar-resize:hover, .rm-sidebar-resize:hover,
.rm-sidebar-resize:active { background: var(--rm-accent); opacity: 0.4; } .rm-sidebar-resize:active { background: var(--rm-accent); opacity: 0.4; }
.sidebar-collapsed .rm-sidebar { display: none; } .sidebar-collapsed .rm-sidebar,
.focus-mode .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); }
.sidebar-resizing .rm-sidebar { transition: none; }
/* ---- 大纲 ---- */ /* ---- 大纲 ---- */
.rm-outline-backdrop { display: contents; } .rm-outline-backdrop { display: flex; flex: none; width: 256px; overflow: clip; }
.rm-outline { flex: none; width: 240px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); } .rm-outline { box-sizing: border-box; flex: none; width: 256px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); color: var(--rm-text-muted); }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 8px 6px 16px; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); } .rm-outline-panel-enter-active,
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 0 6px 12px; } .rm-outline-panel-leave-active { transition: width var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-outline-list li { padding-top: 5px; padding-bottom: 5px; padding-right: 8px; border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: 1.4; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-outline-panel-enter-from,
.rm-outline-panel-leave-to { width: 0; opacity: 0; }
.rm-outline-panel-leave-active { pointer-events: none; }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-1) var(--rm-space-3); font-size: var(--rm-ui-font-size); font-weight: 500; color: var(--rm-text-muted); }
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: var(--rm-space-1) var(--rm-space-2); }
.rm-outline-list li { box-sizing: border-box; height: var(--rm-control-compact); padding: 0 var(--rm-space-2); border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: var(--rm-control-compact); cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-list li:hover { background: var(--rm-hover); } .rm-outline-list li:hover { background: var(--rm-hover); }
.rm-outline-list li.on { color: var(--rm-accent-strong); background: var(--rm-active); } .rm-outline-list li.on { color: var(--rm-text); background: var(--rm-active); font-weight: 500; }
.rm-backdrop, .rm-backdrop,
.rm-mobile-bar { display: none; } .rm-mobile-bar { display: none; }
/* ---- 侧栏 ---- */ /* ---- 侧栏 ---- */
.rm-sidebar-inner { display: flex; flex-direction: column; height: 100%; overflow-y: auto; } .rm-sidebar-inner { box-sizing: border-box; display: flex; flex-direction: column; width: calc(var(--rm-sidebar-width) - 1px); height: 100%; overflow-y: auto; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-sidebar-head { display: flex; align-items: center; gap: 2px; padding: 12px 10px 8px 16px; } .sidebar-collapsed .rm-sidebar-inner,
.focus-mode .rm-sidebar-inner { opacity: 0; transform: translateX(-12px); }
.rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); } .rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); }
.rm-tree { flex: 1; min-height: 100px; overflow: auto; padding: 0 6px 12px; } .rm-tree { flex: 1; min-height: 100px; overflow: auto; padding: 0 6px 12px; }
.rm-tree.drop { background: var(--rm-active); } .rm-tree.drop { background: var(--rm-active); }
.rm-tree-list { list-style: none; margin: 0; padding: 0; } .rm-tree-list { list-style: none; margin: 0; padding: 0; }
.rm-tree-row { .rm-tree-row {
--rm-tree-row-overlay: transparent;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -133,14 +147,14 @@ button {
height: var(--rm-control-size); height: var(--rm-control-size);
padding-right: 4px; padding-right: 4px;
border-radius: 6px; border-radius: 6px;
font-size: var(--rm-ui-font-size); font-size: inherit;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
.rm-tree-row:hover { background: var(--rm-hover); } .rm-tree-row:hover { --rm-tree-row-overlay: var(--rm-hover); background: var(--rm-hover); }
/* 长按由应用处理,不弹出 iOS 的系统菜单 */ /* 长按由应用处理,不弹出 iOS 的系统菜单 */
.rm-tree-row { -webkit-touch-callout: none; } .rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { background: var(--rm-active); color: var(--rm-accent-strong); } .rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; } .rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; } .rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform 0.15s; }
.rm-tree-chevron.open { transform: rotate(90deg); } .rm-tree-chevron.open { transform: rotate(90deg); }
@@ -158,12 +172,18 @@ button {
font: inherit; font: inherit;
outline: none; outline: none;
} }
.rm-tree-more { width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; } .rm-tree-more { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); z-index: 1; width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; pointer-events: none; }
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */
.rm-tree-row .rm-tree-more { background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg); }
.rm-tree-row:hover .rm-tree-more, .rm-tree-row:hover .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; } .rm-tree-row:focus-within .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; pointer-events: auto; }
@media (pointer: coarse), (hover: none) {
.rm-tree-more { position: static; transform: none; opacity: 1; pointer-events: auto; width: max(44px, var(--rm-control-compact)); height: max(44px, var(--rm-control-size)); }
.rm-tree-row .rm-tree-more { background: none; }
}
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; } .rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; }
/* ---- 搜索 ---- */ /* ---- 搜索 ---- */
.rm-sidebar-head .rm-icon-btn.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; } .rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.rm-search-box { .rm-search-box {
display: flex; display: flex;
@@ -185,7 +205,6 @@ button {
background: none; background: none;
color: var(--rm-text); color: var(--rm-text);
font: inherit; font: inherit;
font-size: var(--rm-ui-font-size);
outline: none; outline: none;
} }
.rm-search-input::-webkit-search-cancel-button { display: none; } .rm-search-input::-webkit-search-cancel-button { display: none; }
@@ -193,7 +212,7 @@ button {
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; } .rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rm-search-item.active { background: var(--rm-hover); } .rm-search-item.active { background: var(--rm-hover); }
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); } .rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: var(--rm-ui-font-size); overflow: hidden; white-space: nowrap; } .rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: inherit; overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); } .rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-search-snippet { .rm-search-snippet {
margin-top: 2px; margin-top: 2px;
@@ -370,9 +389,8 @@ button {
background: var(--rm-bg); background: var(--rm-bg);
box-shadow: var(--rm-shadow); box-shadow: var(--rm-shadow);
} }
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; } .rm-dialog-head { display: flex; align-items: center; padding: var(--rm-space-3) var(--rm-space-4) var(--rm-space-2); }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); } .rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); }
.rm-share-dialog { max-width: 640px; }
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; } .rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
.rm-share-form input, .rm-share-form input,
.rm-share-form select { .rm-share-form select {
@@ -390,51 +408,127 @@ button {
.rm-share-form select:focus { border-color: var(--rm-accent); } .rm-share-form select:focus { border-color: var(--rm-accent); }
.rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; } .rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-share-note a { color: var(--rm-accent-strong); } .rm-share-note a { color: var(--rm-accent-strong); }
.rm-share-link { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-bad { color: var(--rm-danger); }
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); } .rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); }
.rm-token-created p { margin: 0 0 6px; } .rm-token-created p { margin: 0 0 6px; }
.rm-token-value { display: flex; align-items: center; gap: 8px; } .rm-token-value { display: flex; align-items: center; gap: 8px; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; } .rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; }
/* ---- 手机键盘上方的格式工具条 ---- */ /* ---- 底部格式工具条 ---- */
.rm-format-bar { flex: none; display: flex; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); } .rm-format-bar { flex: none; display: flex; justify-content: center; height: var(--rm-control-size); border-top: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); }
.rm-format-scroll { flex: 1; display: flex; overflow-x: auto; scrollbar-width: none; } .rm-format-scroll { display: flex; align-items: center; }
.rm-format-scroll::-webkit-scrollbar { display: none; } .rm-format-bar button { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0; border: 0; border-radius: 5px; background: none; color: var(--rm-text); cursor: pointer; touch-action: manipulation; }
.rm-format-bar button { flex: none; min-width: var(--rm-control-compact); height: var(--rm-control-size); padding: 0 6px; border: 0; background: none; color: var(--rm-text); font-size: var(--rm-ui-font-size); touch-action: manipulation; }
.rm-format-bar button:active { background: var(--rm-hover); } .rm-format-bar button:active { background: var(--rm-hover); }
.rm-format-bar .bold { font-weight: 700; } .rm-format-icon { transform-origin: center bottom; transition: transform 160ms ease; }
.rm-format-bar .italic { font-style: italic; font-family: Georgia, serif; } .rm-format-label { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translate(-50%, 4px); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-small); line-height: 1.5; white-space: nowrap; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease, visibility 160ms; }
.rm-format-bar .strike { text-decoration: line-through; } @media (hover: hover) and (pointer: fine) {
.rm-format-bar .mono { font-family: var(--rm-font-mono); } .rm-format-bar button:hover .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-hide { border-left: 1px solid var(--rm-divider) !important; } .rm-format-bar button:hover .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
.down { transform: rotate(90deg); } }
.rm-format-bar button:focus-visible .rm-format-icon { transform: translateY(-4px) scale(1.3); }
.rm-format-bar button:focus-visible .rm-format-label { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
.rm-format-icon, .rm-format-label { transition: none; }
.rm-format-bar button:is(:hover, :focus-visible) .rm-format-icon { transform: none; }
}
/* ---- 设置 ---- */ /* ---- 设置 ---- */
.rm-settings { flex-direction: row; max-width: 820px; height: min(640px, 85vh); max-height: none; overflow: hidden; }
.rm-settings-nav { flex: none; width: 190px; display: flex; flex-direction: column; gap: 2px; padding: 0 8px 12px; border-right: 1px solid var(--rm-divider); overflow-y: auto; }
.rm-settings-nav .rm-dialog-head { padding-left: 12px; }
.rm-settings-close-list,
.rm-settings-back { display: none; }
.rm-settings-tab { display: flex; align-items: center; gap: 10px; height: var(--rm-control-size); padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-selection); color: var(--rm-accent-strong); font-weight: 600; }
.rm-settings-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-settings-content { flex: 1; overflow-y: auto; padding-bottom: 16px; }
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; } .rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
.rm-setting-label { font-size: var(--rm-ui-font-size); } .rm-setting-label { font-size: var(--rm-ui-font-size); }
.rm-setting select { height: var(--rm-control-size); padding: 0 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font-size: var(--rm-ui-font-size); } .rm-setting select { min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; line-height: 1.4; }
.rm-segmented { display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 8px; overflow: hidden; } .rm-segmented { flex: none; display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 6px; overflow: hidden; }
.rm-segmented button { flex: 1; height: var(--rm-control-size); padding: 0 14px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-segmented button { flex: 1 0 auto; white-space: nowrap; min-height: var(--rm-control-size); padding: 4px 10px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; cursor: pointer; }
.rm-segmented button:first-child { border-left: 0; } .rm-segmented button:first-child { border-left: 0; }
.rm-segmented button.on { background: var(--rm-accent); color: #fff; } .rm-segmented button.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-settings-form { max-width: 360px; padding: 4px 20px 16px; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-shortcuts { width: calc(100% - 40px); margin: 0 20px; border-collapse: collapse; font-size: var(--rm-ui-font-size); }
.rm-shortcuts td { padding: 8px 0; border-bottom: 1px solid var(--rm-divider); }
.rm-shortcuts td:last-child { text-align: right; }
.rm-shortcuts kbd { font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); padding: 2px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-inline-code-bg); }
.flip { transform: rotate(180deg); } .flip { transform: rotate(180deg); }
.rm-dialog-backdrop.rm-settings-backdrop { padding: 24px; background: color-mix(in srgb, var(--rm-bg) 30%, transparent); backdrop-filter: blur(4px); }
.rm-dialog.rm-settings { box-sizing: border-box; flex-direction: row; width: 100%; max-width: 960px; height: min(720px, 100%); max-height: 100%; overflow: hidden; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(20px); outline: none; }
.rm-settings-nav { box-sizing: border-box; flex: none; width: 192px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent); }
.rm-settings .rm-dialog-head { flex: none; box-sizing: border-box; min-height: 52px; gap: 8px; padding: 8px 16px; }
.rm-settings .rm-dialog-head h2 { font-size: var(--rm-ui-font-size); font-weight: 600; min-width: 0; outline: none; }
.rm-settings-close-list, .rm-settings-back { display: none; }
.rm-settings-search { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: var(--rm-control-size); margin: 0 8px 8px; padding: 0 6px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); }
.rm-settings-search > .rm-icon { flex: none; width: 16px; height: 16px; color: var(--rm-text-muted); }
.rm-settings-search input { box-sizing: border-box; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 4px 0; border: 0; outline: 0; background: none; color: var(--rm-text); font: inherit; }
.rm-settings-search input::-webkit-search-cancel-button { display: none; }
.rm-settings-search:focus-within { border-color: var(--rm-accent); }
.rm-settings-categories { min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.rm-settings-tab { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; min-height: var(--rm-control-size); margin: 2px 0; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-tab .rm-icon { flex: none; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-settings-nav-foot { margin-top: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-nav-foot small { font-size: inherit; overflow-wrap: anywhere; }
.rm-settings-body { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.rm-settings-body > .rm-dialog-head { border-bottom: 1px solid var(--rm-divider); }
.rm-settings-save { flex: none; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-save.error { color: var(--rm-danger); }
.rm-settings-content { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; padding: 0 24px 16px; }
.rm-settings-page { min-width: 0; }
.rm-settings .rm-setting { flex-wrap: wrap; gap: 12px 16px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); }
.rm-setting-info { flex: 1 1 180px; min-width: 0; }
.rm-setting-info p { margin: 4px 0 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: var(--rm-ui-line-height); overflow-wrap: anywhere; }
.rm-setting-control { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-settings .rm-setting input:not([type=checkbox],[type=range]), .rm-settings .rm-setting select { box-sizing: border-box; max-width: 260px; min-width: 0; min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings .rm-setting-control > input:not([type=checkbox],[type=range]) { width: 240px; max-width: 100%; }
.rm-settings .rm-setting select { max-width: min(260px, 100%); }
.rm-settings .rm-setting input[type=range] { width: 120px; min-width: 0; max-width: 100%; margin: 0; accent-color: var(--rm-accent); }
.rm-settings input[type=checkbox] { accent-color: var(--rm-accent); }
.rm-setting-control output { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.rm-setting-control code { overflow-wrap: anywhere; }
.rm-setting-reset { flex: none; color: var(--rm-text-muted); }
.rm-settings-group-title { margin: 20px 0 0; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
.rm-settings-group-title:first-child { margin-top: 16px; }
.rm-settings-details { margin: 12px 0; }
.rm-settings-details > summary { padding: 8px 0; min-height: var(--rm-control-size); box-sizing: border-box; cursor: pointer; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
.rm-settings :is(.rm-setting, .rm-settings-details, .rm-settings-form):focus { outline: 2px solid var(--rm-accent); outline-offset: -2px; border-radius: 6px; }
.rm-typography-preview { margin: 8px 0 0; padding: 16px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-sidebar-bg); font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text)); overflow-wrap: anywhere; }
.rm-typography-preview h2 { margin-top: 0; font-family: var(--rm-font-heading); font-size: 1.5em; }
.rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); }
.rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .9em; color: var(--rm-inline-code-text); }
.rm-settings .rm-share-note, .rm-settings .rm-appearance-note { margin: 12px 0; overflow-wrap: anywhere; }
.rm-settings .rm-share-form { flex-wrap: wrap; padding: 16px 0 0; }
.rm-settings .rm-share-form input { flex-basis: 180px; }
.rm-settings .rm-trash-list { padding: 0; }
.rm-settings .rm-trash-list li { flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); border-radius: 0; }
.rm-settings .rm-trash-info { flex-basis: 180px; }
.rm-settings .rm-token-created { margin: 12px 0; }
.rm-settings :is(.rm-snippet-row, .rm-plugin-row) { padding: 12px 0; flex-wrap: wrap; }
.rm-settings .rm-snippet-form { padding: 12px 0; }
.rm-settings .rm-plugin-view { padding: 12px 0; }
.rm-settings .rm-settings-form { box-sizing: border-box; max-width: 400px; padding: 16px 0; border: 0; border-radius: 0; background: none; box-shadow: none; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-settings-form .rm-share-note { margin: 0; }
.rm-settings-unavailable { max-width: 240px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result-count { margin: 12px 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-result:hover { background: var(--rm-hover); }
.rm-settings-result > span { flex: 1; min-width: 0; }
.rm-settings-result strong { font-weight: 500; }
.rm-settings-result small { display: block; margin-top: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-result > .rm-icon { flex: none; color: var(--rm-text-muted); }
@media (max-width: 767px) {
.rm-dialog-backdrop.rm-settings-backdrop { padding: 0; }
.rm-dialog.rm-settings { height: 100%; max-width: none; border: 0; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--rm-bg); }
.rm-settings-nav { display: none; width: 100%; border-right: 0; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings.show-list.searching { flex-direction: column; }
.rm-settings.show-list.searching .rm-settings-body { display: flex; }
.rm-settings.show-list.searching :is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) { display: none; }
.rm-settings.show-list:not(.searching) .rm-settings-nav { flex: 1; }
.rm-settings .rm-dialog-head { padding: 8px 12px; }
.rm-settings-close-list, .rm-settings-back { display: inline-flex; }
.rm-settings-content { padding: 0 16px 16px; }
.rm-settings-search { min-height: 44px; }
.rm-settings-tab { min-height: 44px; }
.rm-settings-save { max-width: 6em; }
.rm-settings .rm-setting-info { flex-basis: 100%; }
.rm-settings .rm-setting-control { width: 100%; max-width: 100%; }
.rm-setting-control > select { flex: 1 1 0; }
.rm-settings input:not([type=checkbox],[type=radio],[type=range]), .rm-settings select, .rm-settings textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; } .rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); } .rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
.rm-trash-list li:hover { background: var(--rm-hover); } .rm-trash-list li:hover { background: var(--rm-hover); }
@@ -460,28 +554,24 @@ button {
/* ---- 移动端 ---- */ /* ---- 移动端 ---- */
@media (max-width: 767px) { @media (max-width: 767px) {
/* 设置在手机上全屏,一次只显示分组列表或分组内容 */
.rm-dialog-backdrop:has(.rm-settings) { padding: 0; }
.rm-settings { max-width: none; height: 100%; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; }
.rm-settings-nav { width: auto; flex: 1; border-right: 0; display: none; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings-tab { height: 48px; font-size: var(--rm-ui-font-size); }
.rm-settings-close-list,
.rm-settings-back { display: inline-flex; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-setting { flex-wrap: wrap; }
.rm-sidebar { .rm-sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
z-index: 40; z-index: 40;
width: min(84vw, 320px); width: min(84vw, 320px);
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.22s ease; visibility: hidden;
pointer-events: none;
transition: transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s var(--rm-panel-duration);
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
} }
.sidebar-open .rm-sidebar { transform: none; box-shadow: var(--rm-shadow); } .sidebar-collapsed .rm-sidebar,
.rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity 0.22s; display: block; } .focus-mode .rm-sidebar { width: min(84vw, 320px); border-right-width: 1px; transition-delay: 0s, var(--rm-panel-duration); }
.sidebar-open .rm-backdrop { opacity: 1; pointer-events: auto; } .rm-sidebar-inner { width: 100%; }
.sidebar-collapsed .rm-sidebar-inner { opacity: 1; transform: none; }
.sidebar-open:not(.focus-mode) .rm-sidebar { transform: none; visibility: visible; pointer-events: auto; transition-delay: 0s; box-shadow: var(--rm-shadow); }
.rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing); display: block; }
.sidebar-open:not(.focus-mode) .rm-backdrop { opacity: 1; pointer-events: auto; }
.rm-tabs { display: none; } .rm-tabs { display: none; }
.rm-mobile-bar { .rm-mobile-bar {
display: flex; display: flex;
@@ -494,18 +584,12 @@ button {
} }
.rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); } .rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-collapsed .rm-sidebar { display: block; }
.rm-sidebar-resize { display: none; } .rm-sidebar-resize { display: none; }
/* 大纲在手机上为从右侧滑入的浮层 */ /* 大纲在手机上为从右侧滑入的浮层 */
.rm-outline-backdrop { display: block; position: fixed; inset: 0; z-index: 41; background: rgba(0, 0, 0, 0.3); } .rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(0, 0, 0, 0.3); }
.rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); } .rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); }
.rm-outline-list li { padding-top: 12px; padding-bottom: 12px; font-size: var(--rm-ui-font-size); }
.rm-save-state { width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: transparent; }
.rm-save-state.on { background: var(--rm-accent); }
/* 触控热区不小于 44px */ /* 触控热区不小于 44px */
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); } .rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
.rm-tree-more { opacity: 1; width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-sidebar-head .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */ /* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); } .rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-auth-card input, .rm-auth-card input,
@@ -522,7 +606,7 @@ button {
} }
/* 主题与 CSS 编辑 */ /* 主题与 CSS 编辑 */
.rm-appearance-note { margin: 8px 20px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; } .rm-appearance-note { margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4); font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; } .rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); } .rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); }
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; } .rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
@@ -543,72 +627,45 @@ button {
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); } .rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } } @media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } }
.rm-ui-font-control { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-font-input { display: inline-flex; align-items: center; gap: 6px; } .rm-font-input { display: inline-flex; align-items: center; gap: 6px; }
.rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); } .rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); } .rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
.rm-shortcut-settings { padding: 12px 20px; } .rm-shortcut-settings { padding: 16px 0 0; }
.rm-shortcut-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-shortcut-toolbar input { box-sizing: border-box; flex: 1 1 180px; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 6px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; color: var(--rm-text); background: var(--rm-bg); }
.rm-shortcut-title { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
.rm-shortcut-title small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); margin-top: 4px; }
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); } .rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); }
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } .rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; } .rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); } .rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
.rm-context-menu { position: fixed; right: auto; width: max-content; } .rm-context-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
.rm-context-menu button { flex: none; white-space: nowrap; }
.rm-context-menu-backdrop { z-index: 44; }
.rm-tree-row.selected { background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); } .rm-tree-row.selected { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); }
/* 轻玻璃工作台 */ /* 轻玻璃工作台 */
.rm-global-header { height: var(--rm-header-height); flex: none; display: flex; align-items: center; gap: 18px; padding: 0 24px; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); z-index: 10; } .rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
.rm-wordmark { flex: none; display: flex; align-items: center; gap: 10px; background: none; border: 0; padding: 0; font-family: var(--rm-font-heading); font-size: var(--rm-ui-font-title); font-weight: 600; cursor: pointer; } .rm-tree-label { flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: left; cursor: pointer; }
.rm-wordmark svg { color: var(--rm-accent); } .rm-tree-label:hover { background: var(--rm-hover); }
.rm-edition { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); padding: 4px 8px; border-radius: 5px; background: var(--rm-sidebar-bg); } .rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
.rm-primary-nav { flex: none; display: flex; align-self: stretch; gap: 18px; margin-left: 12px; } .rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
.rm-primary-nav button { display: flex; align-items: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: none; font-size: var(--rm-ui-font-size); cursor: pointer; padding: 0 3px; } .rm-sidebar-foot { border: 0; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-2); }
.rm-primary-nav .on { color: var(--rm-accent); border-bottom-color: var(--rm-accent); } .rm-foot-btn { height: var(--rm-control-size); font-size: inherit; }
.rm-global-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 10px; flex: 1; max-width: 330px; min-width: 0; margin-left: auto; padding: 8px 12px; color: var(--rm-text-muted); border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); border-radius: 8px; text-align: left; font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tabs { height: auto; min-height: 31px; padding: 2px 6px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-global-search > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-tab { box-sizing: border-box; min-width: 84px; height: max(31px, calc(var(--rm-ui-font-size) + 15px)); max-width: 196px; padding: 0 3px 0 8px; font-size: var(--rm-ui-font-size); font-weight: 500; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-global-search kbd { flex: none; font: inherit; font-size: var(--rm-ui-font-caption); margin-left: auto; opacity: .7; white-space: nowrap; }
.rm-header-actions { flex: none; display: flex; align-items: center; gap: 10px; }
.rm-header-actions .rm-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-avatar { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; border-radius: 50%; background: var(--rm-selection); color: var(--rm-accent-strong); font-size: var(--rm-ui-font-caption); font-weight: 700; }
.rm-vault-heading { display: flex; align-items: center; gap: 10px; padding: 20px 16px 10px; }
.rm-vault-heading > div { flex: 1; min-width: 0; }
.rm-vault-heading strong, .rm-vault-status strong { display: block; font-size: var(--rm-ui-font-size); font-weight: 600; }
.rm-vault-heading small, .rm-vault-status small { display: block; margin-top: 4px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); }
.rm-vault-icon { display: grid; flex: none; place-items: center; width: var(--rm-control-size); height: var(--rm-control-size); border-radius: 8px; background: var(--rm-active); color: var(--rm-accent); }
.rm-sidebar-head { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 14px 12px 22px; border-bottom: 1px solid var(--rm-divider); }
.rm-sidebar-head .rm-icon-btn { min-width: 0; width: 100%; min-height: var(--rm-control-size); height: auto; padding: 10px 6px; flex-direction: row; justify-content: flex-start; gap: 8px; font-size: var(--rm-ui-font-size); }
.rm-tree-label { padding: 22px 16px 12px; font-size: var(--rm-ui-font-small); font-weight: 600; color: var(--rm-text-muted); }
.rm-tree-label span { font-size: var(--rm-ui-font-caption); margin-left: 4px; opacity: .7; }
.rm-tree { padding: 0 10px 16px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); gap: 6px; }
.rm-tree-row.active { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-vault-status { display: flex; gap: 10px; align-items: center; padding: 14px 16px 4px; border-top: 1px solid var(--rm-divider); }
.rm-sidebar-foot { border: 0; padding: 8px 10px 12px; }
.rm-foot-btn { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-outline { width: 256px; background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); }
.rm-outline-head { padding: 18px 14px; font-size: var(--rm-ui-font-size); }
.rm-outline-list { padding: 8px 12px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: var(--rm-ui-font-size); }
.rm-outline-list li.on { box-shadow: inset 3px 0 var(--rm-accent); }
.rm-tabs { height: auto; min-height: calc(var(--rm-control-size) + 4px); padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { min-width: 0; height: var(--rm-control-size); max-width: 240px; font-size: var(--rm-ui-font-size); border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); } .rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
.rm-tab-add { margin: 4px; } .rm-tab-add { margin: 0 3px; align-self: center; }
.rm-editor-chrome { flex: none; padding: 14px 28px 16px; border-bottom: 1px solid var(--rm-divider); } .rm-tab-close { width: max(24px, calc(var(--rm-ui-font-size) + 10px)); height: max(24px, calc(var(--rm-ui-font-size) + 10px)); }
.rm-breadcrumb { min-width: 0; display: flex; align-items: center; gap: 10px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow: hidden; white-space: nowrap; } @media (pointer: coarse) {
.rm-breadcrumb .current { color: var(--rm-accent); overflow: hidden; text-overflow: ellipsis; } .rm-tab { height: max(48px, calc(var(--rm-ui-font-size) + 26px)); }
.rm-crumb-separator { opacity: .4; } .rm-tab-close { width: var(--rm-control-compact); height: var(--rm-control-compact); }
.rm-document-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin-top: 10px; } }
.rm-document-meta strong { font-weight: 500; color: var(--rm-text); } .rm-editor-chrome { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 2px; padding: 2px 6px; border-bottom: 1px solid var(--rm-divider); }
.rm-document-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 18px; } .rm-editor-chrome .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-compact); }
.rm-mode-switch { flex: none; display: inline-flex; padding: 3px; border-radius: 9px; background: var(--rm-sidebar-bg); }
.rm-mode-switch button { min-height: var(--rm-control-size); display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-mode-switch .on { background: var(--rm-active); color: var(--rm-accent-strong); box-shadow: 0 1px 4px rgba(19,27,46,.06); }
.rm-document-filename { margin-left: auto; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.focus-mode .rm-sidebar, .focus-mode .rm-outline-backdrop { display: none; }
.rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); } .rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); }
.rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); } .rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); }
.rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); } .rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
@@ -616,87 +673,38 @@ button {
.rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); } .rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); }
.rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; } .rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; }
.rm-menu button { min-height: var(--rm-control-size); } .rm-menu button { min-height: var(--rm-control-size); }
@media (max-width: 1399px) {
.rm-global-search kbd { display: none; }
}
@media (min-width: 768px) and (max-width: 1199px) { @media (min-width: 768px) and (max-width: 1199px) {
.rm-global-header { gap: 12px; padding: 0 16px; } .rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(19,27,46,.12); }
.rm-edition { display: none; }
.rm-outline-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; z-index: 41; background: rgba(19,27,46,.12); }
.rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); } .rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.rm-global-header { display: none; } .rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
.rm-editor-chrome { padding: 12px 16px; }
.rm-breadcrumb, .rm-document-actions { display: none; }
.rm-document-meta { font-size: var(--rm-ui-font-small); margin: 0; }
.rm-document-meta > :nth-child(3) { display: none; }
.rm-tree-label { padding-top: 16px; }
.rm-tree-row { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-outline { width: min(84vw, 320px); } .rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; } .rm-auth-card { padding: 28px 24px; }
} }
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; } .rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
.rm-knowledge-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--rm-ui-font-size) * 5 + 48px)), 1fr)); gap: 4px; padding: 14px 12px 0; }
.rm-knowledge-nav button { min-height: var(--rm-control-size); overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; padding: 9px 8px; font-size: var(--rm-ui-font-size); border: 0; border-radius: 7px; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-knowledge-nav button:hover, .rm-knowledge-nav .on { background: var(--rm-active); color: var(--rm-accent); }
/* 偏好设置沿用即时保存,在主画布中展开。 */
.rm-dialog-backdrop:has(.rm-settings) { top: var(--rm-header-height); padding: 28px; background: radial-gradient(ellipse at 70% 10%, var(--rm-selection), transparent 65%), var(--rm-bg); backdrop-filter: none; }
.rm-settings { max-width: 1250px; width: 100%; height: 100%; background: none; border: 0; border-radius: 0; box-shadow: none; gap: 28px; }
.rm-settings-nav { width: 228px; border: 1px solid var(--rm-divider); border-radius: 16px; padding: 18px 12px; background: color-mix(in srgb, var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(18px); }
.rm-settings-nav .rm-dialog-head { padding: 8px 10px 24px; border-bottom: 1px solid var(--rm-divider); margin-bottom: 16px; }
.rm-settings-nav h2 { font-size: var(--rm-ui-font-size); } .rm-settings-nav h2 small { display: block; font: var(--rm-ui-font-caption) var(--rm-font-text); color: var(--rm-text-muted); margin-top: 6px; }
.rm-settings-tab { flex: none; font-size: var(--rm-ui-font-size); min-height: var(--rm-control-size); color: var(--rm-text-muted); }
.rm-settings-tab.on { background: var(--rm-active); box-shadow: inset 3px 0 var(--rm-accent); }
.rm-settings-nav-foot { margin-top: auto; display: grid; gap: 12px; padding: 24px 10px 0; } .rm-settings-nav-foot small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-body > .rm-dialog-head { padding: 6px 2px 24px; align-items: flex-start; }
.rm-settings-heading small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-settings-heading h2 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); margin: 12px 0; } .rm-settings-heading p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); margin: 0; }
.rm-settings-content { border-radius: 16px; border: 1px solid var(--rm-divider); padding: 20px 8px; background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); box-shadow: 0 3px 18px rgba(19,27,46,.025); }
.rm-setting { padding: 13px 20px; font-size: var(--rm-ui-font-size); } .rm-setting-label { font-size: var(--rm-ui-font-size); } .rm-setting input[type=range] { width: 145px; accent-color: var(--rm-accent); }
.rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { max-width: 260px; min-width: 0; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings-group-title { font: 600 var(--rm-ui-font-heading) var(--rm-font-heading); border-top: 1px solid var(--rm-divider); margin: 26px 20px 10px; padding-top: 24px; }
.rm-theme-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 6px 20px 20px; }
.rm-theme-cards button { display: flex; flex-direction: column; gap: 8px; text-align: left; padding: 12px; border: 1px solid var(--rm-divider); border-radius: 12px; background: var(--rm-active); cursor: pointer; min-width: 0; }
.rm-theme-cards .on { outline: 2px solid var(--rm-accent); outline-offset: -1px; }
.rm-theme-cards strong { font-size: var(--rm-ui-font-size); font-weight: 600; } .rm-theme-cards small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); }
.rm-theme-sample { width: 100%; height: 60px; border-radius: 7px; background: #f2f3ff; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 12px; box-sizing: border-box; }
.rm-theme-sample i { width: 65%; height: 3px; background: #c7c4d8; border-radius: 4px; } .rm-theme-sample i:first-child { width: 45%; height: 5px; background: #4f46e5; } .dark .rm-theme-sample { background: #1e2022; } .dark .rm-theme-sample i:first-child { background: #42d392; } .system .rm-theme-sample { background: linear-gradient(120deg,#f2f3ff 50%,#1e2022 50%); }
.rm-typography-preview { margin: 20px; padding: 24px; border-radius: 12px; border: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); font-size: var(--rm-font-size); line-height: var(--rm-line-height); }
.rm-typography-preview > small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .12em; } .rm-typography-preview h2 { font-family: var(--rm-font-heading); font-size: 1.5em; } .rm-typography-preview p { margin-bottom: var(--rm-paragraph-spacing); } .rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .75em; color: var(--rm-inline-code-text); }
@media(max-width: 767px) {
.rm-dialog-backdrop:has(.rm-settings) { top: 0; padding: 0; }
.rm-settings { gap: 0; } .rm-settings-nav { width: auto; border-radius: 0; padding: 20px 16px; } .rm-settings-body { padding: 16px; } .rm-settings-content { padding: 12px 0; } .rm-settings-heading h2 { font-size: var(--rm-ui-font-heading); } .rm-settings-body > .rm-dialog-head { gap: 10px; }
.rm-theme-cards { grid-template-columns: 1fr; padding: 12px; gap: 8px; } .rm-theme-cards button { padding: 8px; }
.rm-setting { padding: 12px; } .rm-setting input:not([type=checkbox],[type=range]), .rm-setting select { font-size: max(16px, var(--rm-ui-font-size)); max-width: 100%; } .rm-settings-tab { min-height: 48px; font-size: var(--rm-ui-font-size); }
}
.rm-selection-toolbar { position: fixed; display: flex; align-items: center; box-sizing: border-box; max-width: calc(100vw - 24px); overflow-x: auto; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; } .rm-selection-toolbar { position: fixed; display: flex; align-items: center; box-sizing: border-box; max-width: calc(100vw - 24px); overflow-x: auto; padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 90%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar button { width: var(--rm-control-compact); height: var(--rm-control-compact); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; cursor: pointer; font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; } .rm-selection-toolbar button { width: var(--rm-control-compact); height: var(--rm-control-compact); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; cursor: pointer; font: var(--rm-ui-font-size) var(--rm-font-text); } .rm-selection-toolbar button:hover { background: var(--rm-selection); color: var(--rm-accent); } .rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; }
@media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 40px); height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } .rm-format-bar button { min-width: var(--rm-control-compact); height: var(--rm-control-size); font-size: var(--rm-ui-font-size); border-radius: 5px; } .rm-format-scroll { align-items: center; } } @media(min-width:768px) { .rm-format-bar { box-sizing: content-box; position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 18px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 12px; background: color-mix(in srgb, var(--rm-bg) 85%, transparent); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 15; } }
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } } @media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
.rm-typography-preview { font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-shell { position: relative; } .rm-shell { position: relative; }
.rm-global-header { box-sizing: border-box; } @media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); } }
.rm-dialog-backdrop:has(.rm-settings) { position: absolute; }
.rm-settings-heading { flex: 1; } @media (max-width: 1199px) {
.rm-appearance-advanced { margin: 4px 0 16px; } .rm-outline-panel-enter-active,
.rm-appearance-advanced > summary { margin: 0 20px 12px; padding: 10px 0; cursor: pointer; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-outline-panel-leave-active { transition-property: opacity; }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { display: none; } } .rm-outline { transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
@media(min-width:768px) and (max-width:1000px) { .rm-outline-panel-enter-from .rm-outline,
.rm-global-header { gap: 8px; padding: 0 12px; } .rm-outline-panel-leave-to .rm-outline { transform: translateX(100%); }
.rm-global-search, .rm-header-actions .rm-avatar { display: none; } .rm-outline-panel-enter-from,
.rm-header-actions { margin-left: auto; gap: 6px; } .rm-outline-panel-leave-to { width: auto; }
.rm-primary-nav { margin-left: auto; gap: 8px; }
.rm-wordmark { font-size: calc(var(--rm-ui-font-size) + 8px); }
} }
@media(max-width:360px) { @media (prefers-reduced-motion: reduce) {
.rm-sidebar-head .rm-icon-btn { flex-direction: column; justify-content: center; gap: 6px; } :root { --rm-panel-duration: 0ms; }
} }
.rm-setting select { height: auto; min-height: var(--rm-control-size); line-height: 1.4; }
@media (max-width: 767px), (pointer: coarse) { @media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); } input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
} }
+175
View File
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api, ApiError } from './api'
import { allCommands, commandKeys, formatKeys, registerCommand } from './commands'
import SettingsDialog from './SettingsDialog.vue'
import ShortcutSettings from './ShortcutSettings.vue'
import { DEFAULTS, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings } from './settings'
import { searchSettings } from './settingsCatalog'
import { ws } from './workspace'
let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
Object.assign(settingsSave, { pending: 0, result: 'idle', error: '' })
vi.spyOn(api, 'patchSettings').mockResolvedValue({})
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'themes').mockResolvedValue([{ id: 'default', name: '默认', version: '1' }])
})
afterEach(() => {
app?.unmount()
app = undefined
host?.remove()
cleanup.splice(0).forEach(fn => fn())
ws.settings = null
vi.restoreAllMocks()
})
function mount(component: typeof SettingsDialog | typeof ShortcutSettings) {
host = document.createElement('div')
document.body.append(host)
app = createApp(component)
app.mount(host)
}
async function search(selector: string, query: string) {
const input = host!.querySelector<HTMLInputElement>(selector)!
input.value = query
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
describe('设置查找与重置', () => {
it('设置和命令不再提供分享入口', async () => {
expect(searchSettings('分享')).toEqual([])
expect(allCommands().map(command => command.id)).not.toContain('note.share')
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
ws.settings = 'appearance'
mount(SettingsDialog)
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9)
expect(host!.textContent).not.toContain('分享')
})
it('按名称、关键词及多词匹配,只索引静态说明', () => {
expect(searchSettings(' ')).toEqual([])
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'tokens', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })]))
settings.dailyFolder = 'private-note-folder'
settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }]
expect(searchSettings('private')).toEqual([])
})
it('从搜索结果跳转设置行,并展开所在折叠区', async () => {
ws.settings = 'editor'
mount(SettingsDialog)
await search('input[aria-label="搜索设置"]', 'CSS 高级')
host!.querySelector<HTMLButtonElement>('.rm-settings-result')!.click()
await vi.waitFor(() => expect(document.activeElement?.id).toBe('rm-setting-css-snippets'))
expect(ws.settings).toBe('appearance')
expect(host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open).toBe(true)
expect(host!.querySelector<HTMLInputElement>('input[aria-label="搜索设置"]')!.value).toBe('')
})
it('单项重置只提交对应键,不影响另一类字号,并可重新读取', async () => {
settings.uiFontSize = 20
settings.fontSize = 24
settings.lineHeight = 2
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLButtonElement>('button[aria-label="重置界面字号"]')!.click()
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledWith({ uiFontSize: DEFAULTS.uiFontSize }))
expect(settings.fontSize).toBe(24)
const editor = [...host!.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent === '编辑器')!
editor.click()
await nextTick()
host!.querySelector<HTMLButtonElement>('button[aria-label="重置正文行高"]')!.click()
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenLastCalledWith({ lineHeight: DEFAULTS.lineHeight }))
expect(settings.fontSize).toBe(24)
vi.mocked(api.settings).mockResolvedValue({ uiFontSize: DEFAULTS.uiFontSize, fontSize: 24, lineHeight: DEFAULTS.lineHeight })
await loadSettings()
expect(settings.fontSize).toBe(24)
expect(settings.lineHeight).toBe(DEFAULTS.lineHeight)
})
})
describe('保存反馈', () => {
it('所有排队请求完成之前保留保存中,不提前发送后续请求', async () => {
let finish!: (value: Record<string, unknown>) => void
vi.mocked(api.patchSettings).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const first = updateSettings({ uiFontSize: 18 })
const second = updateSettings({ fontSize: 22 })
expect(settingsSave.pending).toBe(2)
await vi.waitFor(() => expect(api.patchSettings).toHaveBeenCalledTimes(1))
expect(settingsSave.result).toBe('idle')
finish({})
await Promise.all([first, second])
expect(api.patchSettings).toHaveBeenNthCalledWith(2, { fontSize: 22 })
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved', error: '' })
})
it('失败可见,重新读取不冒充保存成功,后续保存仍可执行', async () => {
vi.mocked(api.patchSettings).mockRejectedValueOnce(new ApiError(500, '写入失败'))
await expect(updateSettings({ uiFontSize: 18 })).rejects.toThrow('写入失败')
expect(settingsSave).toMatchObject({ pending: 0, result: 'error', error: '写入失败' })
await loadSettings()
expect(settingsSave.result).toBe('error')
await updateSettings({ uiFontSize: 16 })
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved', error: '' })
})
it('写入成功但主题加载失败,保留保存结果和原始错误', async () => {
const cause = new ApiError(503, '主题暂时不可用')
vi.spyOn(api, 'theme').mockRejectedValueOnce(cause)
const failure = await updateSettings({ theme: 'unavailable' }).catch(error => error)
expect(failure).toBeInstanceOf(SettingsAppearanceError)
expect(failure.cause).toBe(cause)
expect(failure.message).toContain('设置已保存,但主题应用失败')
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved' })
})
it('CSS 已写入但主题应用失败时结束编辑,不把成功的写入留作重试', async () => {
settings.theme = 'broken-theme'
vi.spyOn(api, 'theme').mockRejectedValue(new ApiError(503, '主题暂时不可用'))
ws.settings = 'appearance'
mount(SettingsDialog)
host!.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open = true
const add = [...host!.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent === '新建片段')!
add.click()
await nextTick()
await search('input[aria-label="片段名称"]', '主题失败时的片段')
const css = host!.querySelector<HTMLTextAreaElement>('textarea')!
css.value = ':root { --test-marker: 1; }'
css.dispatchEvent(new Event('input', { bubbles: true }))
host!.querySelector<HTMLFormElement>('.rm-snippet-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(host!.querySelector('.rm-snippet-form')).toBeNull())
expect(settings.cssSnippets).toHaveLength(1)
expect(api.patchSettings).toHaveBeenCalledTimes(1)
expect(settingsSave.result).toBe('saved')
})
})
describe('快捷键查找', () => {
it('过滤列表后仍检查隐藏命令的冲突,可按 ID 和组合键查找', async () => {
const occupied = { id: 'plugin:settings-test:hidden', title: '隐藏占用命令', keys: ['Mod+Alt+Shift+9'], run() {} }
cleanup.push(registerCommand({ id: 'settings-test.target', title: '审查定位命令', run() {} }), registerCommand(occupied))
mount(ShortcutSettings)
await search('input[aria-label="搜索快捷键"]', '审查定位命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
const add = [...host!.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent === '添加')!
add.click()
await nextTick()
host!.querySelector('[data-shortcut-recorder]')!.dispatchEvent(new KeyboardEvent('keydown', { key: '9', code: 'Digit9', ctrlKey: true, altKey: true, shiftKey: true, bubbles: true, cancelable: true }))
await nextTick()
expect(host!.querySelector('[role="alert"]')!.textContent).toContain('隐藏占用命令')
expect(api.patchSettings).not.toHaveBeenCalled()
await search('input[aria-label="搜索快捷键"]', 'plugin:settings-test:hidden')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
expect(host!.querySelector('[data-shortcut-recorder]')).toBeNull()
await search('input[aria-label="搜索快捷键"]', formatKeys(commandKeys(occupied)[0]))
expect(host!.textContent).toContain('隐藏占用命令')
await search('input[aria-label="搜索快捷键"]', '不存在的命令')
expect(host!.querySelectorAll('.rm-shortcut-row')).toHaveLength(0)
expect(host!.textContent).toContain('没有匹配的快捷键')
})
})
+23 -3
View File
@@ -17,12 +17,20 @@ export const CONTENT_WIDTHS = [
export const SIDEBAR_WIDTH = { min: 200, max: 480 } export const SIDEBAR_WIDTH = { min: 200, max: 480 }
const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 } export const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> } export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings' const CACHE_KEY = 'rawmark.settings'
export const settings = reactive<Settings>(sanitize(JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}'))) export const settings = reactive<Settings>(sanitize(JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}')))
// 只记录写入结果;重新读取设置和主题应用不代表保存成功。
export const settingsSave = reactive({ pending: 0, result: 'idle' as 'idle' | 'saved' | 'error', error: '' })
export class SettingsAppearanceError extends Error {
constructor(cause: unknown) {
super('设置已保存,但主题应用失败:' + (cause instanceof Error ? cause.message : String(cause)), { cause })
}
}
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值 // 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
function sanitize(raw: Record<string, unknown>): Settings { function sanitize(raw: Record<string, unknown>): Settings {
@@ -101,12 +109,24 @@ export async function updateSettings(patch: Partial<Settings>) {
applied({ ...settings, ...patch }) applied({ ...settings, ...patch })
// 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。 // 设置请求串行提交,防止快速切换和片段编辑的旧请求覆盖新值。
await saveSettings(patch) await saveSettings(patch)
await applySettingsAppearance() try { await applySettingsAppearance() }
catch (cause) { throw new SettingsAppearanceError(cause) }
} }
let saveQueue: Promise<unknown> = Promise.resolve() let saveQueue: Promise<unknown> = Promise.resolve()
function saveSettings(patch: Partial<Settings>) { function saveSettings(patch: Partial<Settings>) {
const pending = saveQueue.then(() => api.patchSettings(patch)) settingsSave.pending++
const pending = saveQueue.then(async () => {
try {
await api.patchSettings(patch)
settingsSave.result = 'saved'
settingsSave.error = ''
} catch (cause) {
settingsSave.result = 'error'
settingsSave.error = cause instanceof Error ? cause.message : String(cause)
throw cause
} finally { settingsSave.pending-- }
})
saveQueue = pending.catch(() => {}) saveQueue = pending.catch(() => {})
return pending return pending
} }
+62
View File
@@ -0,0 +1,62 @@
import type { IconName } from './Icon.vue'
import type { SettingsSection } from './workspace'
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'templates', title: '日记与模板', icon: 'calendar', description: '日记目录、模板目录与日记模板' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
{ id: 'tokens', title: 'API 令牌', icon: 'key', description: '创建或撤销 REST API 访问令牌' },
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '修改登录密码与退出登录' },
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
]
export const SETTING_ITEMS = {
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
'glass-opacity': { section: 'appearance', title: '磨砂玻璃通透度', description: '调整玻璃面板的透明程度,保留当前主题配色。', keywords: '透明度 背景' },
'css-snippets': { section: 'appearance', title: 'CSS 片段', description: '管理自定义主题和 CSS 片段。', keywords: '样式 自定义 高级' },
'reset-appearance': { section: 'appearance', title: '恢复默认外观与排版', description: '重置主题、界面与正文排版;停用并保留 CSS 片段。', keywords: '重置 默认 恢复' },
'text-font': { section: 'editor', title: '正文主要字体', description: '用于笔记正文,不改变界面字体。', keywords: '无衬线 宋体 系统' },
'code-font': { section: 'editor', title: '代码字体', description: '用于代码块和行内代码。', keywords: '等宽 JetBrains Mono' },
'font-size': { section: 'editor', title: '编辑器字号', description: '调整正文与代码的基础字号,不影响界面字号。', keywords: '正文 大小' },
'content-width': { section: 'editor', title: '正文宽度', description: '限制正文最大宽度,或使用可用空间的全宽。', keywords: '窄 标准 宽 全宽' },
'line-height': { section: 'editor', title: '正文行高', description: '调整正文各行之间的距离。', keywords: '行距 排版' },
'paragraph-spacing': { section: 'editor', title: '段落间距', description: '调整相邻段落之间的距离。', keywords: '段距 排版' },
'selection-toolbar': { section: 'editor', title: '选中文本浮动工具栏', description: '选中文字后显示常用格式操作。', keywords: '格式 编辑' },
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
'daily-folder': { section: 'templates', title: '日记目录', description: '仓库相对目录;留空时保存在根目录,修改不会移动已有日记。', keywords: '路径 Daily' },
'templates-folder': { section: 'templates', title: '模板目录', description: '仓库相对目录,修改不会移动已有模板。', keywords: '路径 Templates' },
'daily-template': { section: 'templates', title: '日记模板', description: '日记按本地日期命名,已有日记直接打开。', keywords: '空白笔记 日期' },
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' },
'openapi': { section: 'about', title: 'API 文档', description: '在「API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' },
} satisfies Record<string, { section: SettingsSection; title: string; description: string; keywords: string }>
export type SettingId = keyof typeof SETTING_ITEMS
export interface SettingSearchResult {
section: SettingsSection
id?: SettingId
title: string
description: string
}
/** 只搜索设置说明,不读取设置值、笔记或管理列表。 */
export function searchSettings(query: string): SettingSearchResult[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!terms.length) return []
const matches = (text: string) => terms.every(term => text.toLocaleLowerCase().includes(term))
return [
...SETTINGS_SECTIONS.filter(s => matches(s.title + ' ' + s.description)).map(s => ({ section: s.id, title: s.title, description: s.description })),
...(Object.entries(SETTING_ITEMS) as [SettingId, typeof SETTING_ITEMS[SettingId]][])
.filter(([, item]) => matches(item.title + ' ' + item.description + ' ' + item.keywords))
.map(([id, item]) => ({ id, section: item.section, title: item.title, description: item.description })),
]
}
+1 -5
View File
@@ -35,7 +35,7 @@ export interface Tab {
conflictRev?: string conflictRev?: string
} }
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash' | 'plugins' | 'about' export type SettingsSection = 'appearance' | 'editor' | 'templates' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
export interface Toast { export interface Toast {
id: number id: number
@@ -63,8 +63,6 @@ export const ws = reactive({
switcher: null as string | null, switcher: null as string | null,
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组) // 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null, settings: (pluginsPaused ? 'plugins' : appearancePaused ? 'appearance' : null) as SettingsSection | 'index' | null,
// 分享当前笔记的对话框(笔记路径)
sharing: null as string | string[] | null,
// 「移动到…」对话框(笔记或文件夹路径) // 「移动到…」对话框(笔记或文件夹路径)
moving: null as string | string[] | null, moving: null as string | string[] | null,
// 导入目标目录,空字符串为根目录 // 导入目标目录,空字符串为根目录
@@ -1100,9 +1098,7 @@ function registerBuiltinCommands() {
{ id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) }, { id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode }, { id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') }, { id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
{ id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } }, { id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } },
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') }, { id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') },
]) { ]) {
registerCommand(cmd) registerCommand(cmd)
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HighlightRequest {
code: string code: string
} }
// 保留 Prism 的嵌套 token 与原文,编辑器和分享页共用官方 token 类样式。 // 保留 Prism 的嵌套 token 与原文,编辑器使用官方 token 类样式。
export type TokenRun = (string | { classes: string; content: TokenRun })[] export type TokenRun = (string | { classes: string; content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。 // 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
+1 -1
View File
@@ -462,7 +462,7 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
const name = text(label.from + 2, label.to - 2) const name = text(label.from + 2, label.to - 2)
return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n` return `<div class="footnote" id="${escapeAttr(footnoteId(name))}"><sup>${escapeHtml(name)}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
} }
// 目录占位,由分享页根据标题填充 // 目录占位,由调用方根据标题填充
case 'TableOfContents': case 'TableOfContents':
return '<nav class="toc"></nav>\n' return '<nav class="toc"></nav>\n'
// front matter 是元数据,不输出 // front matter 是元数据,不输出
+1 -1
View File
@@ -50,7 +50,7 @@ self.addEventListener('fetch', (event) => {
const { request } = event const { request } = event
const url = new URL(request.url) const url = new URL(request.url)
if (request.method !== 'GET' || url.origin !== self.location.origin) return if (request.method !== 'GET' || url.origin !== self.location.origin) return
// 只匹配构建产物与应用入口,不缓存 API、分享地址、笔记图片或插件。 // 只匹配构建产物与应用入口,不缓存 API、笔记图片或插件。
const entry = request.mode === 'navigate' && (url.pathname === '/' || url.pathname === '/index.html') const entry = request.mode === 'navigate' && (url.pathname === '/' || url.pathname === '/index.html')
if (!entry && !paths.has(url.pathname) && !url.pathname.startsWith('/assets/')) return if (!entry && !paths.has(url.pathname) && !url.pathname.startsWith('/assets/')) return
event.respondWith((async () => { event.respondWith((async () => {
-422
View File
@@ -1,422 +0,0 @@
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
import { copyText } from '../clipboard'
import { readingStats } from '../app/reading'
import { applyDisplaySettings, sanitizeDisplaySettings } from '../themes/typography'
import { requestTokens, type TokenRun } from '../highlight/client'
import { renderMarkdown } from '../markdown/html'
import { hydrate, renderMermaid } from '../markdown/render'
import { applyAppearance, sanitizeAppearance } from '../themes/appearance'
import '../themes/base.css'
import './share.css'
interface SharedNote {
title: string
dir: false
content: string
updatedAt: number
}
interface SharedDirectory {
title: string
dir: true
entries: { path: string; dir: boolean }[]
}
interface SharedFile {
title: string
dir: false
}
interface SharedCollection {
collection: true
title: string
items: { id: number; title: string; exists: boolean; dir: boolean }[]
}
const root = document.getElementById('share')!
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
const api = '/api/v1/public/shares/' + encodeURIComponent(token)
const selectedItem = new URL(location.href).searchParams.get('item')
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
function pageUrl(path: string, item: string | null = selectedItem) {
const query = new URLSearchParams()
if (item !== null) query.set('item', item)
if (path) query.set('path', path)
return location.pathname + (query.size ? '?' + query : '')
}
function downloadUrl() {
const url = new URL(api + '/assets/' + encodePath(selectedPath), location.origin)
url.searchParams.set('download', '1')
if (selectedItem !== null) url.searchParams.set('item', selectedItem)
return url.href
}
function message(text: string) {
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
}
async function load() {
let res: Response
try {
const query = new URLSearchParams()
if (selectedPath) query.set('path', selectedPath)
if (selectedItem !== null) query.set('item', selectedItem)
res = await fetch(api + (query.size ? '?' + query : ''), { credentials: 'same-origin', cache: 'no-store' })
} catch {
message('网络错误,请检查连接后刷新')
return
}
if (res.ok) {
const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile | SharedCollection
let appearanceError = false
try {
const response = await fetch(api + '/appearance', { cache: 'no-store' })
if (!response.ok) throw new Error('读取外观失败')
const appearance = await response.json() as Record<string, unknown>
applyDisplaySettings(sanitizeDisplaySettings(appearance))
applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '')
} catch {
appearanceError = true
}
if ('collection' in content) renderCollection(content)
else if (content.dir) renderDirectory(content)
else if ('content' in content) render(content)
else {
document.title = content.title
const link = Object.assign(document.createElement('a'), { href: downloadUrl(), download: content.title, textContent: '下载 ' + content.title })
root.replaceChildren(navigation(), link)
}
if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' }))
}
else if (res.status === 401) askPassword()
else if (res.status === 404) {
message(selectedPath || selectedItem !== null ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
if (selectedPath || selectedItem !== null) root.prepend(navigation())
}
else message(`加载失败(${res.status})`)
}
function askPassword(error = '') {
const form = document.createElement('form')
form.className = 'rm-share-password'
form.innerHTML = `
<p>这篇分享需要访问密码</p>
<input type="password" autocomplete="current-password" required aria-label="访问密码" />
<p class="rm-share-error"></p>
<button type="submit">查看</button>`
const input = form.querySelector('input')!
const button = form.querySelector('button')!
form.querySelector('.rm-share-error')!.textContent = error
form.addEventListener('submit', async (e) => {
e.preventDefault()
button.disabled = true
try {
const res = await fetch(api + '/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: input.value }),
})
if (res.ok) return void load()
if (res.status === 404) return message('分享不存在、已过期或已被撤销')
askPassword(res.status === 401 ? '密码错误' : res.status === 429 ? '尝试次数过多,请稍后再试' : `验证失败(${res.status})`)
} catch {
askPassword('网络错误,请重试')
}
})
root.replaceChildren(form)
input.focus()
}
const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
function navigation() {
const nav = document.createElement('nav')
nav.className = 'rm-share-nav'
nav.setAttribute('aria-label', '分享导航')
nav.append(Object.assign(document.createElement('a'), { href: pageUrl('', null), textContent: '分享首页' }))
if (selectedItem !== null) nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '主体首页' }))
const parts = selectedPath.split('/').filter(Boolean)
for (let i = 0; i < parts.length; i++) {
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
}
return nav
}
function renderCollection(collection: SharedCollection) {
document.title = collection.title
const heading = Object.assign(document.createElement('h1'), { textContent: collection.title })
const list = document.createElement('ul')
list.className = 'rm-share-files'
for (const item of collection.items) {
const row = document.createElement('li')
row.append(item.exists
? Object.assign(document.createElement('a'), { href: pageUrl('', String(item.id)), textContent: item.title + (item.dir ? '/' : '') })
: Object.assign(document.createElement('span'), { textContent: item.title + '(已不可用)' }))
list.append(row)
}
root.replaceChildren(heading, list)
}
function renderDirectory(directory: SharedDirectory) {
document.title = directory.title
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
const list = document.createElement('ul')
list.className = 'rm-share-files'
for (const entry of directory.entries) {
const li = document.createElement('li')
li.style.paddingLeft = (entry.path.split('/').length - 1) * 16 + 'px'
const path = selectedPath ? selectedPath + '/' + entry.path : entry.path
li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
list.append(li)
}
const listing = document.createElement('section')
listing.className = 'rm-share-listing'
listing.append(navigation(), heading, list)
root.replaceChildren(publicHeader(document.title), listing)
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
}
function action(text: string, title: string, run: () => void) {
const button = Object.assign(document.createElement('button'), { type: 'button', textContent: text, title })
button.addEventListener('click', run)
return button
}
function publicHeader(title: string) {
const bar = document.createElement('header')
bar.className = 'rm-public-header'
const brand = Object.assign(document.createElement('a'), { className: 'rm-public-brand', href: pageUrl('', null), textContent: 'RawMark' })
const badge = Object.assign(document.createElement('span'), { className: 'rm-public-badge', textContent: '只读知识库' })
const current = Object.assign(document.createElement('span'), { className: 'rm-public-current', textContent: title })
const controls = document.createElement('div')
controls.className = 'rm-public-actions'
const toc = action('大纲', '显示 / 隐藏文章大纲', () => { const open = root.classList.toggle('toc-open'); toc.setAttribute('aria-expanded', String(open)) })
toc.className = 'rm-reading-action rm-toc-toggle'
toc.setAttribute('aria-expanded', 'false')
const focus = action('专注', '专注阅读', () => { const on = root.classList.toggle('rm-share-focus'); focus.setAttribute('aria-pressed', String(on)); focus.textContent = on ? '退出专注' : '专注' })
focus.className = 'rm-reading-action'
focus.setAttribute('aria-pressed', 'false')
const copy = action('复制链接', '复制当前分享链接', () => { void copyText(location.href).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
controls.append(toc, focus, copy, action('打印', '打印文章', () => window.print()))
bar.append(brand, badge, current, controls)
return bar
}
let stopReading: (() => void) | undefined
function render(note: SharedNote) {
stopReading?.()
document.title = note.title
const aside = document.createElement('aside')
aside.className = 'rm-share-sidebar'
const header = document.createElement('header')
header.className = 'rm-share-header'
const article = document.createElement('article')
article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤。
article.innerHTML = renderMarkdown(note.content)
const headings = buildToc(article)
const first = article.firstElementChild
const title = first?.tagName === 'H1' ? first.textContent ?? note.title : note.title.replace(/\.md$/, '')
if (first?.tagName === 'H1') first.remove()
const stats = readingStats(article.textContent ?? '')
header.append(
Object.assign(document.createElement('span'), { className: 'rm-share-kicker', textContent: 'PUBLIC NOTE · 公开分享' }),
first?.tagName === 'H1' ? first : Object.assign(document.createElement('h1'), { textContent: title }),
Object.assign(document.createElement('p'), { className: 'rm-share-time', textContent: `更新于 ${dateFormat.format(note.updatedAt)} · ${stats.words.toLocaleString('zh-CN')} 字词 · 预计阅读 ${stats.minutes} 分钟` }),
)
const surface = document.createElement('div')
surface.className = 'rm-reading-card'
const footer = Object.assign(document.createElement('footer'), { className: 'rm-reading-footer', textContent: '由 RawMark 分享 · 内容来自源文件的最新版本' })
surface.append(header, article, footer)
const layout = document.createElement('div')
layout.className = 'rm-reading-layout'
layout.append(aside, surface)
root.replaceChildren(publicHeader(title), ...(selectedPath || selectedItem !== null ? [navigation()] : []), layout)
const backdrop = action('', '关闭文章大纲', () => root.classList.remove('toc-open'))
backdrop.className = 'rm-share-sidebar-backdrop'
root.append(backdrop)
resolveImages(surface)
resolveLinks(surface)
const toc = document.createElement('nav')
toc.className = 'rm-public-toc'
toc.setAttribute('aria-label', '文章大纲')
toc.append(Object.assign(document.createElement('h2'), { textContent: '文章大纲' }))
const min = Math.min(...headings.map(h => Number(h.tagName[1])))
for (const heading of headings) {
const link = Object.assign(document.createElement('a'), { href: '#' + heading.id, textContent: heading.textContent })
link.style.paddingLeft = (Number(heading.tagName[1]) - min) * 14 + 12 + 'px'
link.addEventListener('click', () => root.classList.remove('toc-open'))
toc.append(link)
}
if (!headings.length) toc.append(Object.assign(document.createElement('p'), { textContent: '此文章没有小标题' }))
const progressLabel = Object.assign(document.createElement('div'), { className: 'rm-reading-progress-label' })
const progress = document.createElement('progress'); progress.max = 100; progress.value = 0
progress.setAttribute('aria-label', '阅读进度')
toc.append(progressLabel, progress)
aside.append(toc)
const hint = Object.assign(document.createElement('p'), { className: 'rm-reading-hint', textContent: '当前内容为只读分享,修改由知识库作者在源文件中完成。' })
aside.append(hint)
void sharedNavigation(aside)
hydrate(surface)
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch(error => {
code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error)
})
let frame = 0
function update() {
frame = 0
const rect = surface.getBoundingClientRect()
const value = Math.round(Math.max(0, Math.min(100, (innerHeight - rect.top) / Math.max(1, rect.height) * 100)))
progress.value = value
progressLabel.textContent = '阅读进度 · ' + value + '%'
let active = headings[0]
for (const heading of headings) { if (heading.getBoundingClientRect().top < 160) active = heading }
for (const link of toc.querySelectorAll('a')) { const on = link.getAttribute('href') === '#' + active?.id; link.classList.toggle('on', on); if (on) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current') }
}
const schedule = () => { if (!frame) frame = requestAnimationFrame(update) }
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
const observer = new ResizeObserver(schedule)
observer.observe(surface)
stopReading = () => { observer.disconnect(); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); cancelAnimationFrame(frame) }
schedule()
if (location.hash) {
try { const id = decodeURIComponent(location.hash.slice(1)); requestAnimationFrame(() => document.getElementById(id)?.scrollIntoView()) }
catch { root.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '链接锚点编码无效' })) }
}
}
// 目录导航只读取当前分享主体,不请求登录后的仓库元数据。
async function sharedNavigation(aside: HTMLElement) {
if (!selectedPath) return
try {
const response = await fetch(api + (selectedItem !== null ? '?item=' + encodeURIComponent(selectedItem) : ''), { cache: 'no-store' })
if (!response.ok) throw new Error(`读取分享目录失败(${response.status})`)
const directory = await response.json() as SharedNote | SharedDirectory
if (!directory.dir) return
const nav = document.createElement('nav')
nav.className = 'rm-public-file-nav'
nav.setAttribute('aria-label', '分享目录')
nav.append(Object.assign(document.createElement('h2'), { textContent: directory.title }))
for (const entry of directory.entries) {
if (entry.path.split('/').some(part => part === 'assets' || part.endsWith('.assets')) || !entry.dir && !entry.path.endsWith('.md')) continue
const link = Object.assign(document.createElement('a'), { href: pageUrl(entry.path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') })
if (entry.path === selectedPath) link.className = 'on'
nav.append(link)
}
aside.prepend(nav)
} catch (error) { aside.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: error instanceof Error ? error.message : '分享目录读取失败' })) }
}
// 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
function resourceUrl(src: string): URL | null {
try {
const prefix = new URL(api + '/assets/', location.origin)
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
if (selectedItem !== null) target.searchParams.set('item', selectedItem)
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
} catch { return null }
}
function resolveImages(article: HTMLElement) {
for (const img of article.querySelectorAll('img')) {
const src = img.getAttribute('src') ?? ''
if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) {
img.src = resourceUrl(src)?.href ?? '#'
}
img.loading = 'lazy'
}
}
function resolveLinks(article: HTMLElement) {
for (const link of article.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href')!
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#')) continue
const target = resourceUrl(href)
if (!target) { link.href = '#'; continue }
if (selectedPath) {
try {
const path = decodeURIComponent(target.pathname.slice((api + '/assets/').length)).replace(/\/$/, '')
link.href = pageUrl(path) + target.hash
} catch { link.href = '#' }
} else {
target.searchParams.set('download', '1')
link.href = target.href
link.download = ''
}
}
}
function buildToc(article: HTMLElement) {
const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')]
const used = new Set<string>()
for (const h of headings) {
const base = (h.textContent ?? '').trim().toLowerCase().replace(/\s+/g, '-') || 'section'
let id = base
for (let i = 1; used.has(id) || document.getElementById(id); i++) id = `${base}-${i}`
used.add(id)
h.id = id
}
const navs = article.querySelectorAll('nav.toc')
const top = Math.min(...headings.map((h) => Number(h.tagName[1])))
const list = document.createElement('ul')
for (const h of headings) {
const li = document.createElement('li')
li.style.paddingLeft = `${(Number(h.tagName[1]) - top) * 1.2}em`
li.append(Object.assign(document.createElement('a'), { href: '#' + h.id, textContent: h.textContent }))
list.append(li)
}
for (const nav of navs) nav.replaceChildren(list.cloneNode(true))
return headings
}
async function decorateCode(code: HTMLElement) {
const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase()
const text = (code.textContent ?? '').replace(/\n$/, '')
if (lang === 'mermaid') {
const figure = document.createElement('div')
figure.className = 'rm-share-mermaid'
figure.dataset.code = text
try {
figure.innerHTML = await renderMermaid(text)
code.parentElement!.replaceWith(figure)
} catch {
// 语法错误时保留源码
}
return
}
const pre = code.parentElement!
const tools = document.createElement('div'); tools.className = 'rm-public-code-tools'
const copy = action('复制', '复制代码', () => { void copyText(text).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
tools.append(Object.assign(document.createElement('span'), { textContent: lang || 'text' }), copy)
pre.prepend(tools)
if (!lang) return
const run = await requestTokens(lang, text)
if (!run) return
const out = document.createDocumentFragment()
function appendTokens(parent: DocumentFragment | HTMLElement, tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') parent.append(token)
else {
const span = document.createElement('span')
span.className = token.classes
appendTokens(span, token.content)
parent.append(span)
}
}
}
appendTokens(out, run)
code.replaceChildren(out)
}
window.addEventListener('rawmark-appearance', () => {
for (const el of root.querySelectorAll<HTMLElement>('.rm-share-mermaid')) {
const code = el.dataset.code
if (code !== undefined) void renderMermaid(code).then((svg) => { el.innerHTML = svg }).catch(() => { el.textContent = '图表渲染失败,请刷新重试' })
}
})
window.addEventListener('keydown', event => { if (event.key === 'Escape') root.classList.remove('toc-open') })
void load()
-162
View File
@@ -1,162 +0,0 @@
/* 只读分享页:排版与编辑器渲染效果保持一致(同一套 --rm-* 变量) */
.rm-share {
max-width: var(--rm-content-width);
margin: 0 auto;
padding: 24px 24px 80px;
font-size: var(--rm-font-size);
line-height: var(--rm-line-height);
overflow-wrap: break-word;
}
.rm-share-message { font-size: var(--rm-ui-font-size); margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
.rm-share-nav { margin-bottom: 16px; color: var(--rm-text-muted); }
.rm-share-nav a, .rm-share-files a { color: var(--rm-accent-strong); }
.rm-share-files { list-style: none; padding: 0; }
.rm-share-files li { margin: 8px 0; }
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
.rm-share-password p { margin: 0; }
.rm-share-password input {
min-width: 0;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-share-password input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-share-password button {
height: var(--rm-control-size);
border: 0;
border-radius: 8px;
background: var(--rm-accent);
color: #fff;
font-size: var(--rm-ui-font-size);
cursor: pointer;
}
.rm-share-password button:disabled { opacity: 0.6; }
.rm-share-error { min-height: 1.2em; color: #e5484d; font-size: var(--rm-ui-font-small); }
.rm-share-header {
display: flex;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--rm-divider);
color: var(--rm-text-muted);
font-size: var(--rm-font-size);
}
.rm-share-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-share-time { flex: none; }
.rm-share-content h1 { font-size: 2.2em; line-height: 1.3; margin: 0.9em 0 0.4em; }
.rm-share-content h2 { font-size: 1.75em; line-height: 1.225; padding-bottom: 0.5em; border-bottom: 1px solid var(--rm-divider); margin: 0.9em 0 0.4em; }
.rm-share-content h3 { font-size: 1.4em; line-height: 1.43; margin: 0.8em 0 0.4em; }
.rm-share-content h4 { font-size: 1.2em; margin: 0.8em 0 0.4em; }
.rm-share-content h5,
.rm-share-content h6 { font-size: 1em; margin: 0.8em 0 0.4em; }
.rm-share-content p { margin: 0.6em 0; }
.rm-share-content a { color: var(--rm-accent-strong); text-decoration: none; }
.rm-share-content a:hover { text-decoration: underline; }
.rm-share-header img,
.rm-share-content img { max-width: 100%; border-radius: 4px; }
.rm-share-content mark { background: var(--rm-highlight-bg); color: inherit; padding: 0 2px; border-radius: 2px; }
.rm-share-content hr { border: 0; border-top: 1px solid var(--rm-divider); margin: 1.5em 0; }
.rm-share-content blockquote { margin: 0.8em 0; padding: 5px 15px; border-left: 4px solid #42b983; background: rgba(66, 185, 131, 0.1); }
.rm-share-content ul,
.rm-share-content ol { padding-left: 1.6em; }
.rm-share-content li > input[type='checkbox'] { margin: 0 0.4em 0 -1.4em; accent-color: var(--rm-accent); vertical-align: -0.1em; }
.rm-share-content li:has(> input[type='checkbox']) { list-style: none; }
.rm-share-content code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.rm-share-content pre {
margin: 0.8em 0;
padding: 12px 24px;
border-radius: 8px;
background: var(--rm-code-bg);
overflow-x: auto;
line-height: 1.5;
tab-size: 4;
}
.rm-share-content pre code {
padding: 0;
background: none;
color: var(--rm-code-text);
font-size: var(--rm-code-font-size);
}
.rm-share-content table { border-collapse: collapse; display: block; max-width: 100%; width: fit-content; overflow-x: auto; margin: 0.8em 0; font-size: 0.95em; line-height: 1.5; }
.rm-share-content th,
.rm-share-content td { border: 1px solid var(--rm-divider); padding: 6px 12px; }
.rm-share-content th { font-weight: 600; background: var(--rm-inline-code-bg); }
.rm-share-content div[data-math] { margin: 0.8em 0; overflow-x: auto; }
.rm-share-mermaid { margin: 0.8em 0; text-align: center; overflow-x: auto; }
.rm-share-content nav.toc { margin: 0.8em 0; padding: 8px 16px; border-left: 3px solid var(--rm-divider); }
.rm-share-content nav.toc ul { list-style: none; margin: 0; padding: 0; }
.rm-share-content nav.toc li { margin: 2px 0; }
.rm-share-content .footnote-ref a { text-decoration: none; }
.rm-share-content .footnote { margin-top: 0.4em; font-size: 0.9em; color: var(--rm-text-muted); }
.rm-share-content .footnote:first-of-type { margin-top: 2em; padding-top: 1em; border-top: 1px solid var(--rm-divider); }
@media (max-width: 600px) {
.rm-share { padding: 16px 16px 60px; }
.rm-share-time { display: none; }
}
/* 公开阅读工作台:展示当前分享范围,不加载编辑功能。 */
html { scroll-padding-top: calc(var(--rm-header-height) + 24px); }
body { background: radial-gradient(ellipse at 25% 0,var(--rm-selection),transparent 55%), var(--rm-bg); }
.rm-share { max-width: none; padding: calc(var(--rm-header-height) + 36px) 32px 72px; font-family: var(--rm-font-editor, var(--rm-font-text)); }
.rm-public-header { box-sizing: border-box; font-size: var(--rm-ui-font-size); line-height: 1.5; position: fixed; inset: 0 0 auto; height: var(--rm-header-height); display: flex; align-items: center; gap: 20px; padding: 0 28px; z-index: 40; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(20px); font-family: var(--rm-font-text); }
.rm-public-brand { color: var(--rm-accent-strong); text-decoration: none; font: 600 var(--rm-ui-font-title) var(--rm-font-heading); }
.rm-public-badge { font-size: var(--rm-ui-font-caption); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 30px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-public-current { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-public-actions { display: flex; gap: 8px; margin-left: auto; }
.rm-public-actions button { min-height: var(--rm-control-size); border: 1px solid transparent; border-radius: 7px; background: none; color: var(--rm-text-muted); padding: 7px 10px; font: var(--rm-ui-font-size) var(--rm-font-text); cursor: pointer; white-space: nowrap; }
.rm-public-actions button:hover, .rm-public-actions button[aria-pressed=true] { background: var(--rm-hover); color: var(--rm-accent); } .rm-public-actions button:nth-last-child(2) { border-color: var(--rm-divider); }
.rm-share > .rm-share-nav { max-width: 1100px; margin: -16px auto 24px; font: var(--rm-ui-font-caption) var(--rm-font-text); }
.rm-reading-layout { display: flex; align-items: flex-start; justify-content: center; gap: 32px; max-width: 1140px; margin: auto; }
.rm-share-sidebar { position: sticky; top: calc(var(--rm-header-height) + 24px); width: 240px; flex: none; max-height: calc(100dvh - 120px); overflow: auto; font-family: var(--rm-font-text); }
.rm-public-toc, .rm-public-file-nav { padding: 20px 14px; border: 1px solid var(--rm-divider); border-radius: 13px; background: color-mix(in srgb,var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(18px); margin-bottom: 16px; }
.rm-share-sidebar h2 { font-size: var(--rm-ui-font-size); margin: 0 10px 16px; color: var(--rm-accent); font-weight: 600; }
.rm-share-sidebar a { display: block; padding: 8px 12px; text-decoration: none; color: var(--rm-text-muted); font-size: var(--rm-ui-font-size); line-height: 1.65; border-radius: 6px; overflow-wrap: anywhere; }
.rm-share-sidebar a:hover { background: var(--rm-hover); } .rm-share-sidebar a.on { color: var(--rm-accent); background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
.rm-reading-progress-label { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); border-top: 1px solid var(--rm-divider); padding-top: 18px; margin-top: 20px; } progress { display: block; width: 100%; height: 5px; margin-top: 9px; border: 0; border-radius: 6px; overflow: hidden; accent-color: var(--rm-accent); } progress::-webkit-progress-bar { background: var(--rm-divider); } progress::-webkit-progress-value { background: var(--rm-accent); } progress::-moz-progress-bar { background: var(--rm-accent); }
.rm-reading-hint { border: 1px solid var(--rm-divider); border-radius: 12px; padding: 18px; font-size: var(--rm-ui-font-caption); line-height: 1.9; color: var(--rm-text-muted); background: var(--rm-active); }
.rm-reading-card { flex: 1; min-width: 0; max-width: var(--rm-content-width); padding: 40px 44px; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 18px; background: color-mix(in srgb,var(--rm-bg) 90%,transparent); box-shadow: 0 8px 40px rgba(19,27,46,.03); }
.rm-share-header { display: block; padding-bottom: 28px; margin-bottom: 28px; border-bottom: 1px solid var(--rm-divider); }
.rm-share-kicker { display: inline-block; font: var(--rm-ui-font-caption) var(--rm-font-text); letter-spacing: .1em; color: var(--rm-accent); background: var(--rm-selection); padding: 5px 8px; border-radius: 5px; }
.rm-share-header h1 { font: 600 2.125em/1.5 var(--rm-font-heading); color: var(--rm-text); margin: 20px 0 18px; }
.rm-share-time { display: block; font: var(--rm-ui-font-small)/1.8 var(--rm-font-text); color: var(--rm-text-muted); margin: 0; }
.rm-share-content :is(h1,h2,h3,h4,h5,h6) { font-family: var(--rm-font-heading); font-weight: 600; scroll-margin-top: var(--rm-header-height); } .rm-share-content h2 { font-size: 1.5em; line-height: 1.5; margin-top: 1.5em; } .rm-share-content p { margin: 0 0 var(--rm-paragraph-spacing); } .rm-share-content blockquote { border-left-color: var(--rm-accent); background: color-mix(in srgb,var(--rm-accent) 5%,transparent); border-radius: 0 8px 8px 0; padding: 16px 20px; } .rm-share-content blockquote p:last-child { margin-bottom: 0; }
.rm-share-content pre { position: relative; border: 1px solid var(--rm-divider); border-radius: 12px; padding: calc(var(--rm-control-compact) + 20px) 20px 20px; }
.rm-public-code-tools { position: absolute; inset: 8px 12px auto; display: flex; justify-content: space-between; align-items: center; font: var(--rm-ui-font-caption) var(--rm-font-mono); color: var(--rm-text-muted); user-select: none; }
.rm-public-code-tools button { min-height: var(--rm-control-compact); color: inherit; font: inherit; background: none; border: 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; } .rm-public-code-tools button:hover { background: var(--rm-hover); color: var(--rm-accent); }
.rm-reading-footer { margin-top: 40px; border-top: 1px solid var(--rm-divider); padding-top: 24px; color: var(--rm-text-muted); font: var(--rm-ui-font-small) var(--rm-font-text); text-align: center; }
.rm-share-sidebar-backdrop { display: none; }
.rm-share-focus .rm-share-sidebar { display: none; } .rm-share-focus .rm-reading-layout { max-width: 960px; } .rm-share-focus .rm-reading-card { box-shadow: none; }
.rm-share-listing { font-size: var(--rm-ui-font-size); max-width: 900px; margin: auto; border: 1px solid var(--rm-divider); border-radius: 18px; padding: 36px; background: var(--rm-active); } .rm-share-listing h1 { font: 600 var(--rm-ui-font-title) var(--rm-font-heading); } .rm-share-listing .rm-share-nav { font-size: var(--rm-ui-font-size); } .rm-share-files li { padding: 10px; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); } .rm-share-files a { text-decoration: none; } .rm-share:has(.rm-share-listing) .rm-reading-action { display: none; }
.rm-share-password { font-size: var(--rm-ui-font-size); box-sizing: border-box; width: 100%; max-width: 340px; padding: 32px; border: 1px solid var(--rm-divider); border-radius: 18px; background: var(--rm-active); box-shadow: var(--rm-shadow); font-family: var(--rm-font-text); }
@media(min-width:1001px) { .rm-public-actions .rm-toc-toggle { display: none; } }
@media(max-width:1000px) { .rm-share { padding: calc(var(--rm-header-height) + 24px) 24px 48px; } .rm-share-sidebar { display: none; } .rm-public-current { display: none; } .toc-open .rm-share-sidebar { display: block; position: fixed; inset: var(--rm-header-height) auto 0 0; width: min(84vw, 300px); box-sizing: border-box; max-height: none; z-index: 35; padding: 20px; background: var(--rm-bg); box-shadow: var(--rm-shadow); } .toc-open .rm-share-sidebar-backdrop { display: block; position: fixed; inset: var(--rm-header-height) 0 0; border: 0; background: rgba(19,27,46,.2); z-index: 34; } }
@media(max-width:767px) { :root { --rm-header-height: calc(var(--rm-control-size) + var(--rm-ui-font-size) * 1.5 + 20px); } .rm-public-header { flex-wrap: wrap; align-content: center; padding: max(8px, env(safe-area-inset-top)) 12px 8px; gap: 4px 8px; } .rm-public-brand { font-size: var(--rm-ui-font-heading); } .rm-public-badge { display: none; } .rm-public-actions { width: 100%; justify-content: space-between; margin-left: 0; gap: 0; } .rm-public-actions button { min-width: 44px; min-height: 44px; font-size: var(--rm-ui-font-size); padding: 5px 4px; } }
@media(max-width:600px) { .rm-share { padding: calc(var(--rm-header-height) + 20px) 12px 40px; } .rm-reading-card { padding: 24px 20px; border-radius: 14px; } .rm-share-header h1 { font-size: 1.75em; } .rm-share-time { display: block; font-size: var(--rm-ui-font-small); } .rm-share-content pre { padding: calc(var(--rm-control-compact) + 20px) 16px 16px; } .rm-public-code-tools { color: #b9c2d6; } .rm-share-listing { padding: 24px 16px; } }
@media print { body { background: white; color: black; } .rm-share { padding: 0; } .rm-public-header, .rm-share-sidebar, .rm-share-nav, .rm-share-sidebar-backdrop, .rm-public-code-tools { display: none !important; } .rm-reading-layout { display: block; max-width: none; } .rm-reading-card, .rm-share-listing { border: 0; box-shadow: none; max-width: none; padding: 0; background: white; } .rm-share-content pre, .rm-share-content table { overflow: visible; } .rm-share-content pre { white-space: pre-wrap; padding: 16px; } a { color: inherit; } }
@media (max-width: 767px), (pointer: coarse) {
.rm-share-password input { font-size: max(16px, var(--rm-ui-font-size)); }
}
+27 -19
View File
@@ -10,14 +10,22 @@
--rm-font-mono: 'JetBrains Mono', ui-monospace, monospace; --rm-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--rm-font-size: 16px; --rm-font-size: 16px;
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */ /* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */
--rm-ui-font-size: 16px; --rm-ui-font-size: 14px;
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px)); --rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px)); --rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
--rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px); --rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px);
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 12px); --rm-ui-font-title: calc(var(--rm-ui-font-size) + 8px);
--rm-icon-size: calc(var(--rm-ui-font-size) + 4px); --rm-ui-line-height: 1.5;
--rm-control-size: max(40px, calc(var(--rm-ui-font-size) + 24px)); --rm-icon-size: max(16px, calc(var(--rm-ui-font-size) + 2px));
--rm-control-compact: max(32px, calc(var(--rm-ui-font-size) + 16px)); --rm-control-size: max(32px, calc(var(--rm-ui-font-size) + 18px));
--rm-control-compact: max(28px, calc(var(--rm-ui-font-size) + 14px));
--rm-space-1: 4px;
--rm-space-2: 8px;
--rm-space-3: 12px;
--rm-space-4: 16px;
--rm-space-6: 24px;
--rm-panel-duration: 220ms;
--rm-panel-easing: cubic-bezier(0.2, 0, 0, 1);
--rm-header-height: calc(var(--rm-ui-font-size) + 48px); --rm-header-height: calc(var(--rm-ui-font-size) + 48px);
--rm-line-height: 1.75; --rm-line-height: 1.75;
--rm-content-width: 760px; --rm-content-width: 760px;
@@ -99,7 +107,7 @@ body {
font-family: var(--rm-font-text); font-family: var(--rm-font-text);
} }
body { font-size: var(--rm-ui-font-size); line-height: 1.5; } body { font-size: var(--rm-ui-font-size); line-height: var(--rm-ui-line-height); }
button, input, select, textarea { font: inherit; } button, input, select, textarea { font: inherit; }
.rm-icon { flex: none; width: var(--rm-icon-size); height: var(--rm-icon-size); } .rm-icon { flex: none; width: var(--rm-icon-size); height: var(--rm-icon-size); }
@media (pointer: coarse), (max-width: 767px) { @media (pointer: coarse), (max-width: 767px) {
@@ -335,27 +343,27 @@ button, input, select, textarea { font: inherit; }
.cm-line.cm-rm-frontmatter-begin { border-radius: 6px 6px 0 0; padding-top: 4px; } .cm-line.cm-rm-frontmatter-begin { border-radius: 6px 6px 0 0; padding-top: 4px; }
.cm-line.cm-rm-frontmatter-end { border-radius: 0 0 6px 6px; padding-bottom: 4px; } .cm-line.cm-rm-frontmatter-end { border-radius: 0 0 6px 6px; padding-bottom: 4px; }
/* 浅色编辑排版与分享共用同一套 Prism token 颜色。 */ /* 浅色编辑器的 Prism token 颜色。 */
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) .token { text-shadow: none; background: none; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code .token { text-shadow: none; background: none; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.comment,.prolog,.doctype,.cdata) { color: #94a3b8; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.comment,.prolog,.doctype,.cdata) { color: #94a3b8; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.punctuation,.operator) { color: #64748b; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.punctuation,.operator) { color: #64748b; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.keyword,.atrule,.tag) { color: #7c3aed; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #7c3aed; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.string,.char,.attr-value,.builtin) { color: #059669; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #059669; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.function,.class-name) { color: #2563eb; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #2563eb; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.number,.boolean,.constant) { color: #d97706; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #d97706; }
.cm-line:is(.cm-rm-h1,.cm-rm-h2,.cm-rm-h3,.cm-rm-h4,.cm-rm-h5,.cm-rm-h6) { font-family: var(--rm-font-heading); font-weight: 600; } .cm-line:is(.cm-rm-h1,.cm-rm-h2,.cm-rm-h3,.cm-rm-h4,.cm-rm-h5,.cm-rm-h6) { font-family: var(--rm-font-heading); font-weight: 600; }
.cm-line.cm-rm-h1 { font-size: 2.125em; line-height: 1.4; } .cm-line.cm-rm-h1 { font-size: 2.125em; line-height: 1.4; }
.cm-line.cm-rm-h2 { font-size: 1.5em; line-height: 1.5; } .cm-line.cm-rm-h2 { font-size: 1.5em; line-height: 1.5; }
.cm-editor .cm-content { padding-top: 36px; } .cm-editor .cm-content { padding-top: 16px; }
.cm-rm-code-lang { font-family: var(--rm-font-mono); } .cm-rm-code-lang { font-family: var(--rm-font-mono); }
.cm-line.cm-rm-code-begin::before { border-top: 1px solid var(--rm-divider); } .cm-line.cm-rm-code-begin::before { border-top: 1px solid var(--rm-divider); }
.cm-line.cm-rm-code-end::before { border-bottom: 1px solid var(--rm-divider); } .cm-line.cm-rm-code-end::before { border-bottom: 1px solid var(--rm-divider); }
.cm-line.cm-rm-paragraph-end { padding-bottom: var(--rm-paragraph-spacing); } .cm-line.cm-rm-paragraph-end { padding-bottom: var(--rm-paragraph-spacing); }
@media (max-width: 767px) { @media (max-width: 767px) {
:root:not([data-rm-color-mode="dark"]) { --rm-code-bg: #283044; --rm-code-text: #eef0ff; } :root:not([data-rm-color-mode="dark"]) { --rm-code-bg: #283044; --rm-code-text: #eef0ff; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.keyword,.atrule,.tag) { color: #c4b5fd; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.keyword,.atrule,.tag) { color: #c4b5fd; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.string,.char,.attr-value,.builtin) { color: #6ee7b7; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.string,.char,.attr-value,.builtin) { color: #6ee7b7; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.function,.class-name) { color: #93c5fd; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.function,.class-name) { color: #93c5fd; }
:root:not([data-rm-color-mode="dark"]) :is(.cm-rm-code, .rm-share-content pre) :is(.number,.boolean,.constant) { color: #fcd34d; } :root:not([data-rm-color-mode="dark"]) .cm-rm-code :is(.number,.boolean,.constant) { color: #fcd34d; }
.cm-line.cm-rm-h1 { font-size: 1.75em; } .cm-line.cm-rm-h1 { font-size: 1.75em; }
} }
+4 -4
View File
@@ -5,8 +5,8 @@ import { applyDisplaySettings, DISPLAY_DEFAULTS, sanitizeDisplaySettings } from
afterEach(() => document.documentElement.removeAttribute('style')) afterEach(() => document.documentElement.removeAttribute('style'))
describe('界面与正文排版设置', () => { describe('界面与正文排版设置', () => {
it('旧设置缺少界面字号时使用 16px,保留正文字号', () => { it('旧设置缺少界面字号时使用 14px,保留正文字号', () => {
expect(sanitizeDisplaySettings({ fontSize: 24 })).toMatchObject({ uiFontSize: 16, fontSize: 24 }) expect(sanitizeDisplaySettings({ fontSize: 24 })).toMatchObject({ uiFontSize: 14, fontSize: 24 })
}) })
it.each([14, 16, 20])('接受范围内的整数界面字号 %s', uiFontSize => { it.each([14, 16, 20])('接受范围内的整数界面字号 %s', uiFontSize => {
@@ -14,7 +14,7 @@ describe('界面与正文排版设置', () => {
}) })
it.each([13, 21, 16.5, '18', null, Number.NaN, Number.POSITIVE_INFINITY])('拒绝非法界面字号 %s', uiFontSize => { it.each([13, 21, 16.5, '18', null, Number.NaN, Number.POSITIVE_INFINITY])('拒绝非法界面字号 %s', uiFontSize => {
expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(16) expect(sanitizeDisplaySettings({ uiFontSize }).uiFontSize).toBe(14)
}) })
it('应用界面字号不改变正文、代码字体和正文宽度', () => { it('应用界面字号不改变正文、代码字体和正文宽度', () => {
@@ -27,7 +27,7 @@ describe('界面与正文排版设置', () => {
expect(style.getPropertyValue('--rm-font-editor')).toContain('Noto Serif SC') expect(style.getPropertyValue('--rm-font-editor')).toContain('Noto Serif SC')
expect(style.getPropertyValue('--rm-font-mono')).toBe('ui-monospace, monospace') expect(style.getPropertyValue('--rm-font-mono')).toBe('ui-monospace, monospace')
applyDisplaySettings({ ...display, uiFontSize: DISPLAY_DEFAULTS.uiFontSize }) applyDisplaySettings({ ...display, uiFontSize: DISPLAY_DEFAULTS.uiFontSize })
expect(style.getPropertyValue('--rm-ui-font-size')).toBe('16px') expect(style.getPropertyValue('--rm-ui-font-size')).toBe('14px')
expect(style.getPropertyValue('--rm-font-size')).toBe('24px') expect(style.getPropertyValue('--rm-font-size')).toBe('24px')
}) })
}) })
+2 -2
View File
@@ -1,4 +1,4 @@
// 编辑器和只读分享共用可公开的排版设置;账户、快捷键等设置不进入此模块。 // 编辑器排版设置;账户、快捷键等设置不进入此模块。
export const TEXT_FONTS = { export const TEXT_FONTS = {
sans: { label: 'Manrope · 清晰无衬线', css: "'Manrope', 'PingFang SC', 'Microsoft YaHei', sans-serif" }, sans: { label: 'Manrope · 清晰无衬线', css: "'Manrope', 'PingFang SC', 'Microsoft YaHei', sans-serif" },
serif: { label: 'Noto Serif SC · 思源宋体', css: "'Noto Serif SC', 'Source Han Serif SC', serif" }, serif: { label: 'Noto Serif SC · 思源宋体', css: "'Noto Serif SC', 'Source Han Serif SC', serif" },
@@ -8,7 +8,7 @@ export const CODE_FONTS = {
jetbrains: { label: 'JetBrains Mono', css: "'JetBrains Mono', ui-monospace, monospace" }, jetbrains: { label: 'JetBrains Mono', css: "'JetBrains Mono', ui-monospace, monospace" },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' }, system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
} }
export const UI_FONT_SIZE = { min: 14, max: 20, default: 16 } export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const DISPLAY_DEFAULTS = { uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 } export const DISPLAY_DEFAULTS = { uiFontSize: UI_FONT_SIZE.default, fontSize: 16, contentWidth: 760, textFont: 'sans' as keyof typeof TEXT_FONTS, codeFont: 'jetbrains' as keyof typeof CODE_FONTS, lineHeight: 1.75, paragraphSpacing: 16, glassOpacity: .75 }
export type DisplaySettings = typeof DISPLAY_DEFAULTS export type DisplaySettings = typeof DISPLAY_DEFAULTS
export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings { export function sanitizeDisplaySettings(raw: Record<string, unknown>): DisplaySettings {
+2 -12
View File
@@ -33,21 +33,11 @@ export default defineConfig({
this.emitFile({ type: 'asset', fileName: 'sw.js', source }) this.emitFile({ type: 'asset', fileName: 'sw.js', source })
}, },
}, },
// 开发服务器中把分享地址 /s/{token} 交给分享页入口(生产环境由 Go 服务处理)
{
name: 'rawmark-share-route',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith('/s/')) req.url = '/share.html'
next()
})
},
},
], ],
build: { build: {
rolldownOptions: { rolldownOptions: {
// demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用;share.html 为只读分享页(/s/{token}) // demo.html 为免登录的编辑器演示页,供自动化性能/交互测试使用
input: { index: 'index.html', demo: 'demo.html', share: 'share.html' }, input: { index: 'index.html', demo: 'demo.html' },
}, },
}, },
worker: { format: 'es' }, worker: { format: 'es' },