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:
1 parent
a716794e41
commit
19aab631ec
63 files changed
+1077
-3343
No files matched your search
@@ -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`。
|
||||||
|
|
||||||
| 环境变量 | 默认值 | 说明 |
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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\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\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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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,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
@@ -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[:])
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 := ""
|
|
||||||
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 := "\n"
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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©.md"
|
const target = "a©.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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 := "原文 "
|
||||||
|
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("文件夹移入自身应被拒绝")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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,43 +70,39 @@ 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>
|
||||||
</div>
|
</SettingRow>
|
||||||
<p v-if="error" class="rm-appearance-note rm-auth-error">{{ error }}</p>
|
<SettingRow id="color-mode">
|
||||||
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后使用原来的明暗设置。</p>
|
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
|
||||||
<p class="rm-appearance-note">可选择内置主题,或从服务器数据目录的 themes 文件夹添加主题;主题和已启用的 CSS 也会应用到分享页。</p>
|
<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 class="rm-setting"><span class="rm-setting-label">CSS 片段</span><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></div>
|
</div>
|
||||||
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
</SettingRow>
|
||||||
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
|
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">{{ error }}</p>
|
||||||
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
|
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。</p>
|
||||||
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
|
<details class="rm-settings-details rm-appearance-advanced"><summary>自定义 CSS 与恢复</summary>
|
||||||
</div>
|
<SettingRow id="css-snippets"><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></SettingRow>
|
||||||
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
||||||
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
|
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
|
||||||
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
|
||||||
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
|
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
|
||||||
</form>
|
</div>
|
||||||
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div>
|
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
||||||
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
|
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></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>
|
||||||
|
</form>
|
||||||
|
<SettingRow id="reset-appearance"><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观与排版</button></SettingRow>
|
||||||
|
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
|
||||||
</details>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
+122
-129
@@ -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))
|
onMounted(() => dialog.value!.focus({ preventScroll: true }))
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
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,125 +146,86 @@ 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">
|
||||||
<PluginsPanel v-if="section === 'plugins'" />
|
<div v-if="searching" class="rm-settings-results">
|
||||||
<template v-else-if="section === 'appearance'">
|
<p class="rm-settings-result-count" role="status">{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }}</p>
|
||||||
<h3 class="rm-settings-group-title">界面显示</h3>
|
<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 class="rm-setting">
|
</div>
|
||||||
<label for="rm-ui-font-size" class="rm-setting-label">界面字号</label>
|
<div v-show="!searching" class="rm-settings-page">
|
||||||
<div class="rm-ui-font-control">
|
<PluginsPanel v-if="section === 'plugins'" />
|
||||||
|
<template v-else-if="section === 'appearance'">
|
||||||
|
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
|
||||||
<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>
|
<AppearanceSettings />
|
||||||
<p class="rm-share-note">调整菜单、侧栏与按钮文字,不影响正文和代码字号。</p>
|
</template>
|
||||||
<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 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>
|
|
||||||
<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>
|
|
||||||
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
|
|
||||||
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="section === 'account'">
|
<template v-else-if="section === 'templates'">
|
||||||
<form class="rm-auth-card rm-settings-form" @submit.prevent="changePassword">
|
<SettingRow id="daily-folder" control="rm-daily-folder"><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></SettingRow>
|
||||||
<h3>修改登录密码</h3>
|
<SettingRow id="templates-folder" control="rm-templates-folder"><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></SettingRow>
|
||||||
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
|
<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>
|
||||||
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
|
<p class="rm-share-note">模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
|
||||||
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
|
</template>
|
||||||
<p v-if="pwError" class="rm-auth-error">{{ pwError }}</p>
|
|
||||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
|
|
||||||
</form>
|
|
||||||
<div class="rm-setting">
|
|
||||||
<span class="rm-setting-label">退出登录</span>
|
|
||||||
<button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
<template v-else-if="section === 'account'">
|
||||||
|
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
|
||||||
|
<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.next" 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" role="alert">{{ pwError }}</p>
|
||||||
|
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
|
||||||
|
</form>
|
||||||
|
<SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
<SharePanel v-else-if="section === 'shares'" :path="null" />
|
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||||
<TokensPanel v-else-if="section === 'tokens'" />
|
<TokensPanel v-else-if="section === 'tokens'" />
|
||||||
<TrashPanel v-else-if="section === 'trash'" />
|
<TrashPanel v-else-if="section === 'trash'" />
|
||||||
<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>
|
</template>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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)); }
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 })),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;实际失败必须与纯文本结果区分。
|
||||||
|
|||||||
@@ -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 是元数据,不输出
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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
@@ -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' },
|
||||||
|
|||||||
Reference in new issue
Block a user