From eb63b14c8e261f702191720c9e02a0aff5354374 Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 17 Oct 2026 16:23:39 +0800 Subject: [PATCH] Add settings page with synced appearance and password change - Store app settings as one JSON document on the server, merged per key and pushed to other devices; cache locally to avoid a flash on load - Settings page groups appearance, account, shortcuts, shares, API tokens and trash; full screen with a section list on phones - Font size and content width settings - Change the login password, signing out other sessions - Settings writes and password changes only accept a login session --- internal/api/api.go | 21 +++-- internal/api/api_test.go | 55 ++++++++++++ internal/api/openapi.json | 159 ++++++++++++++++++++++++++++++++- internal/api/settings.go | 103 +++++++++++++++++++++ internal/api/shares.go | 7 +- internal/auth/auth.go | 26 ++++++ internal/events/hub.go | 1 + internal/store/store.go | 5 +- web/src/app/Icon.vue | 6 +- web/src/app/SettingsDialog.vue | 159 +++++++++++++++++++++++++++++++++ web/src/app/ShareDialog.vue | 100 ++------------------- web/src/app/SharePanel.vue | 102 +++++++++++++++++++++ web/src/app/Sidebar.vue | 2 +- web/src/app/TokensDialog.vue | 103 --------------------- web/src/app/TokensPanel.vue | 96 ++++++++++++++++++++ web/src/app/TrashDialog.vue | 60 ------------- web/src/app/TrashPanel.vue | 54 +++++++++++ web/src/app/TreeNode.vue | 2 +- web/src/app/Workspace.vue | 8 +- web/src/app/api.ts | 6 ++ web/src/app/app.css | 41 +++++++++ web/src/app/commands.ts | 5 ++ web/src/app/events.ts | 2 +- web/src/app/settings.ts | 53 +++++++++++ web/src/app/workspace.ts | 29 ++++-- 25 files changed, 915 insertions(+), 290 deletions(-) create mode 100644 internal/api/settings.go create mode 100644 web/src/app/SettingsDialog.vue create mode 100644 web/src/app/SharePanel.vue delete mode 100644 web/src/app/TokensDialog.vue create mode 100644 web/src/app/TokensPanel.vue delete mode 100644 web/src/app/TrashDialog.vue create mode 100644 web/src/app/TrashPanel.vue create mode 100644 web/src/app/settings.ts diff --git a/internal/api/api.go b/internal/api/api.go index 14cd05b..3361060 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -12,6 +12,7 @@ import ( "net/http" "path" "strings" + "sync" "time" "rawmark/internal/auth" @@ -34,6 +35,8 @@ type Server struct { store *store.Store hub *events.Hub index *search.Index + // 串行化设置的"读取-合并-写入" + settingsMu sync.Mutex // OnSetup 在首次初始化完成后调用(用于删除启动码文件) OnSetup func() } @@ -68,6 +71,7 @@ func (s *Server) routes() []route { {"POST /api/v1/auth/setup", public, s.setup}, {"POST /api/v1/auth/login", public, s.login}, {"POST /api/v1/auth/logout", public, s.logout}, + {"POST /api/v1/auth/password", sessionOnly, s.changePassword}, {"GET /api/v1/tree", authed, s.tree}, {"GET /api/v1/files/{path...}", authed, s.readFile}, @@ -83,6 +87,8 @@ func (s *Server) routes() []route { {"GET /api/v1/events", authed, s.events}, {"GET /api/v1/search", authed, s.search}, + {"GET /api/v1/settings", authed, s.getSettings}, + {"PATCH /api/v1/settings", sessionOnly, s.patchSettings}, {"GET /api/v1/shares", sessionOnly, s.listShares}, {"POST /api/v1/shares", sessionOnly, s.createShare}, {"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare}, @@ -230,11 +236,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - token, err := s.auth.Login(host, password) + token, err := s.auth.Login(clientIP(r), password) switch { case errors.Is(err, auth.ErrRateLimited): writeError(w, 429, err.Error()) @@ -426,6 +428,15 @@ func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) { // ---- 响应工具 ---- +// clientIP 返回限速用的来源地址(未经反向代理头部,避免伪造) +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil { writeError(w, 400, "请求格式错误") diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c8974c9..50544bf 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -309,3 +309,58 @@ func TestAPITokenScopes(t *testing.T) { t.Errorf("吊销后应为 401,得到 %d", code) } } + +func TestSettingsMerge(t *testing.T) { + f := newFixture(t) + if got := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); len(got) != 0 { + t.Fatalf("初始为空对象: %v", got) + } + f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":18,"theme":"dark"}`, nil) + got := decode[map[string]any](t, f.do(f.admin, "PATCH", "/api/v1/settings", `{"fontSize":20,"theme":null,"cssSnippets":[{"id":"a"}]}`, nil)) + if got["fontSize"] != 20.0 || got["theme"] != nil || len(got["cssSnippets"].([]any)) != 1 { + t.Fatalf("合并结果: %v", got) + } + if again := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)); again["fontSize"] != 20.0 || len(again) != 2 { + t.Fatalf("读取: %v", again) + } + for _, body := range []string{`[1]`, `null`, `"x"`, `{`} { + if code := f.status(f.admin, "PATCH", "/api/v1/settings", body, nil); code != 400 { + t.Errorf("%s 应为 400,得到 %d", body, code) + } + } + tok := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"x","scope":"write"}`, nil)).Token + bearer := map[string]string{"Authorization": "Bearer " + tok} + if code := f.status(&http.Client{}, "GET", "/api/v1/settings", "", bearer); code != 200 { + t.Errorf("令牌可读设置,得到 %d", code) + } + if code := f.status(&http.Client{}, "PATCH", "/api/v1/settings", `{"a":1}`, bearer); code != 403 { + t.Errorf("令牌不能改设置(自定义 CSS 等会影响页面),得到 %d", code) + } +} + +func TestChangePassword(t *testing.T) { + f := newFixture(t) + other := newClient() + f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil) + if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 401 { + t.Errorf("当前密码错误应为 401,得到 %d", code) + } + if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"short"}`, nil); code != 400 { + t.Errorf("新密码过短应为 400,得到 %d", code) + } + if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"new-password"}`, nil); code != 200 { + t.Fatalf("修改密码: %d", code) + } + if code := f.status(f.admin, "GET", "/api/v1/tree", "", nil); code != 200 { + t.Errorf("当前会话应保留,得到 %d", code) + } + if code := f.status(other, "GET", "/api/v1/tree", "", nil); code != 401 { + t.Errorf("其他会话应失效,得到 %d", code) + } + if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); code != 401 { + t.Errorf("旧密码应失效,得到 %d", code) + } + if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"new-password"}`, nil); code != 200 { + t.Errorf("新密码登录,得到 %d", code) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index b9ece2d..fa1a72b 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -245,6 +245,160 @@ } } }, + "/auth/password": { + "post": { + "summary": "修改登录密码;当前会话保留,其他设备需重新登录", + "tags": [ + "登录" + ], + "security": [ + { + "cookie": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "required": [ + "current", + "next" + ] + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ok" + } + } + } + }, + "400": { + "description": "新密码过短", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "当前密码错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "失败次数过多", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/settings": { + "get": { + "summary": "应用设置(全部设备共用的一份 JSON 对象)", + "tags": [ + "设置" + ], + "responses": { + "200": { + "description": "设置", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "patch": { + "summary": "合并修改设置", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "description": "按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。成功后经 WebSocket 推送 settings 事件。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "合并后的设置", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "不是 JSON 对象", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "超过 1 MB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/tree": { "get": { "summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)", @@ -1446,7 +1600,7 @@ }, "Event": { "type": "object", - "description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path)", + "description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path);settings:设置变化", "properties": { "type": { "type": "string", @@ -1454,7 +1608,8 @@ "tree", "changed", "deleted", - "moved" + "moved", + "settings" ] }, "path": { diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..2687e32 --- /dev/null +++ b/internal/api/settings.go @@ -0,0 +1,103 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + + "rawmark/internal/auth" + "rawmark/internal/events" +) + +// 应用设置:一份 JSON 对象,全部设备共用。内容由前端定义,服务端只做合并与保存 +const settingsKey = "app.settings" + +func (s *Server) readSettings() (map[string]json.RawMessage, error) { + v, ok, err := s.store.Setting(settingsKey) + if err != nil || !ok { + return map[string]json.RawMessage{}, err + } + out := map[string]json.RawMessage{} + return out, json.Unmarshal([]byte(v), &out) +} + +func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.readSettings() + if err != nil { + internalError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, settings) +} + +// patchSettings 按顶层键合并:请求中的键覆盖原值,值为 null 的键被删除。 +// 不同设备同时修改不同的设置不会互相覆盖;合并后通知其他设备重新读取 +func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes)) + if err != nil { + writeError(w, 413, "设置过大") + return + } + var patch map[string]json.RawMessage + if err := json.Unmarshal(data, &patch); err != nil || patch == nil { + writeError(w, 400, "设置必须是 JSON 对象") + return + } + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + settings, err := s.readSettings() + if err != nil { + internalError(w, r, err) + return + } + for k, v := range patch { + if bytes.Equal(v, []byte("null")) { + delete(settings, k) + } else { + settings[k] = v + } + } + merged, err := json.Marshal(settings) + if err != nil { + internalError(w, r, err) + return + } + if len(merged) > maxJSONBytes { + writeError(w, 413, "设置过大") + return + } + if err := s.store.SetSetting(settingsKey, string(merged)); err != nil { + internalError(w, r, err) + return + } + s.hub.Broadcast(events.Event{Type: "settings"}) + writeJSON(w, 200, settings) +} + +// changePassword 修改登录密码;当前会话保留,其他设备需重新登录 +func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { + var body struct{ Current, Next string } + if !readJSON(w, r, &body) { + return + } + c, err := r.Cookie(cookieName) + if err != nil { + writeError(w, 401, "未登录") + return + } + switch err := s.auth.ChangePassword(clientIP(r), c.Value, body.Current, body.Next); { + case errors.Is(err, auth.ErrRateLimited): + writeError(w, 429, err.Error()) + case errors.Is(err, auth.ErrBadCredentials): + writeError(w, 401, "当前密码错误") + case errors.Is(err, auth.ErrWeakPassword): + writeError(w, 400, err.Error()) + case err != nil: + internalError(w, r, err) + default: + writeJSON(w, 200, map[string]bool{"ok": true}) + } +} diff --git a/internal/api/shares.go b/internal/api/shares.go index bbe4172..bd31a1b 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "log/slog" - "net" "net/http" "path" "strings" @@ -191,11 +190,7 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) { return } if sh.PasswordHash != "" { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr - } - switch err := s.auth.CheckSharePassword(host, sh.PasswordHash, body.Password); { + switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); { case errors.Is(err, auth.ErrRateLimited): writeError(w, 429, err.Error()) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2d2c513..01d63d6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -143,6 +143,32 @@ func (m *Manager) Validate(token string) (bool, error) { func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) } +// ChangePassword 校验当前密码后设置新密码,并注销 keepToken 以外的所有会话 +func (m *Manager) ChangePassword(ip, keepToken, current, next string) error { + if !m.allow(ip) { + return ErrRateLimited + } + encoded, _, err := m.store.Setting(passwordKey) + if err != nil { + return err + } + ok, err := verifyPassword(encoded, current) + if err != nil { + return err + } + if !ok { + m.recordFailure(ip) + return ErrBadCredentials + } + if len([]rune(next)) < MinPasswordLen { + return ErrWeakPassword + } + if err := m.store.SetSetting(passwordKey, hashPassword(next)); err != nil { + return err + } + return m.store.DeleteOtherSessions(hashToken(keepToken)) +} + // ---- 分享 ---- // HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串 diff --git a/internal/events/hub.go b/internal/events/hub.go index b7a22e3..ee147ac 100644 --- a/internal/events/hub.go +++ b/internal/events/hub.go @@ -13,6 +13,7 @@ import ( // changed 笔记内容变化,Rev 为新版本 // deleted 笔记被删除 // moved 笔记被移动(From -> Path),内容版本不变 +// settings 应用设置变化,客户端重新读取 type Event struct { Type string `json:"type"` Path string `json:"path,omitempty"` diff --git a/internal/store/store.go b/internal/store/store.go index ace5392..ebddba4 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -101,8 +101,9 @@ func (s *Store) DeleteSession(tokenHash string) error { return err } -func (s *Store) DeleteAllSessions() error { - _, err := s.db.Exec(`DELETE FROM sessions`) +// DeleteOtherSessions 删除除 keepHash 之外的所有会话(修改密码后让其他设备重新登录) +func (s *Store) DeleteOtherSessions(keepHash string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash) return err } diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index dd22f5a..0b0240c 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -16,9 +16,13 @@ const paths = { search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35', link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71', key: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zM15.5 7.5l3 3L22 7l-3-3', + settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z', + palette: 'M12 22a10 10 0 1 1 10-10c0 2.8-2.2 4-4 4h-2a2 2 0 0 0-1.5 3.3A1.7 1.7 0 0 1 12 22zM7.5 10.5h.01M12 7.5h.01M16.5 10.5h.01', + user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z', } -defineProps<{ name: keyof typeof paths; size?: number }>() +export type IconName = keyof typeof paths +defineProps<{ name: IconName; size?: number }>() diff --git a/web/src/app/api.ts b/web/src/app/api.ts index b39c9e4..cee2839 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -94,6 +94,12 @@ export const api = { login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }), logout: () => request('POST', '/api/v1/auth/logout'), + changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }), + + settings: () => json>(request('GET', '/api/v1/settings')), + // 按顶层键合并,值为 null 的键被删除 + patchSettings: (patch: Record) => json>(request('PATCH', '/api/v1/settings', { json: patch })), + tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries, search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results, diff --git a/web/src/app/app.css b/web/src/app/app.css index 2cf53de..7a6d90e 100644 --- a/web/src/app/app.css +++ b/web/src/app/app.css @@ -378,6 +378,32 @@ button { .rm-token-created p { margin: 0 0 6px; } .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: 12px; user-select: all; } +/* ---- 设置 ---- */ +.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: 36px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--rm-text); font: inherit; font-size: 14px; 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: 44px; padding: 6px 20px; } +.rm-setting-label { font-size: 14px; } +.rm-setting select { height: 34px; padding: 0 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font-size: 14px; } +.rm-segmented { display: inline-flex; border: 1px solid var(--rm-divider); border-radius: 8px; overflow: hidden; } +.rm-segmented button { height: 32px; padding: 0 14px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; font-size: 13px; cursor: pointer; } +.rm-segmented button:first-child { border-left: 0; } +.rm-segmented button.on { background: var(--rm-accent); color: #fff; } +.rm-settings-form { max-width: 360px; padding: 4px 20px 16px; } +.rm-settings-form h3 { margin: 0; font-size: 15px; } +.rm-shortcuts { width: calc(100% - 40px); margin: 0 20px; border-collapse: collapse; font-size: 14px; } +.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: 12px; padding: 2px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-inline-code-bg); } +.flip { transform: rotate(180deg); } + .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:hover { background: var(--rm-hover); } @@ -403,6 +429,16 @@ button { /* ---- 移动端 ---- */ @media (max-width: 768px) { + /* 设置在手机上全屏,一次只显示分组列表或分组内容 */ + .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: 16px; } + .rm-settings-close-list, + .rm-settings-back { display: inline-flex; width: 40px; height: 40px; } + .rm-setting { flex-wrap: wrap; } .rm-sidebar { position: fixed; inset: 0 auto 0 0; @@ -434,6 +470,11 @@ button { .rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; } /* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */ .rm-search-input { height: 40px; font-size: 16px; } + .rm-auth-card input, + .rm-share-form input, + .rm-share-form select, + .rm-setting select { font-size: 16px; } + .rm-share-form { flex-wrap: wrap; } .rm-search-item { padding: 10px 8px; } /* 手机上从顶部弹出,给软键盘留出空间 */ .rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); } diff --git a/web/src/app/commands.ts b/web/src/app/commands.ts index 754cf50..246de0a 100644 --- a/web/src/app/commands.ts +++ b/web/src/app/commands.ts @@ -21,6 +21,11 @@ export function registerCommand(cmd: Command): () => void { } } +/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */ +export function allCommands(): Command[] { + return [...commands.values()] +} + export function availableCommands(): Command[] { return [...commands.values()].filter((c) => c.when?.() ?? true) } diff --git a/web/src/app/events.ts b/web/src/app/events.ts index 87cba60..43e39c5 100644 --- a/web/src/app/events.ts +++ b/web/src/app/events.ts @@ -1,7 +1,7 @@ // 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步 export interface VaultEvent { - type: 'tree' | 'changed' | 'deleted' | 'moved' + type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' path?: string from?: string rev?: string diff --git a/web/src/app/settings.ts b/web/src/app/settings.ts new file mode 100644 index 0000000..834ee63 --- /dev/null +++ b/web/src/app/settings.ts @@ -0,0 +1,53 @@ +// 应用设置:服务器上一份 JSON,全部设备共用;修改立即保存,其他设备经事件推送同步。 +// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变 +import { reactive } from 'vue' +import { api } from './api' + +export const FONT_SIZES = [14, 15, 16, 17, 18, 20, 22] +// 0 为全宽 +export const CONTENT_WIDTHS = [ + { label: '窄', value: 680 }, + { label: '标准', value: 760 }, + { label: '宽', value: 960 }, + { label: '全宽', value: 0 }, +] + +const DEFAULTS = { fontSize: 16, contentWidth: 760 } +export type Settings = typeof DEFAULTS + +const CACHE_KEY = 'rawmark.settings' + +export const settings = reactive(sanitize(JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}'))) + +// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值 +function sanitize(raw: Record): Settings { + const pick = (v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def) + return { + fontSize: pick(raw.fontSize, FONT_SIZES, DEFAULTS.fontSize), + contentWidth: pick( + raw.contentWidth, + CONTENT_WIDTHS.map((w) => w.value), + DEFAULTS.contentWidth, + ), + } +} + +function applied(next: Settings) { + Object.assign(settings, next) + localStorage.setItem(CACHE_KEY, JSON.stringify(settings)) + const root = document.documentElement.style + root.setProperty('--rm-font-size', settings.fontSize + 'px') + root.setProperty('--rm-content-width', settings.contentWidth ? settings.contentWidth + 'px' : 'none') +} + +applied({ ...settings }) + +export async function loadSettings() { + applied(sanitize(await api.settings())) +} + +/** 修改设置:先在本地生效再保存,保存失败时由调用方提示并重新载入 */ +export async function updateSettings(patch: Partial) { + applied({ ...settings, ...patch }) + await api.patchSettings(patch) +} diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index a4a50c9..77f3ef1 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -9,6 +9,7 @@ import { api, ApiError, type Entry } from './api' import { drafts } from './drafts' import { connectEvents, type VaultEvent } from './events' import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths' +import { loadSettings, settings } from './settings' export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' @@ -24,6 +25,8 @@ export interface Tab { conflictRev?: string } +export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash' + export interface Toast { id: number text: string @@ -45,10 +48,10 @@ export const ws = reactive({ searchOpen: false, // 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令 switcher: null as string | null, - trashOpen: false, - // 分享对话框:path 为笔记路径,null 为全部分享的管理列表 - shareDialog: null as { path: string | null } | null, - tokensOpen: false, + // 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组) + settings: null as SettingsSection | 'index' | null, + // 分享当前笔记的对话框(笔记路径) + sharing: null as string | null, // 最近打开的笔记,最新的在前 recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[], toasts: [] as Toast[], @@ -646,6 +649,7 @@ async function syncOpenTab(tab: Tab) { function handleEvents(evs: VaultEvent[]) { for (const ev of evs) { if (ev.type === 'tree') void refreshTree() + else if (ev.type === 'settings') void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err))) else if (ev.type === 'changed') { const tab = tabOf(ev.path!) // 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理 @@ -701,7 +705,10 @@ let disconnect: (() => void) | undefined let listenersInstalled = false export async function startWorkspace() { + // 设置读取失败不影响使用,沿用本地缓存的值 + const settingsLoaded = loadSettings().catch((err) => toast('读取设置失败:' + errorText(err))) await refreshTree() + await settingsLoaded const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null } const exists = new Set(ws.entries.map((e) => e.path)) await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote)) @@ -732,6 +739,11 @@ export async function startWorkspace() { () => ws.recent, (r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)), ) + // 字号、正文宽度变化后行高随之改变,编辑器需要重新测量 + watch( + () => [settings.fontSize, settings.contentWidth], + () => view?.requestMeasure(), + ) // 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') void flushAll(true) @@ -760,10 +772,11 @@ function registerBuiltinCommands() { { id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) }, // 浏览器不允许网页覆盖 Ctrl+W { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) }, - { id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) }, - { id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.shareDialog = { path: ws.active }) }, - { id: 'app.shares', title: '管理分享链接', run: () => (ws.shareDialog = { path: null }) }, - { id: 'app.tokens', title: 'API 令牌', run: () => (ws.tokensOpen = true) }, + { id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') }, + { id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') }, + { id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) }, + { id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') }, + { id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') }, ]) { registerCommand(cmd) }