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
@@ -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>
|
||||
Reference in new issue
Block a user