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