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

+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)
}