refactor(web): align resource APIs and forms with the starter conventions

Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors.

Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
This commit is contained in:
eezd committed 2026-10-26 11:53:37 +08:00
1 parent cef0298c34
commit 6b537c03e0
114 files changed
+2876 -2217

No files matched your search

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { changePasswordApi } from '@@/apis/auth'
import Icon from '@@/components/Icon/index.vue'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { reactive, ref } from 'vue'
import { useUserStore } from '@/pinia/stores/user'
import SettingRow from './SettingRow.vue'
import TokensPanel from './TokensPanel.vue'
defineEmits<{ logout: [] }>()
const user = useUserStore()
const form = ref<FormInstance>()
const fields = reactive({ current: '', next: '', confirm: '' })
const rules: FormRules<typeof fields> = {
current: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
next: [{ required: true, min: 8, message: '新密码至少 8 位', trigger: 'blur' }],
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.next ? new Error('两次输入的新密码不一致') : undefined), trigger: 'blur' }],
}
const error = ref('')
const busy = ref(false)
async function submit() {
if (busy.value) return
busy.value = true; error.value = ''
const owner = user.getSessionVersion()
try {
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
user.assertSession(owner)
await changePasswordApi({ current: fields.current, next: fields.next })
Object.assign(fields, { current: '', next: '', confirm: '' })
form.value!.clearValidate()
toast('登录密码已修改,其他设备需要重新登录')
} catch (cause) {
if (!isCancel(cause)) error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
busy.value = false
}
}
</script>
<template>
<section id="rm-setting-password" tabindex="-1">
<h3 class="rm-settings-group-title">
修改登录密码
</h3>
<p class="rm-share-note">
修改后其他设备需要重新登录。
</p>
<el-form ref="form" class="rm-account-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="submit">
<el-form-item label="当前密码" prop="current">
<el-input v-model="fields.current" type="password" autocomplete="current-password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="next">
<el-input v-model="fields.next" type="password" autocomplete="new-password" show-password />
</el-form-item>
<el-form-item label="确认新密码" prop="confirm">
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
</el-form-item>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<el-button native-type="submit" type="primary" :loading="busy">
修改密码
</el-button>
</el-form>
</section>
<section id="rm-setting-tokens" tabindex="-1">
<h3 class="rm-settings-group-title">
API 令牌
</h3><TokensPanel />
</section>
<SettingRow id="logout">
<el-button @click="$emit('logout')">
<Icon name="logout" />退出
</el-button>
</SettingRow>
</template>
<style scoped lang="scss">
.rm-account-form {
max-width: 440px;
margin-bottom: 24px;
}
</style>
@@ -1,17 +1,23 @@
<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 type { Theme } from '@@/apis/themes/type'
import type { CssSnippet } from '@@/appearance/appearance'
import type { Settings } from '@/pinia/stores/settings'
import { getThemesApi } from '@@/apis/themes'
import { appearancePaused, fixedThemeMode } from '@@/appearance/appearance'
import { DISPLAY_DEFAULTS } from '@@/appearance/typography'
import { confirmAction } from '@@/utils/confirm'
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 { pinia } from '@/pinia'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import SettingRow from './SettingRow.vue'
const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia)
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
const error = ref('')
const busy = ref(false)
@@ -23,7 +29,7 @@ const fixedMode = computed(() => fixedThemeMode(settings.theme))
async function refreshThemes() {
error.value = ''
try {
themes.value = await preferencesApi.themes()
themes.value = (await getThemesApi()).data.themes
await reloadTheme()
} catch (err) {
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络'
@@ -75,9 +81,15 @@ async function toggle(snippet: CssSnippet) {
}
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
if (busy.value) return
busy.value = true
try {
if (!await confirmAction(`删除 CSS 片段“${snippet.name}”?`)) return
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) {
if (editing.value === snippet.id) editing.value = null
}
} finally {
busy.value = false
}
}
@@ -94,24 +106,16 @@ async function reset() {
已临时停用主题和 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-select id="rm-theme-select" :model-value="settings.theme" :disabled="busy" @change="change({ theme: $event })">
<el-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="theme.name" />
<el-option v-if="missingTheme" :value="settings.theme" :label="`${settings.theme}(不可用)`" />
</el-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>
<el-segmented :model-value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" :options="[{ value: 'light', label: '浅色' }, { value: 'dark', label: '深色' }, { value: 'system', label: '跟随系统' }]" aria-label="明暗模式" @change="change({ colorMode: $event as Settings['colorMode'] })" />
</SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
{{ error }}
@@ -127,7 +131,9 @@ async function reset() {
</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-checkbox :model-value="snippet.enabled" :disabled="busy" @change="toggle(snippet)">
{{ snippet.name }}
</el-checkbox>
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
编辑
</el-button>
@@ -135,9 +141,9 @@ async function reset() {
删除
</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>
<el-form v-if="editing !== null" class="rm-snippet-form" label-position="top" @submit.prevent="saveSnippet">
<label>片段名称<el-input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
<label>CSS<el-input v-model="draft.css" type="textarea" aria-label="CSS" :rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div>
<el-button class="rm-btn" :disabled="busy" native-type="submit">
保存并应用
@@ -145,7 +151,7 @@ async function reset() {
取消
</el-button>
</div>
</form>
</el-form>
<SettingRow id="reset-appearance">
<el-button class="rm-btn" :disabled="busy" @click="reset">
恢复默认外观与排版
@@ -0,0 +1,77 @@
<script setup lang="ts">
import FontPicker from '@@/components/FontPicker/index.vue'
import { ref, watch } from 'vue'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change'
import SettingRow from './SettingRow.vue'
const { settings } = useSettingsStore()
const change = useSettingsChange()
const fontInput = ref(String(settings.fontSize))
watch(() => settings.fontSize, (value) => {
fontInput.value = String(value)
})
function changeFont(value: string) {
fontInput.value = value
if (!value.trim()) return
const size = Number(value)
if (Number.isFinite(size) && size > 0 && size !== settings.fontSize) void change({ fontSize: size })
}
</script>
<template>
<h3 class="rm-settings-group-title">
正文与代码
</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })">
<FontPicker id="rm-text-font" setting="textFont" />
</SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })">
<FontPicker id="rm-code-font" setting="codeFont" />
</SettingRow>
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })">
<el-input id="rm-font-size" type="number" :model-value="fontInput" @input="changeFont" @blur="fontInput = String(settings.fontSize)">
<template #append>
px
</template>
</el-input>
</SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })">
<el-segmented :model-value="settings.contentWidth" :options="CONTENT_WIDTHS" aria-label="正文宽度" @change="change({ contentWidth: Number($event) })" />
</SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })">
<el-slider id="rm-line-height" :min="1.2" :max="2.5" :step="0.05" :model-value="settings.lineHeight" @change="change({ lineHeight: Number($event) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output>
</SettingRow>
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1">
<summary>排版预览</summary><article class="rm-typography-preview">
<h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code>
</article>
</details>
<h3 class="rm-settings-group-title">
编辑行为
</h3>
<SettingRow id="selection-toolbar" control="rm-selection-toolbar">
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
</SettingRow>
<SettingRow id="spellcheck" control="rm-spellcheck">
<el-switch id="rm-spellcheck" :model-value="settings.spellcheck" @change="change({ spellcheck: Boolean($event) })" />
</SettingRow>
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })">
<el-select id="rm-tab-size" :model-value="settings.tabSize" @change="change({ tabSize: $event })">
<el-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="`${n} 列`" />
</el-select>
</SettingRow>
</template>
<style scoped lang="scss">
:deep(.el-input) {
width: 130px;
}
:deep(.el-slider) {
width: 150px;
margin: 0 10px;
}
:deep(.el-segmented) {
max-width: 100%;
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { UI_FONT_SIZE } from '@@/appearance/typography'
import FontPicker from '@@/components/FontPicker/index.vue'
import { DEFAULTS, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change'
import AppearanceSettings from './AppearanceSettings.vue'
import SettingRow from './SettingRow.vue'
const { settings } = useSettingsStore()
const change = useSettingsChange()
</script>
<template>
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })">
<FontPicker id="rm-ui-font" setting="uiFont" />
</SettingRow>
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<el-input-number id="rm-ui-font-size" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" :step="1" :model-value="settings.uiFontSize" @change="($event !== undefined && $event !== null) && change({ uiFontSize: $event })" />
</SettingRow>
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })">
<el-slider id="rm-glass-opacity" :min="0.4" :max="1" :step="0.05" :model-value="settings.glassOpacity" @change="change({ glassOpacity: Number($event) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output>
</SettingRow>
<AppearanceSettings />
</template>
<style scoped lang="scss">
:deep(.el-slider) {
width: 150px;
margin: 0 10px;
}
</style>
@@ -1,18 +1,26 @@
<script setup lang="ts">
import type { BackupKind, MaintenanceStatus } from '@/pages/settings/apis/type'
import { authApi } from '@@/apis/auth'
import type { BackupKind, MaintenanceStatus } from '@@/apis/maintenance/type'
import type { AxiosHeaders, AxiosResponse } from 'axios'
import { getAuthStateApi } from '@@/apis/auth'
import { cancelBackupUploadApi, clearTempApi, exportBackupApi, exportLogsApi, exportNotesApi, getMaintenanceStatusApi, importSettingsApi, maintainIndexApi, resetSettingsApi, restoreBackupApi, startBackupUploadApi, uploadBackupChunkApi, validateBackupApi } from '@@/apis/maintenance'
import { refreshPlugins } from '@@/plugins/host'
import { confirmAction } from '@@/utils/confirm'
import { isCancel } from 'axios'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { settingsApi } from '@/pages/settings/apis'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
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 { flushSettings, loadSettings, reloadTheme } = useSettingsStore(pinia)
const user = useUserStore()
const busy = ref(false)
@@ -28,7 +36,7 @@ onBeforeUnmount(() => {
})
onMounted(async () => {
try {
const status = await settingsApi.maintenanceStatus(); if (mounted) last.value = status
const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status
} catch (cause) {
if (mounted) error.value = `读取维护状态失败:${String(cause)}`
}
@@ -45,7 +53,7 @@ async function perform(label: string, action: () => Promise<void>) {
user.assertSession(version)
await action()
if (mounted) {
emit('changed'); last.value = await settingsApi.maintenanceStatus()
emit('changed'); last.value = (await getMaintenanceStatusApi()).data
}
} catch (cause) {
if (mounted && !isCancel(cause)) error.value = String(cause)
@@ -55,11 +63,11 @@ async function perform(label: string, action: () => Promise<void>) {
}
}
}
async function download(response: Response, fallback: string) {
async function download(response: AxiosResponse<Blob, unknown, AxiosHeaders>, fallback: string) {
const version = user.getSessionVersion()
const blob = await response.blob()
const blob = response.data
user.assertSession(version)
const header = response.headers.get('Content-Disposition') ?? ''
const header = String(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
@@ -69,21 +77,29 @@ async function download(response: Response, fallback: string) {
message.value = '导出文件已生成'
}
async function exportPackage(kind: BackupKind) {
await perform('正在导出…', async () => download(await settingsApi.exportBackup(kind), `rawmark-${kind}.zip`))
await perform('正在导出…', async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`))
}
async function exportMarkdown() {
await perform('正在导出笔记…', async () => download(await settingsApi.exportNotes(), 'rawmark-notes.zip'))
await perform('正在导出笔记…', async () => download(await exportNotesApi(), 'rawmark-notes.zip'))
}
async function exportSystemLogs() {
await perform('正在导出日志…', async () => download(await settingsApi.exportLogs(), 'rawmark-system-logs.zip'))
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
}
async function syncPreferences() {
await loadSettings(); await reloadTheme(); await refreshPlugins()
}
async function reset() {
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
if (busy.value) return
busy.value = true
let confirmed = false
try {
confirmed = await confirmAction('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')
} finally {
busy.value = false
}
if (!confirmed) return
await perform('正在重置设置…', async () => {
await settingsApi.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
await resetSettingsApi(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
})
}
async function restore(kind: BackupKind, event: Event) {
@@ -93,39 +109,39 @@ async function restore(kind: BackupKind, event: Event) {
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)
const { id } = (await startBackupUploadApi({ kind, size: file.size })).data
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)
const result = (await uploadBackupChunkApi({ id, offset, body: chunk })).data
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)
const summary = (await validateBackupApi(id)).data
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
if (!await confirmAction(`备份时间:${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 importSettingsApi(id); submitted = true
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
} else {
await settingsApi.restoreBackup(id); submitted = true
await restoreBackupApi(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) {
if (((await getAuthStateApi()).data).setUp) {
location.reload(); return
}
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
@@ -135,7 +151,7 @@ async function restore(kind: BackupKind, event: Event) {
} finally {
if (!submitted && version === user.getSessionVersion()) {
try {
await settingsApi.cancelBackupUpload(id)
await cancelBackupUploadApi(id)
} catch (cause) {
if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
}
@@ -145,12 +161,12 @@ async function restore(kind: BackupKind, event: Event) {
}
async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
await settingsApi.maintainIndex(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
await maintainIndexApi(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
})
}
async function clearTemp() {
await perform('正在清理临时文件…', async () => {
const result = await settingsApi.clearTemp()
const result = (await clearTempApi()).data
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
if (result.failed.length) error.value = `部分清理失败:\n${result.failed.join('\n')}`
})
@@ -1,11 +1,15 @@
<script setup lang="ts">
import type { InstalledPlugin } from '@@/apis/plugins/type'
import { pluginsApi } from '@@/apis/plugins'
import { computed, ref } from 'vue'
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import PluginMount from '@@/components/PluginMount/index.vue'
import { pluginState, refreshPlugins, retryPlugin } from '@@/plugins/host'
import { pluginsPaused, settingTabs } from '@@/plugins/registry'
import { confirmAction } from '@@/utils/confirm'
import { isCancel } from 'axios'
import { computed, onMounted, ref } from 'vue'
import { ApiError } from '@/http/axios'
import PluginMount from '../../../common/components/PluginMount/index.vue'
import { pluginState, refreshPlugins, retryPlugin } from '../../../common/plugins/host'
import { pluginsPaused, settingTabs } from '../../../common/plugins/registry'
const busy = ref(false)
const error = ref('')
@@ -14,12 +18,16 @@ 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>) {
async function perform(operation?: () => Promise<unknown>, confirmation?: string) {
if (busy.value) return
busy.value = true
error.value = ''
try {
await operation(); await refreshPlugins()
if (confirmation && !await confirmAction(confirmation)) return
if (operation) await operation()
await refreshPlugins()
} catch (err) {
if (isCancel(err)) return
error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试'
} finally {
busy.value = false
@@ -31,18 +39,17 @@ async function install(event: Event) {
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
await perform(() => pluginsApi.installPlugin(file))
await perform(() => installPluginApi(file), '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
}
async function remove(plugin: InstalledPlugin) {
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
await perform(() => pluginsApi.uninstallPlugin(plugin.id))
await perform(() => uninstallPluginApi(plugin.id), `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
}
function toggle(plugin: InstalledPlugin) {
void perform(() => pluginsApi.togglePlugin(plugin.id, !plugin.enabled))
void perform(() => togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }))
}
onMounted(() => perform())
</script>
<template>
@@ -51,7 +58,7 @@ function toggle(plugin: InstalledPlugin) {
</p>
<div class="rm-setting">
<span class="rm-setting-label">已安装插件</span>
<el-button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">
<el-button class="rm-btn" :disabled="busy" @click="perform()">
刷新列表
</el-button>
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
@@ -68,34 +75,51 @@ function toggle(plugin: InstalledPlugin) {
<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>
<el-table :data="pluginState.installed" row-key="id" empty-text="尚未安装插件" :aria-busy="busy">
<!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="插件" min-width="170">
<template #default="{ row: plugin }">
<strong>{{ plugin.name }}</strong><small class="rm-plugin-version">{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small><p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">
{{ pluginState.status[plugin.id].error }}
</p>
</template>
</el-table-column>
<!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="启用" width="72">
<template #default="{ row: plugin }">
<el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="`启用 ${plugin.name}`" @change="toggle(plugin)" />
</template>
</el-table-column>
<!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="操作" width="130">
<template #default="{ row: plugin }">
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
重试
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
卸载
</el-button>
</template>
</el-table-column>
</el-table>
<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>
<el-select id="rm-plugin-tab" v-model="selected" clearable placeholder="选择插件设置页">
<el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" />
</el-select>
</div>
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
</template>
</template>
<style scoped lang="scss">
.rm-plugin-version {
display: block;
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
margin-top: 4px;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SettingId } from '../settings-catalog'
import Icon from '@@/components/Icon/index.vue'
import { computed } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { SETTING_ITEMS } from '../settings-catalog'
const props = defineProps<{ id: SettingId, control?: string, resettable?: boolean }>()
@@ -1,11 +1,14 @@
<script setup lang="ts">
import type { Command } from '../../../common/utils/commands'
import type { Command } from '@@/utils/commands'
import Icon from '@@/components/Icon/index.vue'
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '@@/utils/commands'
import { recordedKeys } from '@@/utils/keys'
import { toast } from '@@/utils/notifications'
import { computed, nextTick, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.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'
import { pinia } from '@/pinia'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
const { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
const commands = computed(allCommands)
const query = ref('')
@@ -76,7 +79,7 @@ async function reset(command: Command) {
<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-input v-model="query" type="search" clearable aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
重置全部快捷键
</el-button>
</div>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { StorageUsage } from '@/pages/settings/apis/type'
import type { StorageUsage } from '@@/apis/storage/type'
import { getStorageApi } from '@@/apis/storage'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { settingsApi } from '@/pages/settings/apis'
import MaintenancePanel from './MaintenancePanel.vue'
import SettingRow from './SettingRow.vue'
@@ -39,7 +41,7 @@ async function scan() {
busy.value = true
error.value = ''
try {
const next = await settingsApi.storage()
const next = (await getStorageApi()).data
if (mounted) usage.value = next
} catch (cause) {
if (mounted) error.value = `统计失败:${String(cause)}`
@@ -1,17 +1,23 @@
<script setup lang="ts">
import type { ApiToken } from '@/pages/settings/apis/type'
import type { ApiToken } from '@@/apis/tokens/type'
import type { FormInstance, FormRules } from 'element-plus'
import { createTokenApi, deleteTokenApi, getTokensApi } from '@@/apis/tokens'
import { copyText } from '@@/utils/clipboard'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ApiError } from '@/http/axios'
import { settingsApi } from '@/pages/settings/apis'
import Icon from '../../../common/components/Icon/index.vue'
import { copyText } from '../../../common/utils/clipboard'
import { useUserStore } from '@/pinia/stores/user'
const tokens = ref<ApiToken[] | null>(null)
const failed = ref(false)
const name = ref('')
const scope = ref<ApiToken['scope']>('read')
const error = ref('')
const loading = ref(false)
const form = ref<FormInstance>()
const fields = reactive({ name: '', scope: 'read' as ApiToken['scope'] })
const rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: true, message: '请输入令牌名称', trigger: 'blur' }] }
const user = useUserStore()
const busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示
const created = ref<string | null>(null)
@@ -22,20 +28,28 @@ function errorText(err: unknown) {
}
async function load() {
if (loading.value) return
loading.value = true; error.value = ''
try {
tokens.value = await settingsApi.tokens()
} catch {
failed.value = true
tokens.value = (await getTokensApi()).data.tokens
} catch (cause) {
if (!isCancel(cause)) error.value = `读取 API 令牌失败:${errorText(cause)}`
} finally {
loading.value = false
}
}
async function create() {
if (busy.value) return
busy.value = true
const owner = user.getSessionVersion()
try {
const res = await settingsApi.createToken(name.value.trim(), scope.value)
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
user.assertSession(owner)
const res = (await createTokenApi({ name: fields.name.trim(), scope: fields.scope })).data
tokens.value = [res.info, ...(tokens.value ?? [])]
created.value = res.token
name.value = ''
fields.name = ''; form.value!.clearValidate()
} catch (err) {
if (isCancel(err)) return
toast(`创建失败:${errorText(err)}`)
@@ -49,13 +63,17 @@ async function copy() {
}
async function revoke(t: ApiToken) {
if (!confirm(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
if (busy.value) return
busy.value = true
try {
await settingsApi.deleteToken(t.id)
if (!await confirmAction(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
await deleteTokenApi(t.id)
tokens.value = tokens.value!.filter(x => x.id !== t.id)
} catch (err) {
if (isCancel(err)) return
toast(`吊销失败:${errorText(err)}`)
} finally {
busy.value = false
}
}
@@ -64,15 +82,19 @@ onMounted(load)
<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-form ref="form" class="rm-token-form" :model="fields" :rules="rules" :disabled="busy" label-position="top" @submit.prevent="create">
<el-form-item label="名称" prop="name">
<el-input v-model="fields.name" autocomplete="off" placeholder="如:备份脚本" maxlength="100" />
</el-form-item>
<el-form-item label="权限">
<el-select v-model="fields.scope" aria-label="权限">
<el-option value="read" label="只读" /><el-option value="write" label="读写" />
</el-select>
</el-form-item>
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
创建
</el-button>
</form>
</el-form>
<div v-if="created" class="rm-token-created">
<p>令牌只显示这一次,请立即保存:</p>
<div class="rm-token-value">
@@ -86,25 +108,59 @@ onMounted(load)
调用时带上请求头 <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 v-if="error" class="rm-auth-error" role="alert">
{{ error }} <el-button text :disabled="loading" @click="load">
重试
</el-button>
</p>
<p v-else-if="tokens && !tokens.length" class="rm-tree-empty">
还没有 API 令牌
<p v-if="loading" role="status">
读取中…
</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>
<el-table v-else-if="tokens" :data="tokens" row-key="id" empty-text="还没有 API 令牌">
<!-- @vue-generic {ApiToken} -->
<el-table-column label="名称" min-width="140">
<template #default="{ row: token }">
<strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} 创建 · {{ token.lastUsedAt ? `${fmt.format(token.lastUsedAt)} 最近使用` : '从未使用' }}</small>
</template>
</el-table-column>
<!-- @vue-generic {ApiToken} -->
<el-table-column label="权限" width="70">
<template #default="{ row: token }">
{{ token.scope === 'write' ? '读写' : '只读' }}
</template>
</el-table-column>
<!-- @vue-generic {ApiToken} -->
<el-table-column label="操作" width="70">
<template #default="{ row: token }">
<el-button text type="danger" :disabled="busy" @click="revoke(token)">
吊销
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped lang="scss">
.rm-token-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-top: 16px;
}
.rm-token-form :deep(.el-form-item) {
flex: 1 1 140px;
margin-bottom: 0;
min-width: 0;
}
.rm-token-form :deep(.el-select) {
width: 100%;
}
.rm-token-time {
display: block;
margin-top: 4px;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
}
</style>
@@ -1,16 +1,23 @@
<script setup lang="ts">
import type { TrashItem } from '@@/apis/trash/type'
import { trashApi } from '@@/apis/trash'
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
import Icon from '@@/components/Icon/index.vue'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
import { isImage } from '@@/utils/paths'
import { isCancel } from 'axios'
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { pinia } from '@/pinia'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import Icon from '../../../common/components/Icon/index.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 { loadSettings, settings, updateSettings } = useSettingsStore(pinia)
const user = useUserStore()
const items = ref<TrashItem[] | null>(null)
@@ -18,21 +25,24 @@ const error = ref('')
const failures = ref<{ id: string, error: string }[]>([])
const busy = ref(false)
const saving = ref(false)
const retentionInput = ref(String(settings.trashRetentionDays))
watch(() => settings.trashRetentionDays, (days) => {
retentionInput.value = String(days)
})
async function load() {
try {
items.value = await trashApi.trash()
items.value = (await getTrashApi()).data.items
error.value = ''
} catch (cause) {
error.value = `读取回收站失败:${String(cause)}`
}
}
async function retention(event: Event) {
const input = event.target as HTMLInputElement
const days = input.valueAsNumber
async function retention(value: string) {
const days = value.trim() ? Number(value) : NaN
if (!Number.isSafeInteger(days) || days < 0) {
input.value = String(settings.trashRetentionDays)
retentionInput.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除')
return
}
@@ -65,15 +75,18 @@ async function doRestore(item: TrashItem) {
async function purge(item?: TrashItem) {
if (busy.value) return
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
const version = user.getSessionVersion()
busy.value = true
failures.value = []
let attempted = false
try {
if (!await confirmAction(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
user.assertSession(version)
attempted = true
if (item) {
await trashApi.purge(item.id)
await purgeTrashApi(item.id)
} else {
const result = await trashApi.clearTrash()
const result = (await clearTrashApi()).data
failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
}
@@ -81,7 +94,7 @@ async function purge(item?: TrashItem) {
if (isCancel(cause)) return
toast(`删除失败:${String(cause)}`)
} finally {
if (version === user.getSessionVersion()) await load()
if (attempted && version === user.getSessionVersion()) await load()
busy.value = false
}
}
@@ -93,7 +106,7 @@ onMounted(load)
<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>
<el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" aria-label="自动删除天数" @change="retention" /><span>天</span>
</SettingRow>
<SettingRow id="trash-clear">
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
@@ -116,20 +129,38 @@ onMounted(load)
<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>
<el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" empty-text="回收站是空的">
<!-- @vue-generic {TrashItem} -->
<el-table-column label="项目" min-width="170">
<template #default="{ row: item }">
<div class="rm-trash-name">
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" /><span>{{ item.path.replace(/\.md$/, '') }}</span>
</div><small>{{ fmt.format(item.deletedAt) }}</small>
</template>
</el-table-column>
<!-- @vue-generic {TrashItem} -->
<el-table-column label="操作" width="160">
<template #default="{ row: item }">
<el-button text :disabled="busy" @click="doRestore(item)">
恢复
</el-button><el-button text type="danger" :disabled="busy" @click="purge(item)">
彻底删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped lang="scss">
.rm-trash-name {
display: flex;
align-items: center;
gap: 8px;
overflow-wrap: anywhere;
}
small {
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
}
</style>