refactor(web): adopt modular Vue architecture and isolate bearer sessions
This commit is contained in:
1 parent
1b2b2b12ff
commit
3cd641a380
379 files changed
+19736
-8712
No files matched your search
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { Theme } from '@@/apis/preferences/type'
|
||||
import type { CssSnippet } from '../../../common/appearance/appearance'
|
||||
import type { Settings } from '../../../pinia/stores/settings'
|
||||
import { preferencesApi } from '@@/apis/preferences'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { appearancePaused, fixedThemeMode } from '../../../common/appearance/appearance'
|
||||
import { DISPLAY_DEFAULTS } from '../../../common/appearance/typography'
|
||||
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const editing = ref<string | null>(null)
|
||||
const draft = ref({ name: '', css: '' })
|
||||
const missingTheme = computed(() => !themes.value.some(theme => theme.id === settings.theme))
|
||||
const fixedMode = computed(() => fixedThemeMode(settings.theme))
|
||||
|
||||
async function refreshThemes() {
|
||||
error.value = ''
|
||||
try {
|
||||
themes.value = await preferencesApi.themes()
|
||||
await reloadTheme()
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络'
|
||||
}
|
||||
}
|
||||
onMounted(refreshThemes)
|
||||
|
||||
async function change(patch: Partial<Settings>): Promise<boolean> {
|
||||
busy.value = true
|
||||
try {
|
||||
await updateSettings(patch)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return false
|
||||
if (err instanceof SettingsAppearanceError) {
|
||||
toast(err.message); return true
|
||||
}
|
||||
toast(`保存外观失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
|
||||
void loadSettings().catch((err) => {
|
||||
if (!isCancel(err)) toast(`重新读取外观失败:${err instanceof ApiError ? err.message : '网络错误'}`)
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function edit(snippet?: CssSnippet) {
|
||||
editing.value = snippet?.id ?? ''
|
||||
draft.value = { name: snippet?.name ?? '', css: snippet?.css ?? '' }
|
||||
}
|
||||
|
||||
async function saveSnippet() {
|
||||
if (!draft.value.name.trim()) return
|
||||
const snippets = settings.cssSnippets.map(s => ({ ...s }))
|
||||
const existing = snippets.find(s => s.id === editing.value)
|
||||
if (existing) {
|
||||
Object.assign(existing, draft.value, { name: draft.value.name.trim() })
|
||||
} else {
|
||||
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
|
||||
const id = [...crypto.getRandomValues(new Uint32Array(4))].map(n => n.toString(16).padStart(8, '0')).join('')
|
||||
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
|
||||
}
|
||||
if (await change({ cssSnippets: snippets })) editing.value = null
|
||||
}
|
||||
|
||||
async function toggle(snippet: CssSnippet) {
|
||||
await change({ cssSnippets: settings.cssSnippets.map(s => s.id === snippet.id ? { ...s, enabled: !s.enabled } : { ...s }) })
|
||||
}
|
||||
|
||||
async function remove(snippet: CssSnippet) {
|
||||
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return
|
||||
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) {
|
||||
if (editing.value === snippet.id) editing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, cssSnippets: settings.cssSnippets.map(s => ({ ...s, enabled: false })) })) {
|
||||
return
|
||||
}
|
||||
toast('已恢复默认外观与排版,CSS 片段已停用并保留')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="appearancePaused" class="rm-appearance-note">
|
||||
已临时停用主题和 CSS。可恢复默认外观后重新打开页面。
|
||||
</p>
|
||||
<SettingRow id="theme-select" control="rm-theme-select">
|
||||
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
|
||||
<option v-for="theme in themes" :key="theme.id" :value="theme.id">
|
||||
{{ theme.name }}
|
||||
</option>
|
||||
<option v-if="missingTheme" :value="settings.theme">
|
||||
{{ settings.theme }}(不可用)
|
||||
</option>
|
||||
</select>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
|
||||
刷新列表
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="color-mode">
|
||||
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
|
||||
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">
|
||||
{{ mode.title }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="fixedMode" class="rm-appearance-note">
|
||||
白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。
|
||||
</p>
|
||||
<details class="rm-settings-details rm-appearance-advanced">
|
||||
<summary>自定义 CSS 与恢复</summary>
|
||||
<SettingRow id="css-snippets">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="edit()">
|
||||
新建片段
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
|
||||
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)">{{ snippet.name }}</label>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
|
||||
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required></label>
|
||||
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
|
||||
<div>
|
||||
<el-button class="rm-btn" :disabled="busy" native-type="submit">
|
||||
保存并应用
|
||||
</el-button> <el-button class="rm-btn" native-type="button" @click="editing = null">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
<SettingRow id="reset-appearance">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset">
|
||||
恢复默认外观与排版
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p class="rm-appearance-note">
|
||||
若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。
|
||||
</p>
|
||||
</details>
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type { BackupKind, MaintenanceStatus } from '@/pages/settings/apis/type'
|
||||
import { authApi } from '@@/apis/auth'
|
||||
import { isCancel } from 'axios'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import { refreshPlugins } from '../../../common/plugins/host'
|
||||
import { flushSettings, loadSettings, reloadTheme } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
|
||||
|
||||
const emit = defineEmits<{ changed: [], importNotes: [] }>()
|
||||
|
||||
const user = useUserStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const progress = ref('')
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
const last = ref<MaintenanceStatus | null>(null)
|
||||
const fullInput = ref<HTMLInputElement>()
|
||||
const settingsInput = ref<HTMLInputElement>()
|
||||
let mounted = true
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await settingsApi.maintenanceStatus(); if (mounted) last.value = status
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `读取维护状态失败:${String(cause)}`
|
||||
}
|
||||
})
|
||||
async function prepare() {
|
||||
await Promise.all([props.prepareMaintenance(), flushSettings()])
|
||||
}
|
||||
async function perform(label: string, action: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
const version = user.getSessionVersion()
|
||||
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
||||
try {
|
||||
await prepare()
|
||||
user.assertSession(version)
|
||||
await action()
|
||||
if (mounted) {
|
||||
emit('changed'); last.value = await settingsApi.maintenanceStatus()
|
||||
}
|
||||
} catch (cause) {
|
||||
if (mounted && !isCancel(cause)) error.value = String(cause)
|
||||
} finally {
|
||||
if (mounted) {
|
||||
busy.value = false; progress.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
async function download(response: Response, fallback: string) {
|
||||
const version = user.getSessionVersion()
|
||||
const blob = await response.blob()
|
||||
user.assertSession(version)
|
||||
const header = response.headers.get('Content-Disposition') ?? ''
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
|
||||
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
|
||||
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = Object.assign(document.createElement('a'), { href: url, download: name })
|
||||
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
message.value = '导出文件已生成'
|
||||
}
|
||||
async function exportPackage(kind: BackupKind) {
|
||||
await perform('正在导出…', async () => download(await settingsApi.exportBackup(kind), `rawmark-${kind}.zip`))
|
||||
}
|
||||
async function exportMarkdown() {
|
||||
await perform('正在导出笔记…', async () => download(await settingsApi.exportNotes(), 'rawmark-notes.zip'))
|
||||
}
|
||||
async function exportSystemLogs() {
|
||||
await perform('正在导出日志…', async () => download(await settingsApi.exportLogs(), 'rawmark-system-logs.zip'))
|
||||
}
|
||||
async function syncPreferences() {
|
||||
await loadSettings(); await reloadTheme(); await refreshPlugins()
|
||||
}
|
||||
async function reset() {
|
||||
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
|
||||
await perform('正在重置设置…', async () => {
|
||||
await settingsApi.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
|
||||
})
|
||||
}
|
||||
async function restore(kind: BackupKind, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]; input.value = ''
|
||||
if (!file) return
|
||||
await perform('正在上传…', async () => {
|
||||
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
|
||||
const version = user.getSessionVersion()
|
||||
const { id } = await settingsApi.startBackupUpload(kind, file.size)
|
||||
let submitted = false
|
||||
try {
|
||||
let offset = 0
|
||||
while (offset < file.size) {
|
||||
if (!mounted) throw new Error('已取消上传')
|
||||
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
|
||||
const result = await settingsApi.uploadBackupChunk(id, offset, chunk)
|
||||
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
|
||||
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
|
||||
}
|
||||
progress.value = '正在校验备份…'
|
||||
const summary = await settingsApi.validateBackup(id)
|
||||
if (!mounted) throw new Error('已取消导入')
|
||||
const description = kind === 'full'
|
||||
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
|
||||
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
|
||||
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
|
||||
await prepare()
|
||||
user.assertSession(version)
|
||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
||||
if (kind === 'settings') {
|
||||
await settingsApi.importSettings(id); submitted = true
|
||||
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
|
||||
} else {
|
||||
await settingsApi.restoreBackup(id); submitted = true
|
||||
user.invalidate(version)
|
||||
const recoveryVersion = user.getSessionVersion()
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
if (recoveryVersion !== user.getSessionVersion()) return
|
||||
try {
|
||||
if ((await authApi.state()).setUp) {
|
||||
location.reload(); return
|
||||
}
|
||||
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
|
||||
}
|
||||
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
|
||||
}
|
||||
} finally {
|
||||
if (!submitted && version === user.getSessionVersion()) {
|
||||
try {
|
||||
await settingsApi.cancelBackupUpload(id)
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
async function index(operation: 'optimize' | 'rebuild') {
|
||||
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
|
||||
await settingsApi.maintainIndex(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
|
||||
})
|
||||
}
|
||||
async function clearTemp() {
|
||||
await perform('正在清理临时文件…', async () => {
|
||||
const result = await settingsApi.clearTemp()
|
||||
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
|
||||
if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}`
|
||||
})
|
||||
}
|
||||
function importNotes() {
|
||||
if (!busy.value) emit('importNotes')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-maintenance" :aria-busy="busy">
|
||||
<h3 class="rm-settings-group-title">
|
||||
备份与迁移
|
||||
</h3>
|
||||
<SettingRow id="backup-full">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('full')">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="fullInput!.click()">
|
||||
恢复
|
||||
</el-button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)">
|
||||
</SettingRow>
|
||||
<SettingRow id="backup-notes">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportMarkdown">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="importNotes">
|
||||
导入
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="backup-settings">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">
|
||||
导出
|
||||
</el-button><el-button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">
|
||||
导入
|
||||
</el-button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)">
|
||||
</SettingRow>
|
||||
<h3 class="rm-settings-group-title">
|
||||
维护工具
|
||||
</h3>
|
||||
<SettingRow id="index-optimize">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="index('optimize')">
|
||||
优化
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="index-rebuild">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="index('rebuild')">
|
||||
重建
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="temp-clear">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="clearTemp">
|
||||
清理
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="settings-reset">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset">
|
||||
重置
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<SettingRow id="logs-export">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="exportSystemLogs">
|
||||
导出
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p v-if="busy" role="status" aria-live="polite">
|
||||
{{ progress }}
|
||||
</p>
|
||||
<p v-if="message" role="status">
|
||||
{{ message }}
|
||||
</p>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="last?.error" class="rm-auth-error" role="alert">
|
||||
上次维护失败:{{ last.error }}
|
||||
</p>
|
||||
<p v-if="last?.backup" class="rm-share-note">
|
||||
操作前备份:backups/{{ last.backup }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-maintenance [role='alert'] {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { InstalledPlugin } from '@@/apis/plugins/type'
|
||||
import { pluginsApi } from '@@/apis/plugins'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import PluginMount from '../../../common/components/PluginMount.vue'
|
||||
import { pluginState, refreshPlugins, retryPlugin } from '../../../common/plugins/host'
|
||||
import { pluginsPaused, settingTabs } from '../../../common/plugins/registry'
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selected = ref('')
|
||||
const currentTab = computed(() => settingTabs.get(selected.value))
|
||||
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
||||
|
||||
async function perform(operation: () => Promise<unknown>) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await operation(); await refreshPlugins()
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function install(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
|
||||
await perform(() => pluginsApi.installPlugin(file))
|
||||
}
|
||||
|
||||
async function remove(plugin: InstalledPlugin) {
|
||||
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
|
||||
await perform(() => pluginsApi.uninstallPlugin(plugin.id))
|
||||
}
|
||||
|
||||
function toggle(plugin: InstalledPlugin) {
|
||||
void perform(() => pluginsApi.togglePlugin(plugin.id, !plugin.enabled))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="pluginsPaused" class="rm-appearance-note">
|
||||
已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。
|
||||
</p>
|
||||
<div class="rm-setting">
|
||||
<span class="rm-setting-label">已安装插件</span>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">
|
||||
刷新列表
|
||||
</el-button>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
|
||||
安装 ZIP
|
||||
</el-button>
|
||||
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install">
|
||||
</div>
|
||||
<p class="rm-appearance-note">
|
||||
插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。
|
||||
</p>
|
||||
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">
|
||||
{{ error || pluginState.error }}
|
||||
</p>
|
||||
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">
|
||||
尚未安装插件。
|
||||
</p>
|
||||
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
|
||||
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)">
|
||||
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
|
||||
</label>
|
||||
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
|
||||
重试
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">
|
||||
卸载
|
||||
</el-button>
|
||||
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">
|
||||
{{ pluginState.status[plugin.id].error }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="rm-appearance-note">
|
||||
若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。
|
||||
</p>
|
||||
<template v-if="settingTabs.size">
|
||||
<div class="rm-setting">
|
||||
<label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
|
||||
<select id="rm-plugin-tab" v-model="selected">
|
||||
<option value="">
|
||||
选择插件设置页
|
||||
</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">
|
||||
{{ tab.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingId } from '../settings-catalog'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { SETTING_ITEMS } from '../settings-catalog'
|
||||
|
||||
const props = defineProps<{ id: SettingId, control?: string, resettable?: boolean }>()
|
||||
defineEmits<{ reset: [] }>()
|
||||
const item = computed(() => SETTING_ITEMS[props.id])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="`rm-setting-${id}`" class="rm-setting" tabindex="-1">
|
||||
<div class="rm-setting-info">
|
||||
<component :is="control ? 'label' : 'span'" :for="control" class="rm-setting-label">
|
||||
{{ item.title }}
|
||||
</component>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="rm-setting-control">
|
||||
<slot />
|
||||
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="`重置${item.title}`" :aria-label="`重置${item.title}`" @click="$emit('reset')">
|
||||
<Icon name="restore" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { Command } from '../../../common/utils/commands'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '../../../common/utils/commands'
|
||||
import { recordedKeys } from '../../../common/utils/keys'
|
||||
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
|
||||
const commands = computed(allCommands)
|
||||
const query = ref('')
|
||||
const filteredCommands = computed(() => {
|
||||
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
|
||||
return commands.value.filter((command) => {
|
||||
const text = [command.title, command.id, ...commandKeys(command).map(formatKeys)].join(' ').toLocaleLowerCase()
|
||||
return terms.every(term => text.includes(term))
|
||||
})
|
||||
})
|
||||
const recording = ref<string | null>(null)
|
||||
const recorder = ref<HTMLInputElement>()
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
watch(query, () => {
|
||||
recording.value = null; error.value = ''
|
||||
})
|
||||
|
||||
async function save(shortcuts: Record<string, string[]>) {
|
||||
busy.value = true
|
||||
try {
|
||||
await updateSettings({ shortcuts })
|
||||
} catch (cause) {
|
||||
if (cause instanceof SettingsAppearanceError) {
|
||||
toast(cause.message); return
|
||||
}
|
||||
toast(`保存快捷键失败:${cause instanceof Error ? cause.message : String(cause)}`)
|
||||
await loadSettings().catch(cause => toast(`重新读取设置失败:${String(cause)}`))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function record(id: string) {
|
||||
error.value = ''
|
||||
recording.value = id
|
||||
await nextTick()
|
||||
recorder.value?.focus()
|
||||
}
|
||||
|
||||
async function capture(event: KeyboardEvent, command: Command) {
|
||||
if (event.key === 'Escape') {
|
||||
recording.value = null; return
|
||||
}
|
||||
const key = recordedKeys(event)
|
||||
if (!key) return
|
||||
const conflict = shortcutConflict(command.id, [key])
|
||||
if (conflict) {
|
||||
error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return
|
||||
}
|
||||
const keys = [...new Set([...commandKeys(command), key])]
|
||||
recording.value = null
|
||||
await save({ ...settings.shortcuts, [command.id]: keys })
|
||||
}
|
||||
|
||||
async function reset(command: Command) {
|
||||
const conflict = shortcutConflict(command.id, command.keys ?? [])
|
||||
if (conflict) {
|
||||
error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return
|
||||
}
|
||||
const next = { ...settings.shortcuts }
|
||||
delete next[command.id]
|
||||
error.value = ''
|
||||
await save(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-shortcut-settings">
|
||||
<div class="rm-shortcut-toolbar">
|
||||
<input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键"><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
|
||||
重置全部快捷键
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="rm-share-note">
|
||||
点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。
|
||||
</p>
|
||||
<p class="rm-share-note">
|
||||
浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。
|
||||
</p>
|
||||
<p class="rm-settings-result-count" role="status">
|
||||
{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}
|
||||
</p>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
|
||||
<span class="rm-shortcut-title">{{ command.title }}<small>{{ command.id }}</small></span>
|
||||
<div class="rm-shortcut-bindings">
|
||||
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
|
||||
<kbd>{{ formatKeys(key) }}</kbd>
|
||||
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
|
||||
</span>
|
||||
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null">
|
||||
<el-button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">
|
||||
添加
|
||||
</el-button>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="reset(command)">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageUsage } from '@/pages/settings/apis/type'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import MaintenancePanel from './MaintenancePanel.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ prepareMaintenance: () => Promise<void> }>()
|
||||
const emit = defineEmits<{ importNotes: [] }>()
|
||||
|
||||
const usage = ref<StorageUsage | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let mounted = true
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
})
|
||||
|
||||
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
|
||||
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
|
||||
const segments = computed(() => {
|
||||
let offset = 0
|
||||
return (usage.value?.categories ?? []).map((category, index) => {
|
||||
const percent = usage.value!.totalSize ? category.size / usage.value!.totalSize * 100 : 0
|
||||
const segment = { ...category, percent, offset, color: colors[index] }
|
||||
offset += percent
|
||||
return segment
|
||||
})
|
||||
})
|
||||
|
||||
function size(value: number): string {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
|
||||
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
|
||||
}
|
||||
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
|
||||
async function scan() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const next = await settingsApi.storage()
|
||||
if (mounted) usage.value = next
|
||||
} catch (cause) {
|
||||
if (mounted) error.value = `统计失败:${String(cause)}`
|
||||
} finally {
|
||||
if (mounted) busy.value = false
|
||||
}
|
||||
}
|
||||
onMounted(scan)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="rm-settings-group-title">
|
||||
空间占用
|
||||
</h3>
|
||||
<SettingRow id="storage">
|
||||
<el-button class="rm-btn" :disabled="busy" @click="scan">
|
||||
{{ busy ? '统计中…' : '刷新' }}
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p class="rm-share-note">
|
||||
统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。
|
||||
</p>
|
||||
<p v-if="error" class="rm-auth-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<div v-if="usage" class="rm-storage" :aria-busy="busy">
|
||||
<div class="rm-storage-summary">
|
||||
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比">
|
||||
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
|
||||
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
|
||||
</svg>
|
||||
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div>
|
||||
</div>
|
||||
<dl class="rm-storage-list">
|
||||
<template v-for="segment in segments" :key="segment.id">
|
||||
<div>
|
||||
<dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">
|
||||
{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small>
|
||||
</dd>
|
||||
</div>
|
||||
<template v-if="segment.id === 'fonts'">
|
||||
<div class="rm-storage-sub">
|
||||
<dt>内置可选字体</dt><dd>{{ size(usage.builtinFontsSize) }}</dd>
|
||||
</div>
|
||||
<div class="rm-storage-sub">
|
||||
<dt>导入字体</dt><dd>{{ size(usage.customFontsSize) }}</dd>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="segment.id === 'vault'">
|
||||
<div class="rm-storage-sub">
|
||||
<dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">
|
||||
{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="rm-storage-sub">
|
||||
<dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">
|
||||
{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
<MaintenancePanel :prepare-maintenance="props.prepareMaintenance" @changed="scan" @import-notes="emit('importNotes')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-storage-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.rm-storage-summary svg {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rm-storage-summary strong {
|
||||
display: block;
|
||||
font-size: var(--rm-ui-font-heading);
|
||||
margin: 6px 0;
|
||||
}
|
||||
.rm-storage-summary small {
|
||||
display: block;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
.rm-storage-list {
|
||||
margin: 0;
|
||||
}
|
||||
.rm-storage-list > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.rm-storage-list dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.rm-storage-list i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rm-storage-list dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.rm-storage-list small {
|
||||
display: inline-block;
|
||||
min-width: 52px;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
.rm-storage-list .rm-storage-sub {
|
||||
padding-left: 18px;
|
||||
color: var(--rm-text-muted);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.rm-storage-summary {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rm-storage-summary svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiToken } from '@/pages/settings/apis/type'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ApiError } from '@/http/axios'
|
||||
import { settingsApi } from '@/pages/settings/apis'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { copyText } from '../../../common/utils/clipboard'
|
||||
|
||||
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 settingsApi.tokens()
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await settingsApi.createToken(name.value.trim(), scope.value)
|
||||
tokens.value = [res.info, ...(tokens.value ?? [])]
|
||||
created.value = res.token
|
||||
name.value = ''
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
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 settingsApi.deleteToken(t.id)
|
||||
tokens.value = tokens.value!.filter(x => x.id !== t.id)
|
||||
} catch (err) {
|
||||
if (isCancel(err)) return
|
||||
toast(`吊销失败:${errorText(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form class="rm-share-form" @submit.prevent="create">
|
||||
<el-input v-model="name" type="text" autocomplete="off" placeholder="名称,如:备份脚本" aria-label="名称" required maxlength="100" />
|
||||
<el-select v-model="scope" aria-label="权限">
|
||||
<el-option value="read" label="只读" /><el-option value="write" label="读写" />
|
||||
</el-select>
|
||||
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
|
||||
创建
|
||||
</el-button>
|
||||
</form>
|
||||
<div v-if="created" class="rm-token-created">
|
||||
<p>令牌只显示这一次,请立即保存:</p>
|
||||
<div class="rm-token-value">
|
||||
<code>{{ created }}</code>
|
||||
<el-button class="rm-btn" @click="copy">
|
||||
复制
|
||||
</el-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>
|
||||
<el-button class="rm-btn rm-btn-danger" @click="revoke(t)">
|
||||
吊销
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { TrashItem } from '@@/apis/trash/type'
|
||||
import { trashApi } from '@@/apis/trash'
|
||||
import { toast } from '@@/utils/notifications'
|
||||
import { isCancel } from 'axios'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useUserStore } from '@/pinia/stores/user'
|
||||
import Icon from '../../../common/components/Icon.vue'
|
||||
import { isImage } from '../../../common/utils/paths'
|
||||
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from '../../../pinia/stores/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const props = defineProps<{ restoreNote: (id: string) => Promise<void> }>()
|
||||
const user = useUserStore()
|
||||
|
||||
const items = ref<TrashItem[] | null>(null)
|
||||
const error = ref('')
|
||||
const failures = ref<{ id: string, error: string }[]>([])
|
||||
const busy = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await trashApi.trash()
|
||||
error.value = ''
|
||||
} catch (cause) {
|
||||
error.value = `读取回收站失败:${String(cause)}`
|
||||
}
|
||||
}
|
||||
|
||||
async function retention(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const days = input.valueAsNumber
|
||||
if (!Number.isSafeInteger(days) || days < 0) {
|
||||
input.value = String(settings.trashRetentionDays)
|
||||
toast('请输入非负整数天数,0 表示永不自动删除')
|
||||
return
|
||||
}
|
||||
if (days === settings.trashRetentionDays) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSettings({ trashRetentionDays: days })
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`保存自动删除期限失败:${String(cause)}`)
|
||||
if (!(cause instanceof SettingsAppearanceError)) {
|
||||
await loadSettings().catch((cause) => {
|
||||
if (!isCancel(cause)) toast(`重新读取设置失败:${String(cause)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRestore(item: TrashItem) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await props.restoreNote(item.id); await load()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purge(item?: TrashItem) {
|
||||
if (busy.value) return
|
||||
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
|
||||
const version = user.getSessionVersion()
|
||||
busy.value = true
|
||||
failures.value = []
|
||||
try {
|
||||
if (item) {
|
||||
await trashApi.purge(item.id)
|
||||
} else {
|
||||
const result = await trashApi.clearTrash()
|
||||
failures.value = result.failed
|
||||
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (isCancel(cause)) return
|
||||
toast(`删除失败:${String(cause)}`)
|
||||
} finally {
|
||||
if (version === user.getSessionVersion()) await load()
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingRow id="trash-retention" control="rm-trash-retention">
|
||||
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention"> 天</label>
|
||||
</SettingRow>
|
||||
<SettingRow id="trash-clear">
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
|
||||
清空全部
|
||||
</el-button>
|
||||
</SettingRow>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">
|
||||
{{ error }} <el-button class="rm-btn" :disabled="busy" @click="load">
|
||||
重试
|
||||
</el-button>
|
||||
</p>
|
||||
<ul v-if="failures.length" role="alert">
|
||||
<li v-for="failure in failures" :key="failure.id">
|
||||
{{ items?.find(item => item.id === failure.id)?.path || failure.id }}:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="items && !items.length" class="rm-tree-empty">
|
||||
回收站是空的
|
||||
</p>
|
||||
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">
|
||||
读取中…
|
||||
</p>
|
||||
<ul v-else class="rm-trash-list" :aria-busy="busy">
|
||||
<li v-for="item in items ?? []" :key="item.id">
|
||||
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : '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>
|
||||
<el-button class="rm-btn" :disabled="busy" @click="doRestore(item)">
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">
|
||||
彻底删除
|
||||
</el-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user