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
This commit is contained in:
1 parent
5b326a70bf
commit
eb63b14c8e
25 files changed
+915
-290
No files matched your search
+16
-5
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"rawmark/internal/auth"
|
"rawmark/internal/auth"
|
||||||
@@ -34,6 +35,8 @@ type Server struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
hub *events.Hub
|
hub *events.Hub
|
||||||
index *search.Index
|
index *search.Index
|
||||||
|
// 串行化设置的"读取-合并-写入"
|
||||||
|
settingsMu sync.Mutex
|
||||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||||
OnSetup func()
|
OnSetup func()
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,7 @@ func (s *Server) routes() []route {
|
|||||||
{"POST /api/v1/auth/setup", public, s.setup},
|
{"POST /api/v1/auth/setup", public, s.setup},
|
||||||
{"POST /api/v1/auth/login", public, s.login},
|
{"POST /api/v1/auth/login", public, s.login},
|
||||||
{"POST /api/v1/auth/logout", public, s.logout},
|
{"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/tree", authed, s.tree},
|
||||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
{"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/events", authed, s.events},
|
||||||
{"GET /api/v1/search", authed, s.search},
|
{"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},
|
{"GET /api/v1/shares", sessionOnly, s.listShares},
|
||||||
{"POST /api/v1/shares", sessionOnly, s.createShare},
|
{"POST /api/v1/shares", sessionOnly, s.createShare},
|
||||||
{"DELETE /api/v1/shares/{token}", sessionOnly, s.deleteShare},
|
{"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) {
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) {
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
token, err := s.auth.Login(clientIP(r), password)
|
||||||
if err != nil {
|
|
||||||
host = r.RemoteAddr
|
|
||||||
}
|
|
||||||
token, err := s.auth.Login(host, password)
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, auth.ErrRateLimited):
|
case errors.Is(err, auth.ErrRateLimited):
|
||||||
writeError(w, 429, err.Error())
|
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 {
|
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 {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
|
||||||
writeError(w, 400, "请求格式错误")
|
writeError(w, 400, "请求格式错误")
|
||||||
|
|||||||
@@ -309,3 +309,58 @@ func TestAPITokenScopes(t *testing.T) {
|
|||||||
t.Errorf("吊销后应为 401,得到 %d", code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-2
@@ -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": {
|
"/tree": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)",
|
"summary": "全部笔记与文件夹(不含 .assets 图片目录与隐藏文件)",
|
||||||
@@ -1446,7 +1600,7 @@
|
|||||||
},
|
},
|
||||||
"Event": {
|
"Event": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path)",
|
"description": "tree:目录结构变化;changed:内容变化(rev 为新版本);deleted:删除;moved:移动(from → path);settings:设置变化",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1454,7 +1608,8 @@
|
|||||||
"tree",
|
"tree",
|
||||||
"changed",
|
"changed",
|
||||||
"deleted",
|
"deleted",
|
||||||
"moved"
|
"moved",
|
||||||
|
"settings"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -191,11 +190,7 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sh.PasswordHash != "" {
|
if sh.PasswordHash != "" {
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
switch err := s.auth.CheckSharePassword(clientIP(r), sh.PasswordHash, body.Password); {
|
||||||
if err != nil {
|
|
||||||
host = r.RemoteAddr
|
|
||||||
}
|
|
||||||
switch err := s.auth.CheckSharePassword(host, sh.PasswordHash, body.Password); {
|
|
||||||
case errors.Is(err, auth.ErrRateLimited):
|
case errors.Is(err, auth.ErrRateLimited):
|
||||||
writeError(w, 429, err.Error())
|
writeError(w, 429, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)) }
|
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 为分享密码生成哈希;空密码表示不设密码,返回空字符串
|
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
// changed 笔记内容变化,Rev 为新版本
|
// changed 笔记内容变化,Rev 为新版本
|
||||||
// deleted 笔记被删除
|
// deleted 笔记被删除
|
||||||
// moved 笔记被移动(From -> Path),内容版本不变
|
// moved 笔记被移动(From -> Path),内容版本不变
|
||||||
|
// settings 应用设置变化,客户端重新读取
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ func (s *Store) DeleteSession(tokenHash string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteAllSessions() error {
|
// DeleteOtherSessions 删除除 keepHash 之外的所有会话(修改密码后让其他设备重新登录)
|
||||||
_, err := s.db.Exec(`DELETE FROM sessions`)
|
func (s *Store) DeleteOtherSessions(keepHash string) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash <> ?`, keepHash)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
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',
|
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',
|
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',
|
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 }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { api, ApiError } from './api'
|
||||||
|
import { allCommands, formatKeys, runCommand } from './commands'
|
||||||
|
import Icon, { type IconName } from './Icon.vue'
|
||||||
|
import { CONTENT_WIDTHS, FONT_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
||||||
|
import SharePanel from './SharePanel.vue'
|
||||||
|
import TokensPanel from './TokensPanel.vue'
|
||||||
|
import TrashPanel from './TrashPanel.vue'
|
||||||
|
import { toast, ws, type SettingsSection } from './workspace'
|
||||||
|
|
||||||
|
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
||||||
|
{ id: 'appearance', title: '外观', icon: 'palette' },
|
||||||
|
{ 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 编辑器内置的按键(不在命令注册表中)
|
||||||
|
const EDITOR_KEYS = [
|
||||||
|
{ title: '撤销', keys: 'Mod+Z' },
|
||||||
|
{ title: '重做', keys: 'Mod+Shift+Z' },
|
||||||
|
{ title: '缩进 / 取消缩进', keys: 'Tab / Shift+Tab' },
|
||||||
|
{ title: '全选(代码块内先选中整个代码块)', keys: 'Mod+A' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
|
||||||
|
// 手机上一次只显示分组列表或分组内容
|
||||||
|
const showList = ref(ws.settings === 'index')
|
||||||
|
const current = computed(() => SECTIONS.find((s) => s.id === section.value)!)
|
||||||
|
const shortcuts = computed(() => [
|
||||||
|
...allCommands()
|
||||||
|
.filter((c) => c.keys?.length)
|
||||||
|
.map((c) => ({ title: c.title, keys: c.keys!.map(formatKeys).join(' / ') })),
|
||||||
|
...EDITOR_KEYS.map((k) => ({ title: k.title, keys: k.keys.split(' / ').map(formatKeys).join(' / ') })),
|
||||||
|
])
|
||||||
|
|
||||||
|
function open(id: SettingsSection) {
|
||||||
|
ws.settings = id
|
||||||
|
showList.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
ws.settings = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && !e.isComposing) close()
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||||
|
|
||||||
|
async function change(patch: Partial<Settings>) {
|
||||||
|
try {
|
||||||
|
await updateSettings(patch)
|
||||||
|
} catch (err) {
|
||||||
|
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
|
||||||
|
void loadSettings().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 修改登录密码 ----
|
||||||
|
const pw = ref({ current: '', next: '', confirm: '' })
|
||||||
|
const pwError = ref('')
|
||||||
|
const pwBusy = ref(false)
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
pwError.value = ''
|
||||||
|
if (pw.value.next.length < 8) return void (pwError.value = '新密码至少 8 位')
|
||||||
|
if (pw.value.next !== pw.value.confirm) return void (pwError.value = '两次输入的新密码不一致')
|
||||||
|
pwBusy.value = true
|
||||||
|
try {
|
||||||
|
await api.changePassword(pw.value.current, pw.value.next)
|
||||||
|
pw.value = { current: '', next: '', confirm: '' }
|
||||||
|
toast('登录密码已修改,其他设备需要重新登录')
|
||||||
|
} catch (err) {
|
||||||
|
pwError.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||||
|
} finally {
|
||||||
|
pwBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rm-dialog-backdrop" @click.self="close">
|
||||||
|
<div class="rm-dialog rm-settings" :class="{ 'show-list': showList }" role="dialog" aria-label="设置">
|
||||||
|
<nav class="rm-settings-nav">
|
||||||
|
<div class="rm-dialog-head">
|
||||||
|
<h2>设置</h2>
|
||||||
|
<button class="rm-icon-btn rm-settings-close-list" title="关闭" @click="close"><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<button v-for="s in SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section }" @click="open(s.id)">
|
||||||
|
<Icon :name="s.icon" />{{ s.title }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<section class="rm-settings-body">
|
||||||
|
<div class="rm-dialog-head">
|
||||||
|
<button class="rm-icon-btn rm-settings-back" title="返回" @click="showList = true"><Icon name="chevron" class="flip" /></button>
|
||||||
|
<h2>{{ current.title }}</h2>
|
||||||
|
<button class="rm-icon-btn" title="关闭" @click="close"><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="rm-settings-content">
|
||||||
|
<template v-if="section === 'appearance'">
|
||||||
|
<div class="rm-setting">
|
||||||
|
<span class="rm-setting-label">字号</span>
|
||||||
|
<select :value="settings.fontSize" aria-label="字号" @change="change({ fontSize: Number(($event.target as HTMLSelectElement).value) })">
|
||||||
|
<option v-for="n in FONT_SIZES" :key="n" :value="n">{{ n }} px</option>
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="section === 'account'">
|
||||||
|
<form class="rm-auth-card rm-settings-form" @submit.prevent="changePassword">
|
||||||
|
<h3>修改登录密码</h3>
|
||||||
|
<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">{{ 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>
|
||||||
|
|
||||||
|
<table v-else-if="section === 'shortcuts'" class="rm-shortcuts">
|
||||||
|
<tr v-for="s in shortcuts" :key="s.title">
|
||||||
|
<td>{{ s.title }}</td>
|
||||||
|
<td><kbd>{{ s.keys }}</kbd></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<SharePanel v-else-if="section === 'shares'" :path="null" />
|
||||||
|
<TokensPanel v-else-if="section === 'tokens'" />
|
||||||
|
<TrashPanel v-else-if="section === 'trash'" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,110 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { copyText } from '../editor/widgets'
|
|
||||||
import { api, ApiError, type Share } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { noteTitle } from './paths'
|
import { noteTitle } from './paths'
|
||||||
import { toast } from './workspace'
|
import SharePanel from './SharePanel.vue'
|
||||||
|
|
||||||
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
|
defineProps<{ path: string }>()
|
||||||
const props = defineProps<{ path: string | null }>()
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
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 title = computed(() => (props.path ? `分享“${noteTitle(props.path)}”` : '所有分享'))
|
|
||||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
|
|
||||||
const linkOf = (s: Share) => `${location.origin}/s/${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 {
|
|
||||||
shares.value = await api.shares(props.path ?? undefined)
|
|
||||||
} catch (err) {
|
|
||||||
toast('读取分享失败:' + errorText(err))
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
<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 rm-share-dialog" role="dialog" :aria-label="`分享“${noteTitle(path)}”`">
|
||||||
<div class="rm-dialog-head">
|
<div class="rm-dialog-head">
|
||||||
<h2>{{ title }}</h2>
|
<h2>分享“{{ noteTitle(path) }}”</h2>
|
||||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||||
</div>
|
</div>
|
||||||
<form v-if="path" class="rm-share-form" @submit.prevent="create">
|
<SharePanel :path="path" />
|
||||||
<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">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p>
|
|
||||||
<p v-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.path.replace(/\.md$/, '') }}</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"> · <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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { copyText } from '../editor/widgets'
|
||||||
|
import { api, ApiError, type Share } from './api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { toast } from './workspace'
|
||||||
|
|
||||||
|
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
|
||||||
|
const props = defineProps<{ path: 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/${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 {
|
||||||
|
shares.value = await api.shares(props.path ?? undefined)
|
||||||
|
} 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">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</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.path.replace(/\.md$/, '') }}</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"> · <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>
|
||||||
@@ -44,7 +44,7 @@ function onRootDrop(e: DragEvent) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="rm-sidebar-foot">
|
<div class="rm-sidebar-foot">
|
||||||
<button class="rm-foot-btn" title="命令面板(F1)" @click="ws.switcher = '>'"><Icon name="command" />命令</button>
|
<button class="rm-foot-btn" title="命令面板(F1)" @click="ws.switcher = '>'"><Icon name="command" />命令</button>
|
||||||
<button class="rm-foot-btn" @click="ws.trashOpen = true"><Icon name="trash" />回收站</button>
|
<button class="rm-foot-btn" title="设置(Ctrl+,)" @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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { copyText } from '../editor/widgets'
|
|
||||||
import { api, ApiError, type ApiToken } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { toast } from './workspace'
|
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
|
||||||
|
|
||||||
const tokens = ref<ApiToken[] | null>(null)
|
|
||||||
const name = ref('')
|
|
||||||
const scope = ref<ApiToken['scope']>('read')
|
|
||||||
const busy = ref(false)
|
|
||||||
// 刚创建的令牌明文,只在本次对话框中显示
|
|
||||||
const created = ref<string | null>(null)
|
|
||||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
|
|
||||||
function errorText(err: unknown) {
|
|
||||||
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
tokens.value = await api.tokens()
|
|
||||||
} catch (err) {
|
|
||||||
toast('读取 API 令牌失败:' + errorText(err))
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create() {
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.createToken(name.value.trim(), scope.value)
|
|
||||||
tokens.value = [res.info, ...(tokens.value ?? [])]
|
|
||||||
created.value = res.token
|
|
||||||
name.value = ''
|
|
||||||
} catch (err) {
|
|
||||||
toast('创建失败:' + errorText(err))
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copy() {
|
|
||||||
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke(t: ApiToken) {
|
|
||||||
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
|
||||||
try {
|
|
||||||
await api.deleteToken(t.id)
|
|
||||||
tokens.value = tokens.value!.filter((x) => x.id !== t.id)
|
|
||||||
} catch (err) {
|
|
||||||
toast('吊销失败:' + errorText(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
|
||||||
<div class="rm-dialog rm-share-dialog" role="dialog" aria-label="API 令牌">
|
|
||||||
<div class="rm-dialog-head">
|
|
||||||
<h2>API 令牌</h2>
|
|
||||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<form class="rm-share-form" @submit.prevent="create">
|
|
||||||
<input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
|
||||||
<select v-model="scope" aria-label="权限">
|
|
||||||
<option value="read">只读</option>
|
|
||||||
<option value="write">读写</option>
|
|
||||||
</select>
|
|
||||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建</button>
|
|
||||||
</form>
|
|
||||||
<div v-if="created" class="rm-token-created">
|
|
||||||
<p>令牌只显示这一次,请立即保存:</p>
|
|
||||||
<div class="rm-token-value">
|
|
||||||
<code>{{ created }}</code>
|
|
||||||
<button class="rm-btn" @click="copy">复制</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="rm-share-note">
|
|
||||||
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
|
|
||||||
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
|
|
||||||
</p>
|
|
||||||
<p v-if="tokens && !tokens.length" class="rm-tree-empty">还没有 API 令牌</p>
|
|
||||||
<ul v-else class="rm-trash-list">
|
|
||||||
<li v-for="t in tokens ?? []" :key="t.id">
|
|
||||||
<Icon name="key" />
|
|
||||||
<div class="rm-trash-info">
|
|
||||||
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
|
|
||||||
<span class="rm-trash-time">
|
|
||||||
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? fmt.format(t.lastUsedAt) + ' 最近使用' : '从未使用' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button class="rm-btn rm-btn-danger" @click="revoke(t)">吊销</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { copyText } from '../editor/widgets'
|
||||||
|
import { api, ApiError, type ApiToken } from './api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { toast } from './workspace'
|
||||||
|
|
||||||
|
const tokens = ref<ApiToken[] | null>(null)
|
||||||
|
const failed = ref(false)
|
||||||
|
const name = ref('')
|
||||||
|
const scope = ref<ApiToken['scope']>('read')
|
||||||
|
const busy = ref(false)
|
||||||
|
// 刚创建的令牌明文,只在本次对话框中显示
|
||||||
|
const created = ref<string | null>(null)
|
||||||
|
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
|
||||||
|
function errorText(err: unknown) {
|
||||||
|
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
tokens.value = await api.tokens()
|
||||||
|
} catch {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.createToken(name.value.trim(), scope.value)
|
||||||
|
tokens.value = [res.info, ...(tokens.value ?? [])]
|
||||||
|
created.value = res.token
|
||||||
|
name.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
toast('创建失败:' + errorText(err))
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(t: ApiToken) {
|
||||||
|
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteToken(t.id)
|
||||||
|
tokens.value = tokens.value!.filter((x) => x.id !== t.id)
|
||||||
|
} catch (err) {
|
||||||
|
toast('吊销失败:' + errorText(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<form class="rm-share-form" @submit.prevent="create">
|
||||||
|
<input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
||||||
|
<select v-model="scope" aria-label="权限">
|
||||||
|
<option value="read">只读</option>
|
||||||
|
<option value="write">读写</option>
|
||||||
|
</select>
|
||||||
|
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="created" class="rm-token-created">
|
||||||
|
<p>令牌只显示这一次,请立即保存:</p>
|
||||||
|
<div class="rm-token-value">
|
||||||
|
<code>{{ created }}</code>
|
||||||
|
<button class="rm-btn" @click="copy">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="rm-share-note">
|
||||||
|
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
|
||||||
|
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
|
||||||
|
</p>
|
||||||
|
<p v-if="failed" class="rm-tree-empty">读取 API 令牌失败</p>
|
||||||
|
<p v-else-if="tokens && !tokens.length" class="rm-tree-empty">还没有 API 令牌</p>
|
||||||
|
<ul v-else class="rm-trash-list">
|
||||||
|
<li v-for="t in tokens ?? []" :key="t.id">
|
||||||
|
<Icon name="key" />
|
||||||
|
<div class="rm-trash-info">
|
||||||
|
<span class="rm-trash-path">{{ t.name }} · {{ t.scope === 'write' ? '读写' : '只读' }}</span>
|
||||||
|
<span class="rm-trash-time">
|
||||||
|
{{ fmt.format(t.createdAt) }} 创建 · {{ t.lastUsedAt ? fmt.format(t.lastUsedAt) + ' 最近使用' : '从未使用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="rm-btn rm-btn-danger" @click="revoke(t)">吊销</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { api, type TrashItem } from './api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import { restore, toast } from './workspace'
|
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
|
||||||
const items = ref<TrashItem[] | null>(null)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
items.value = await api.trash()
|
|
||||||
} catch {
|
|
||||||
toast('读取回收站失败')
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doRestore(item: TrashItem) {
|
|
||||||
items.value = items.value!.filter((i) => i.id !== item.id)
|
|
||||||
await restore(item.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function purge(item: TrashItem) {
|
|
||||||
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
|
|
||||||
items.value = items.value!.filter((i) => i.id !== item.id)
|
|
||||||
try {
|
|
||||||
await api.purge(item.id)
|
|
||||||
} catch {
|
|
||||||
toast('删除失败')
|
|
||||||
void load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
|
||||||
<div class="rm-dialog" role="dialog" aria-label="回收站">
|
|
||||||
<div class="rm-dialog-head">
|
|
||||||
<h2>回收站</h2>
|
|
||||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
|
||||||
</div>
|
|
||||||
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
|
||||||
<ul v-else class="rm-trash-list">
|
|
||||||
<li v-for="item in items ?? []" :key="item.id">
|
|
||||||
<Icon :name="item.dir ? 'folder' : 'note'" />
|
|
||||||
<div class="rm-trash-info">
|
|
||||||
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
|
||||||
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="rm-btn" @click="doRestore(item)">恢复</button>
|
|
||||||
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, type TrashItem } from './api'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { restore, toast } from './workspace'
|
||||||
|
|
||||||
|
const items = ref<TrashItem[] | null>(null)
|
||||||
|
const failed = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
items.value = await api.trash()
|
||||||
|
} catch {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRestore(item: TrashItem) {
|
||||||
|
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||||
|
await restore(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purge(item: TrashItem) {
|
||||||
|
if (!confirm(`彻底删除“${item.path}”?此操作无法撤销。`)) return
|
||||||
|
items.value = items.value!.filter((i) => i.id !== item.id)
|
||||||
|
try {
|
||||||
|
await api.purge(item.id)
|
||||||
|
} catch {
|
||||||
|
toast('删除失败')
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p v-if="failed" class="rm-tree-empty">读取回收站失败</p>
|
||||||
|
<p v-else-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
|
||||||
|
<ul v-else class="rm-trash-list">
|
||||||
|
<li v-for="item in items ?? []" :key="item.id">
|
||||||
|
<Icon :name="item.dir ? 'folder' : 'note'" />
|
||||||
|
<div class="rm-trash-info">
|
||||||
|
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
||||||
|
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="rm-btn" @click="doRestore(item)">恢复</button>
|
||||||
|
<button class="rm-btn rm-btn-danger" @click="purge(item)">彻底删除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -48,7 +48,7 @@ function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
|
|||||||
expanded.add(path)
|
expanded.add(path)
|
||||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||||
} else if (action === 'rename') ws.renaming = path
|
} else if (action === 'rename') ws.renaming = path
|
||||||
else if (action === 'share') ws.shareDialog = { path }
|
else if (action === 'share') ws.sharing = path
|
||||||
else void remove(path)
|
else void remove(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import EditorPane from './EditorPane.vue'
|
|||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import NoteBanner from './NoteBanner.vue'
|
import NoteBanner from './NoteBanner.vue'
|
||||||
import QuickSwitcher from './QuickSwitcher.vue'
|
import QuickSwitcher from './QuickSwitcher.vue'
|
||||||
|
import SettingsDialog from './SettingsDialog.vue'
|
||||||
import ShareDialog from './ShareDialog.vue'
|
import ShareDialog from './ShareDialog.vue'
|
||||||
import TokensDialog from './TokensDialog.vue'
|
|
||||||
import { noteTitle } from './paths'
|
import { noteTitle } from './paths'
|
||||||
import Sidebar from './Sidebar.vue'
|
import Sidebar from './Sidebar.vue'
|
||||||
import TabBar from './TabBar.vue'
|
import TabBar from './TabBar.vue'
|
||||||
import TrashDialog from './TrashDialog.vue'
|
|
||||||
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>()
|
const emit = defineEmits<{ logout: [] }>()
|
||||||
@@ -68,8 +67,7 @@ onBeforeUnmount(() => {
|
|||||||
<EditorPane />
|
<EditorPane />
|
||||||
</main>
|
</main>
|
||||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||||
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
|
<SettingsDialog v-if="ws.settings" />
|
||||||
<ShareDialog v-if="ws.shareDialog" :path="ws.shareDialog.path" @close="ws.shareDialog = null" />
|
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
|
||||||
<TokensDialog v-if="ws.tokensOpen" @close="ws.tokensOpen = false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -94,6 +94,12 @@ export const api = {
|
|||||||
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
|
||||||
logout: () => request('POST', '/api/v1/auth/logout'),
|
logout: () => request('POST', '/api/v1/auth/logout'),
|
||||||
|
|
||||||
|
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
|
||||||
|
|
||||||
|
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
|
||||||
|
// 按顶层键合并,值为 null 的键被删除
|
||||||
|
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
||||||
|
|
||||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
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,
|
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||||
|
|
||||||
|
|||||||
@@ -378,6 +378,32 @@ button {
|
|||||||
.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: 12px; user-select: all; }
|
.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 { 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); }
|
||||||
@@ -403,6 +429,16 @@ button {
|
|||||||
|
|
||||||
/* ---- 移动端 ---- */
|
/* ---- 移动端 ---- */
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.rm-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -434,6 +470,11 @@ button {
|
|||||||
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
|
.rm-sidebar-head .rm-icon-btn { width: 40px; height: 40px; }
|
||||||
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
||||||
.rm-search-input { height: 40px; font-size: 16px; }
|
.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-search-item { padding: 10px 8px; }
|
||||||
/* 手机上从顶部弹出,给软键盘留出空间 */
|
/* 手机上从顶部弹出,给软键盘留出空间 */
|
||||||
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
|
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export function registerCommand(cmd: Command): () => void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 全部已注册的命令(含当前不可用的),用于快捷键一览 */
|
||||||
|
export function allCommands(): Command[] {
|
||||||
|
return [...commands.values()]
|
||||||
|
}
|
||||||
|
|
||||||
export function availableCommands(): Command[] {
|
export function availableCommands(): Command[] {
|
||||||
return [...commands.values()].filter((c) => c.when?.() ?? true)
|
return [...commands.values()].filter((c) => c.when?.() ?? true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
|
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
|
||||||
|
|
||||||
export interface VaultEvent {
|
export interface VaultEvent {
|
||||||
type: 'tree' | 'changed' | 'deleted' | 'moved'
|
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings'
|
||||||
path?: string
|
path?: string
|
||||||
from?: string
|
from?: string
|
||||||
rev?: string
|
rev?: string
|
||||||
|
|||||||
@@ -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<Settings>(sanitize(JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}')))
|
||||||
|
|
||||||
|
// 服务器上的设置可能由旧版本、API 或插件写入,取值不合法的项退回默认值
|
||||||
|
function sanitize(raw: Record<string, unknown>): Settings {
|
||||||
|
const pick = <T>(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<Settings>) {
|
||||||
|
applied({ ...settings, ...patch })
|
||||||
|
await api.patchSettings(patch)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { api, ApiError, type Entry } from './api'
|
|||||||
import { drafts } from './drafts'
|
import { drafts } from './drafts'
|
||||||
import { connectEvents, type VaultEvent } from './events'
|
import { connectEvents, type VaultEvent } from './events'
|
||||||
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||||
|
import { loadSettings, settings } from './settings'
|
||||||
|
|
||||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ export interface Tab {
|
|||||||
conflictRev?: string
|
conflictRev?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SettingsSection = 'appearance' | 'account' | 'shortcuts' | 'shares' | 'tokens' | 'trash'
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number
|
id: number
|
||||||
text: string
|
text: string
|
||||||
@@ -45,10 +48,10 @@ export const ws = reactive({
|
|||||||
searchOpen: false,
|
searchOpen: false,
|
||||||
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
// 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
|
||||||
switcher: null as string | null,
|
switcher: null as string | null,
|
||||||
trashOpen: false,
|
// 打开的设置分组,null 为关闭;index 为从通用入口打开(手机上先显示分组列表,电脑上显示第一组)
|
||||||
// 分享对话框:path 为笔记路径,null 为全部分享的管理列表
|
settings: null as SettingsSection | 'index' | null,
|
||||||
shareDialog: null as { path: string | null } | null,
|
// 分享当前笔记的对话框(笔记路径)
|
||||||
tokensOpen: false,
|
sharing: null as string | null,
|
||||||
// 最近打开的笔记,最新的在前
|
// 最近打开的笔记,最新的在前
|
||||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||||
toasts: [] as Toast[],
|
toasts: [] as Toast[],
|
||||||
@@ -646,6 +649,7 @@ async function syncOpenTab(tab: Tab) {
|
|||||||
function handleEvents(evs: VaultEvent[]) {
|
function handleEvents(evs: VaultEvent[]) {
|
||||||
for (const ev of evs) {
|
for (const ev of evs) {
|
||||||
if (ev.type === 'tree') void refreshTree()
|
if (ev.type === 'tree') void refreshTree()
|
||||||
|
else if (ev.type === 'settings') void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||||
else if (ev.type === 'changed') {
|
else if (ev.type === 'changed') {
|
||||||
const tab = tabOf(ev.path!)
|
const tab = tabOf(ev.path!)
|
||||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||||
@@ -701,7 +705,10 @@ let disconnect: (() => void) | undefined
|
|||||||
let listenersInstalled = false
|
let listenersInstalled = false
|
||||||
|
|
||||||
export async function startWorkspace() {
|
export async function startWorkspace() {
|
||||||
|
// 设置读取失败不影响使用,沿用本地缓存的值
|
||||||
|
const settingsLoaded = loadSettings().catch((err) => toast('读取设置失败:' + errorText(err)))
|
||||||
await refreshTree()
|
await refreshTree()
|
||||||
|
await settingsLoaded
|
||||||
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null }
|
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))
|
const exists = new Set(ws.entries.map((e) => e.path))
|
||||||
await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote))
|
await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote))
|
||||||
@@ -732,6 +739,11 @@ export async function startWorkspace() {
|
|||||||
() => ws.recent,
|
() => ws.recent,
|
||||||
(r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
|
(r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
|
||||||
)
|
)
|
||||||
|
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
|
||||||
|
watch(
|
||||||
|
() => [settings.fontSize, settings.contentWidth],
|
||||||
|
() => view?.requestMeasure(),
|
||||||
|
)
|
||||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||||
@@ -760,10 +772,11 @@ function registerBuiltinCommands() {
|
|||||||
{ id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
|
{ id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
|
||||||
// 浏览器不允许网页覆盖 Ctrl+W
|
// 浏览器不允许网页覆盖 Ctrl+W
|
||||||
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
||||||
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
|
{ id: 'app.settings', title: '设置', keys: ['Mod+,'], run: () => (ws.settings = 'index') },
|
||||||
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.shareDialog = { path: ws.active }) },
|
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
|
||||||
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.shareDialog = { path: null }) },
|
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
|
||||||
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.tokensOpen = true) },
|
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
|
||||||
|
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') },
|
||||||
]) {
|
]) {
|
||||||
registerCommand(cmd)
|
registerCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user