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:
eezd committed 2026-10-17 16:23:39 +08:00
1 parent 5b326a70bf
commit eb63b14c8e
25 files changed
+915 -290

No files matched your search

+16 -5
View File
@@ -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, "请求格式错误")
+55
View File
@@ -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)
}
}
+157 -2
View File
@@ -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": {
+103
View File
@@ -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})
}
}
+1 -6
View File
@@ -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
+26
View File
@@ -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 为分享密码生成哈希;空密码表示不设密码,返回空字符串
+1
View File
@@ -13,6 +13,7 @@ import (
// changed 笔记内容变化,Rev 为新版本
// deleted 笔记被删除
// moved 笔记被移动(From -> Path),内容版本不变
// settings 应用设置变化,客户端重新读取
type Event struct {
Type string `json:"type"`
Path string `json:"path,omitempty"`
+3 -2
View File
@@ -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
}
+5 -1
View File
@@ -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 }>()
</script>
<template>
+159
View File
@@ -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>
+5 -95
View File
@@ -1,110 +1,20 @@
<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 { noteTitle } from './paths'
import { toast } from './workspace'
import SharePanel from './SharePanel.vue'
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
const props = defineProps<{ path: string | null }>()
defineProps<{ path: string }>()
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>
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="title">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${noteTitle(path)}”`">
<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>
</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="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>
<SharePanel :path="path" />
</div>
</div>
</template>
+102
View File
@@ -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>
+1 -1
View File
@@ -44,7 +44,7 @@ function onRootDrop(e: DragEvent) {
</div>
<div class="rm-sidebar-foot">
<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>
</div>
</div>
-103
View File
@@ -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>
+96
View File
@@ -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>
-60
View File
@@ -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>
+54
View File
@@ -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>
+1 -1
View File
@@ -48,7 +48,7 @@ function runMenu(action: 'note' | 'folder' | 'rename' | 'share' | 'delete') {
expanded.add(path)
void (action === 'note' ? createNote(path) : createFolder(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)
}
+3 -5
View File
@@ -6,12 +6,11 @@ import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
import NoteBanner from './NoteBanner.vue'
import QuickSwitcher from './QuickSwitcher.vue'
import SettingsDialog from './SettingsDialog.vue'
import ShareDialog from './ShareDialog.vue'
import TokensDialog from './TokensDialog.vue'
import { noteTitle } from './paths'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
import TrashDialog from './TrashDialog.vue'
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
@@ -68,8 +67,7 @@ onBeforeUnmount(() => {
<EditorPane />
</main>
<QuickSwitcher v-if="ws.switcher !== null" />
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
<ShareDialog v-if="ws.shareDialog" :path="ws.shareDialog.path" @close="ws.shareDialog = null" />
<TokensDialog v-if="ws.tokensOpen" @close="ws.tokensOpen = false" />
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
</div>
</template>
+6
View File
@@ -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<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,
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
+41
View File
@@ -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)); }
+5
View File
@@ -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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
// 订阅服务端的仓库变更事件;断线后指数退避重连,重连成功时触发全量同步
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved'
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings'
path?: string
from?: string
rev?: string
+53
View File
@@ -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)
}
+21 -8
View File
@@ -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)
}