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

+42 -19
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import type { Captcha } from '@@/apis/auth'
import { authApi } from '@@/apis/auth'
import type { Captcha } from '@@/apis/auth/type'
import type { FormInstance, FormRules } from 'element-plus'
import { getCaptchaApi, loginApi, setupApi } from '@@/apis/auth'
import AppStatus from '@@/components/AppStatus/index.vue'
import { isCancel } from 'axios'
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ApiError } from '@/http/axios'
import { useUserStore } from '@/pinia/stores/user'
@@ -13,9 +16,13 @@ const route = useRoute()
const router = useRouter()
const user = useUserStore()
const isSetup = computed(() => route.name === 'Setup')
const password = ref('')
const confirm = ref('')
const captchaCode = ref('')
const form = ref<FormInstance>()
const fields = reactive({ password: '', confirm: '', captchaCode: '' })
const rules: FormRules<typeof fields> = {
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { validator: (_rule, value: string, callback) => callback(isSetup.value && value.length < 8 ? new Error('密码至少 8 位') : undefined), trigger: 'blur' }],
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.password ? new Error('两次输入的密码不一致') : undefined), trigger: 'blur' }],
captchaCode: [{ pattern: /^\d{6}$/, required: true, message: '请输入六位数字验证码', trigger: 'blur' }],
}
const captcha = ref<Captcha>()
const captchaError = ref('')
const error = ref('')
@@ -26,9 +33,9 @@ async function refresh() {
const id = ++refreshID
refreshing.value = true
captchaError.value = ''
captchaCode.value = ''
fields.captchaCode = ''
try {
const result = await authApi.captcha(captcha.value?.captchaId)
const result = (await getCaptchaApi({ replaceId: captcha.value?.captchaId })).data
if (id === refreshID) captcha.value = result
} catch (cause) {
if (id === refreshID && !isCancel(cause)) {
@@ -40,16 +47,20 @@ async function refresh() {
}
watch(isSetup, (setup) => {
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
form.value?.clearValidate()
}, { immediate: true })
onBeforeUnmount(() => {
++refreshID
})
async function submit() {
if (busy.value || (!isSetup.value && (!captcha.value || refreshing.value))) return
error.value = ''
if (isSetup.value && password.value !== confirm.value) {
error.value = '两次输入的密码不一致'; return
}
busy.value = true
const owner = user.getSessionVersion()
try {
const result = isSetup.value ? await authApi.setup(password.value) : await authApi.login(password.value, captcha.value!.captchaId, captchaCode.value)
if (!await new Promise<boolean>(resolve => form.value!.validate(valid => resolve(valid)))) return
user.assertSession(owner)
const result = isSetup.value ? (await setupApi({ password: fields.password })).data : (await loginApi({ password: fields.password, captchaId: captcha.value!.captchaId, captchaCode: fields.captchaCode })).data
if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('登录响应格式错误')
user.setToken(result.token)
const version = user.getSessionVersion()
@@ -70,15 +81,21 @@ async function submit() {
<template>
<AppStatus />
<div class="rm-auth">
<form class="rm-auth-card" @submit.prevent="submit">
<el-form ref="form" class="rm-auth-card" :model="fields" :rules="rules" label-position="top" :disabled="busy" @submit.prevent="submit">
<h1>RawMark</h1>
<p class="rm-auth-hint">
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }}
</p>
<label><span>密码</span><el-input v-model="password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" required autofocus show-password /></label>
<label v-if="isSetup"><span>确认密码</span><el-input v-model="confirm" type="password" autocomplete="new-password" required show-password /></label>
<el-form-item label="密码" prop="password">
<el-input v-model="fields.password" type="password" :autocomplete="isSetup ? 'new-password' : 'current-password'" autofocus show-password />
</el-form-item>
<el-form-item v-if="isSetup" label="确认密码" prop="confirm">
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
</el-form-item>
<template v-else>
<label><span>验证码</span><el-input v-model="captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" required placeholder="输入图片中的六位数字" /></label>
<el-form-item label="验证码" prop="captchaCode">
<el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" placeholder="输入图片中的六位数字" />
</el-form-item>
<div class="rm-captcha">
<el-button :disabled="refreshing || busy" class="rm-captcha-image" aria-label="刷新验证码" @click="refresh">
<img v-if="captcha" :src="captcha.imageDataUrl" alt="登录验证码"><span v-else>{{ refreshing ? '正在加载验证码…' : '点击重新加载验证码' }}</span>
@@ -97,13 +114,19 @@ async function submit() {
<el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
{{ isSetup ? '完成设置' : '登录' }}
</el-button>
</form>
</el-form>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.rm-auth-card :deep(.el-form-item) {
margin-bottom: 18px;
}
.rm-auth-card :deep(.el-form-item__label) {
color: var(--rm-text);
}
.rm-auth {
font-family: 'Maple Mono Normal NL NF CN', monospace;
font-family: var(--rm-font-text);
}
.rm-captcha {
display: flex;
-24
View File
@@ -1,24 +0,0 @@
import type { ApiToken, BackupKind, BackupSummary, MaintenanceStatus, StorageUsage } from './type'
import { requestJSON, requestRaw } from '@/http/axios'
export const settingsApi = {
changePassword: (current: string, next: string) => requestJSON('POST', '/auth/password', { errorOwner: 'caller', json: { current, next } }),
maintenanceStatus: () => requestJSON<MaintenanceStatus>('GET', '/maintenance/status', { errorOwner: 'caller' }),
exportBackup: (kind: BackupKind) => requestRaw('POST', `/maintenance/export/${kind}`, { errorOwner: 'caller' }),
exportNotes: () => requestRaw('POST', '/maintenance/notes/export', { errorOwner: 'caller' }),
startBackupUpload: (kind: BackupKind, size: number) => requestJSON<{ id: string }>('POST', '/maintenance/uploads', { errorOwner: 'caller', json: { kind, size } }),
uploadBackupChunk: (id: string, offset: number, body: Blob) => requestJSON<{ offset: number }>('PUT', `/maintenance/uploads/${id}`, { errorOwner: 'caller', body, headers: { 'Upload-Offset': String(offset) } }),
validateBackup: (id: string) => requestJSON<BackupSummary>('POST', `/maintenance/uploads/${id}/validate`, { errorOwner: 'caller' }),
cancelBackupUpload: (id: string) => requestJSON('DELETE', `/maintenance/uploads/${id}`, { errorOwner: 'caller' }),
importSettings: (id: string) => requestJSON('POST', '/maintenance/settings/import', { errorOwner: 'caller', json: { id, confirmed: true } }),
restoreBackup: (id: string) => requestJSON('POST', '/maintenance/restore', { errorOwner: 'caller', json: { id, confirmed: true } }),
resetSettings: () => requestJSON('POST', '/maintenance/settings/reset', { errorOwner: 'caller', json: { confirmed: true } }),
maintainIndex: (operation: 'optimize' | 'rebuild') => requestJSON('POST', `/maintenance/index/${operation}`, { errorOwner: 'caller' }),
clearTemp: () => requestJSON<{ files: number, bytes: number, failed: string[] }>('POST', '/maintenance/temp/clear', { errorOwner: 'caller' }),
exportLogs: () => requestRaw('POST', '/maintenance/logs/export', { errorOwner: 'caller' }),
tokens: async () => (await requestJSON<{ tokens: ApiToken[] }>('GET', '/tokens', { errorOwner: 'caller' })).tokens,
createToken: (name: string, scope: ApiToken['scope']) =>
requestJSON<{ token: string, info: ApiToken }>('POST', '/tokens', { errorOwner: 'caller', json: { name, scope } }),
deleteToken: (id: string) => requestJSON('DELETE', `/tokens/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
storage: () => requestJSON<StorageUsage>('GET', '/storage', { errorOwner: 'caller' }),
}
-24
View File
@@ -1,24 +0,0 @@
export interface StorageUsage {
totalSize: number
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other', size: number }[]
builtinFontsSize: number
customFontsSize: number
assetsSize: number
trashSize: number
calculatedAt: number
}
export interface MaintenanceStatus { busy: boolean, operation: string, error: string, finishedAt: number, backup?: string }
export interface BackupSummary { kind: 'full' | 'settings', createdAt: number, entries: number, size: number }
export type BackupKind = 'full' | 'settings'
export interface ApiToken {
id: string
name: string
scope: 'read' | 'write'
createdAt: number
// 0 为从未使用
lastUsedAt: number
}
@@ -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>
@@ -0,0 +1,24 @@
import type { Settings } from '@/pinia/stores/settings'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
export function useSettingsChange() {
const store = useSettingsStore()
return async (patch: Partial<Settings>): Promise<boolean> => {
try {
await store.updateSettings(patch)
return true
} catch (cause) {
if (isCancel(cause)) return false
toast(cause instanceof Error ? cause.message : '保存设置失败')
if (cause instanceof SettingsAppearanceError) return true
try {
await store.loadSettings()
} catch (reload) {
if (!isCancel(reload)) toast(`重新读取设置失败:${String(reload)}`)
}
return false
}
}
}
+419 -199
View File
@@ -1,163 +1,65 @@
<script setup lang="ts">
import type { Settings } from '../../pinia/stores/settings'
import type { SettingSearchResult } from './settings-catalog'
import type { SettingsSection } from '@/pinia/stores/settings'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError } from '@/http/axios'
import { settingsApi } from '@/pages/settings/apis'
import { UI_FONT_SIZE } from '../../common/appearance/typography'
import FontPicker from '../../common/components/FontPicker/index.vue'
import Icon from '../../common/components/Icon/index.vue'
import { runCommand } from '../../common/utils/commands'
import { installApp, installPrompt } from '../../common/utils/pwa'
import { CONTENT_WIDTHS, DEFAULTS, loadSettings, settings, SettingsAppearanceError, settingsSave, TAB_SIZES, updateSettings } from '../../pinia/stores/settings'
import AppearanceSettings from './components/AppearanceSettings.vue'
import Icon from '@@/components/Icon/index.vue'
import { runCommand } from '@@/utils/commands'
import { installApp, installPrompt } from '@@/utils/pwa'
import { computed, nextTick, ref } from 'vue'
import { useSettingsStore } from '@/pinia/stores/settings'
import AccountSettings from './components/AccountSettings.vue'
import EditorSettings from './components/EditorSettings.vue'
import InterfaceSettings from './components/InterfaceSettings.vue'
import PluginsPanel from './components/PluginsPanel.vue'
import SettingRow from './components/SettingRow.vue'
import ShortcutSettings from './components/ShortcutSettings.vue'
import StoragePanel from './components/StoragePanel.vue'
import TokensPanel from './components/TokensPanel.vue'
import TrashPanel from './components/TrashPanel.vue'
import { searchSettings, SETTINGS_SECTIONS } from './settings-catalog'
const props = defineProps<{ prepareMaintenance: () => Promise<void>, restoreNote: (id: string) => Promise<void> }>()
const emit = defineEmits<{ importNotes: [] }>()
const { settingsSave } = useSettingsStore()
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error('缺少应用构建版本信息')
const model = defineModel<SettingsSection | 'index' | null>({ required: true })
const section = computed(() => (model.value === 'index' || model.value === null ? 'appearance' : model.value))
// 手机上一次只显示分组列表或分组内容
const section = computed(() => model.value === 'index' || model.value === null ? 'appearance' : model.value)
const showList = ref(model.value === 'index')
const current = computed(() => SETTINGS_SECTIONS.find(s => s.id === section.value)!)
const query = ref('')
const searching = computed(() => !!query.value.trim())
const results = computed(() => searchSettings(query.value))
const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
async function open(id: SettingsSection) {
query.value = ''
model.value = id
showList.value = false
query.value = ''; model.value = id; showList.value = false
await nextTick()
content.value!.scrollTop = 0
heading.value!.focus({ preventScroll: true })
}
async function locate(result: SettingSearchResult) {
await open(result.section)
if (!result.id) return
const target = document.getElementById(`rm-setting-${result.id}`)!
// 设置可能位于 CSS 或排版预览的折叠区内。
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
if (parent instanceof HTMLDetailsElement) parent.open = true
}
if (target instanceof HTMLDetailsElement) target.open = true
await nextTick()
target.focus({ preventScroll: true })
target.scrollIntoView({ block: 'center' })
target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center' })
}
function back() {
query.value = ''
showList.value = true
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
query.value = ''; showList.value = true
}
function close() {
model.value = null
}
function onKey(e: KeyboardEvent) {
if (e.isComposing) return
if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation(); close()
}
if (e.key !== 'Tab') return
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
const first = controls[0]; const last = controls.at(-1)
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); last?.focus()
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); first?.focus()
}
}
onMounted(() => dialog.value!.focus({ preventScroll: true }))
onBeforeUnmount(() => {
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true })
})
async function change(patch: Partial<Settings>) {
try {
await updateSettings(patch)
} catch (err) {
if (isCancel(err)) return
if (err instanceof SettingsAppearanceError) {
toast(err.message); return
}
toast(`保存设置失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
void loadSettings().catch((error) => {
if (!isCancel(error)) toast(`重新读取设置失败:${error instanceof Error ? error.message : '网络错误'}`)
})
}
}
function changeFont(event: Event) {
const input = event.target as HTMLInputElement
const value = input.valueAsNumber
if (!Number.isFinite(value) || value <= 0) {
if (event.type === 'input') return
toast('字号必须为正数')
input.value = String(settings.fontSize)
return
}
if (value !== settings.fontSize) void change({ fontSize: value })
}
function changeUIFont(event: Event) {
const input = event.target as HTMLInputElement
const value = input.valueAsNumber
if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) {
toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`)
input.value = String(settings.uiFontSize)
return
}
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
}
// ---- 修改登录密码 ----
const pw = ref({ current: '', next: '', confirm: '' })
const pwError = ref('')
const pwBusy = ref(false)
async function changePassword() {
pwError.value = ''
if (pw.value.next.length < 8) return void (pwError.value = '新密码至少 8 位')
if (pw.value.next !== pw.value.confirm) return void (pwError.value = '两次输入的新密码不一致')
pwBusy.value = true
try {
await settingsApi.changePassword(pw.value.current, pw.value.next)
pw.value = { current: '', next: '', confirm: '' }
toast('登录密码已修改,其他设备需要重新登录')
} catch (err) {
pwError.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
} finally {
pwBusy.value = false
}
}
</script>
<template>
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
<div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
<el-dialog :model-value="model !== null" class="rm-settings-modal" title="设置" :show-close="false" :close-on-click-modal="true" align-center destroy-on-close @close="close">
<div class="rm-settings" :class="{ 'show-list': showList, searching }">
<nav class="rm-settings-nav" aria-label="设置分类">
<div class="rm-dialog-head">
<h2>设置</h2>
@@ -165,7 +67,11 @@ async function changePassword() {
<Icon name="close" />
</button>
</div>
<label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置"><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
<el-input v-model="query" class="rm-settings-search" type="search" clearable aria-label="搜索设置" placeholder="搜索设置">
<template #prefix>
<Icon name="search" />
</template>
</el-input>
<div class="rm-settings-categories">
<button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)">
<Icon :name="s.icon" />{{ s.title }}
@@ -199,90 +105,9 @@ async function changePassword() {
</div>
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<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 })">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()"> px</label>
</SettingRow>
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })">
<input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })"><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output>
</SettingRow>
<AppearanceSettings />
</template>
<template v-else-if="section === 'editor'">
<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 })">
<label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()"> px</label>
</SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })">
<div class="rm-segmented" role="radiogroup" aria-label="正文宽度">
<button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">
{{ w.label }}
</button>
</div>
</SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })">
<input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })"><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>
<template v-else-if="section === 'account'">
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
<h3>修改登录密码</h3>
<p class="rm-share-note">
修改后其他设备需要重新登录。
</p>
<label><span>当前密码</span><el-input v-model="pw.current" type="password" autocomplete="current-password" required show-password /></label>
<label><span>新密码</span><el-input v-model="pw.next" type="password" autocomplete="new-password" required show-password /></label>
<label><span>确认新密码</span><el-input v-model="pw.confirm" type="password" autocomplete="new-password" required show-password /></label>
<p v-if="pwError" class="rm-auth-error" role="alert">
{{ pwError }}
</p>
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="pwBusy">
修改密码
</el-button>
</form>
<section id="rm-setting-tokens" tabindex="-1">
<h3 class="rm-settings-group-title">
API 令牌
</h3><TokensPanel />
</section>
<SettingRow id="logout">
<el-button class="rm-btn" @click="close(), runCommand('app.logout')">
<Icon name="logout" />退出
</el-button>
</SettingRow>
</template>
<InterfaceSettings v-else-if="section === 'appearance'" />
<EditorSettings v-else-if="section === 'editor'" />
<AccountSettings v-else-if="section === 'account'" @logout="close(), runCommand('app.logout')" />
<ShortcutSettings v-else-if="section === 'shortcuts'" />
@@ -308,5 +133,400 @@ async function changePassword() {
</div>
</section>
</div>
</div>
</el-dialog>
</template>
<style scoped lang="scss">
.rm-settings {
box-sizing: border-box;
display: flex;
flex-direction: row;
width: 100%;
max-width: 960px;
height: min(720px, 82dvh);
max-height: 100%;
overflow: hidden;
border: 1px solid var(--rm-divider);
background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent);
backdrop-filter: blur(20px);
outline: none;
}
.rm-settings-nav {
box-sizing: border-box;
flex: none;
width: 192px;
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--rm-divider);
background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent);
}
.rm-settings :deep(.rm-dialog-head) {
flex: none;
box-sizing: border-box;
min-height: 52px;
gap: 8px;
padding: 8px 16px;
}
.rm-settings :deep(.rm-dialog-head h2) {
font-size: var(--rm-ui-font-size);
font-weight: 600;
min-width: 0;
outline: none;
}
.rm-settings-close-list,
.rm-settings-back {
display: none;
}
.rm-settings-search {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
min-height: var(--rm-control-size);
margin: 0 8px 8px;
padding: 0 6px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
}
.rm-settings-search > .rm-icon {
flex: none;
width: 16px;
height: 16px;
color: var(--rm-text-muted);
}
.rm-settings-search input {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: var(--rm-control-size);
padding: 4px 0;
border: 0;
outline: 0;
background: none;
color: var(--rm-text);
font: inherit;
}
.rm-settings-search input::-webkit-search-cancel-button {
display: none;
}
.rm-settings-search:focus-within {
border-color: var(--rm-accent);
}
.rm-settings-categories {
min-height: 0;
overflow-y: auto;
padding: 0 8px 8px;
}
.rm-settings-tab {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: var(--rm-control-size);
margin: 2px 0;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: none;
color: var(--rm-text-muted);
font: inherit;
text-align: left;
cursor: pointer;
}
.rm-settings-tab .rm-icon {
flex: none;
}
.rm-settings-tab:hover {
background: var(--rm-hover);
}
.rm-settings-tab.on {
background: var(--rm-active);
color: var(--rm-accent-strong);
}
.rm-settings-nav-foot {
margin-top: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
}
.rm-settings-nav-foot small {
font-size: inherit;
overflow-wrap: anywhere;
}
.rm-settings-body {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.rm-settings-body > .rm-dialog-head {
border-bottom: 1px solid var(--rm-divider);
}
.rm-settings-save {
flex: none;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
}
.rm-settings-save.error {
color: var(--rm-danger);
}
.rm-settings-content {
flex: 1;
min-height: 0;
min-width: 0;
overflow-y: auto;
padding: 0 24px 16px;
}
.rm-settings-page {
min-width: 0;
}
.rm-settings :deep(.rm-setting) {
flex-wrap: wrap;
gap: 12px 16px;
padding: 12px 0;
border-bottom: 1px solid var(--rm-divider);
font-size: var(--rm-ui-font-size);
}
:deep(.rm-setting-info) {
flex: 1 1 180px;
min-width: 0;
}
:deep(.rm-setting-info p) {
margin: 4px 0 0;
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
line-height: var(--rm-ui-line-height);
overflow-wrap: anywhere;
}
:deep(.rm-setting-control) {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
max-width: 100%;
min-width: 0;
margin-left: auto;
}
:deep(.rm-setting-control output) {
min-width: 42px;
text-align: right;
font-variant-numeric: tabular-nums;
}
:deep(.rm-setting-control code) {
overflow-wrap: anywhere;
}
:deep(.rm-setting-reset) {
flex: none;
color: var(--rm-text-muted);
}
:deep(.rm-settings-group-title) {
margin: 20px 0 0;
font-size: var(--rm-ui-font-size);
font-weight: 600;
color: var(--rm-text-muted);
}
:deep(.rm-settings-group-title:first-child) {
margin-top: 16px;
}
:deep(.rm-settings-details) {
margin: 12px 0;
}
:deep(.rm-settings-details > summary) {
padding: 8px 0;
min-height: var(--rm-control-size);
box-sizing: border-box;
cursor: pointer;
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
}
:deep(.rm-typography-preview) {
margin: 8px 0 0;
padding: 16px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-sidebar-bg);
font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text));
overflow-wrap: anywhere;
}
:deep(.rm-typography-preview h2) {
margin-top: 0;
font-family: var(--rm-font-heading);
font-size: 1.5em;
}
:deep(.rm-typography-preview p) {
margin: 0;
}
:deep(.rm-typography-preview p + p) {
margin-top: 1lh;
}
:deep(.rm-typography-preview code) {
font-family: var(--rm-font-mono);
font-size: 0.9em;
color: var(--rm-inline-code-text);
}
.rm-settings :deep(.rm-share-note),
.rm-settings :deep(.rm-appearance-note) {
margin: 12px 0;
overflow-wrap: anywhere;
}
.rm-settings :deep(.rm-token-created) {
margin: 12px 0;
}
.rm-settings :deep(.rm-snippet-form) {
padding: 12px 0;
}
.rm-settings :deep(.rm-plugin-view) {
padding: 12px 0;
}
.rm-settings-unavailable {
max-width: 240px;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
}
.rm-settings-result-count {
margin: 12px 0;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
}
.rm-settings-result {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 0;
border: 0;
border-bottom: 1px solid var(--rm-divider);
background: none;
color: var(--rm-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.rm-settings-result:hover {
background: var(--rm-hover);
}
.rm-settings-result > span {
flex: 1;
min-width: 0;
}
.rm-settings-result strong {
font-weight: 500;
}
.rm-settings-result small {
display: block;
margin-top: 4px;
font-size: var(--rm-ui-font-small);
color: var(--rm-text-muted);
overflow-wrap: anywhere;
}
.rm-settings-result > .rm-icon {
flex: none;
color: var(--rm-text-muted);
}
@media (max-width: 767px) {
.rm-settings {
height: 100dvh;
max-width: none;
border: 0;
border-radius: 0;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
background: var(--rm-bg);
}
.rm-settings-nav {
display: none;
width: 100%;
border-right: 0;
}
.rm-settings.show-list .rm-settings-nav {
display: flex;
}
.rm-settings.show-list .rm-settings-body {
display: none;
}
.rm-settings.show-list.searching {
flex-direction: column;
}
.rm-settings.show-list.searching .rm-settings-body {
display: flex;
}
.rm-settings.show-list.searching
:is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) {
display: none;
}
.rm-settings.show-list:not(.searching) .rm-settings-nav {
flex: 1;
}
.rm-settings :deep(.rm-dialog-head) {
padding: 8px 12px;
}
.rm-settings-close-list,
.rm-settings-back {
display: inline-flex;
}
.rm-settings-content {
padding: 0 16px 16px;
}
.rm-settings-search {
min-height: 44px;
}
.rm-settings-tab {
min-height: 44px;
}
.rm-settings-save {
max-width: 6em;
}
.rm-settings :deep(.rm-setting-info) {
flex-basis: 100%;
}
.rm-settings :deep(.rm-setting-control) {
width: 100%;
max-width: 100%;
}
}
:global(.rm-settings-modal.el-dialog) {
width: min(960px, calc(100vw - 48px));
padding: 0;
overflow: hidden;
background: transparent;
}
:global(.rm-settings-modal > .el-dialog__header) {
display: none;
}
:global(.rm-settings-modal > .el-dialog__body) {
padding: 0;
max-height: none;
overflow: hidden;
}
.rm-settings {
max-height: none;
border-radius: 12px;
}
.rm-settings-search {
width: calc(100% - 16px);
padding: 0;
border: none;
background: none;
}
@media (max-width: 767px) {
:global(.rm-settings-modal.el-dialog) {
width: 100%;
max-width: 100vw;
margin: 0;
border-radius: 0;
}
.rm-settings {
border-radius: 0;
}
}
</style>
+1 -1
View File
@@ -1,4 +1,4 @@
import type { IconName } from '../../common/components/Icon/index.vue'
import type { IconName } from '@@/components/Icon/index.vue'
import type { SettingsSection } from '@/pinia/stores/settings'
export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [
-46
View File
@@ -1,46 +0,0 @@
import type { TrashItem } from '@@/apis/trash/type'
import type { TreeIcon } from '@@/apis/vault/type'
import type { ImportResult, NoteMetadata, SearchResult } from './type'
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
export const workspaceApi = {
importNotes: (dir: string, folder: string, files: File[]) => {
const body = new FormData()
body.append('dir', dir)
body.append('folder', folder)
for (const file of files) body.append('files', file)
return requestJSON<ImportResult>('POST', '/import', { errorOwner: 'caller', body })
},
exportFiles: (paths: string[]) => requestRaw('POST', '/export', { errorOwner: 'caller', json: { paths } }),
metadata: () => requestJSON<{ notes: NoteMetadata[], ready: boolean }>('GET', '/metadata', { errorOwner: 'caller' }),
setTreeIcon: (path: string, icon: TreeIcon | null) => requestJSON('PUT', '/tree-icon', { errorOwner: 'caller', json: { path, icon } }),
search: async (q: string) => (await requestJSON<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`, { errorOwner: 'caller' })).results,
async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (ifMatch === '') headers['If-None-Match'] = '*'
else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"`
const res = await requestJSON<{ rev: string }>('PUT', `/files/${encodePath(path)}`, { errorOwner: 'caller', body: content, headers, keepalive: keepalive && new Blob([content]).size < 60_000 })
return res.rev
},
deleteFile: (path: string) => requestJSON<TrashItem>('DELETE', `/files/${encodePath(path)}`, { errorOwner: 'caller' }),
move: (from: string, to: string) => requestJSON('POST', '/move', { errorOwner: 'caller', json: { from, to } }),
mkdir: (path: string) => requestJSON('POST', '/folders', { errorOwner: 'caller', json: { path } }),
async upload(note: string, file: File): Promise<string> {
const form = new FormData()
form.append('file', file, file.name || 'image')
return (await requestJSON<{ link: string }>('POST', `/assets?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', body: form })).link
},
cleanupImages: (paths: string[]) => requestJSON<{ deleted: TrashItem[], skipped: { path: string, reason: string }[] }>('POST', '/assets/cleanup', { errorOwner: 'caller', json: { paths } }),
uploadFolderImage: async (folder: string, file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<{ link: string }>('POST', `/assets?folder=${encodeURIComponent(folder)}`, { errorOwner: 'caller', body })
},
replaceImage: (path: string, file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON('PUT', `/assets/${encodePath(path)}`, { errorOwner: 'caller', body })
},
importImage: async (note: string, url: string): Promise<string> =>
(await requestJSON<{ link: string }>('POST', `/assets/import?note=${encodeURIComponent(note)}`, { errorOwner: 'caller', json: { url } })).link,
}
-23
View File
@@ -1,23 +0,0 @@
export interface ImportResult {
paths: string[]
notes: number
assets: number
warnings: string[]
}
export interface NoteMetadata {
path: string
rev: string
title: string
tags: string[]
links: { target: string, line: number }[]
assets: { target: string, line: number }[]
warnings: string[]
}
export interface SearchResult {
path: string
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
line: number
snippet: string
}
+6 -2
View File
@@ -1,12 +1,15 @@
import * as filesApi from '@@/apis/files'
import { toasts } from '@@/utils/notifications'
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { workspaceApi } from './apis'
import { batchFiles, exportFiles } from './batch'
import { flush, remove } from './workspace'
vi.mock('@@/utils/confirm', () => ({ confirmAction: vi.fn(async () => true) }))
vi.mock('./workspace', () => ({ move: vi.fn(), remove: vi.fn(), refreshTree: vi.fn().mockResolvedValue(undefined), flush: vi.fn(), ws: { tabs: [{ path: 'a.md', dirty: false, status: 'ready' }] } }))
const user = useUserStore(pinia)
beforeEach(() => {
@@ -22,6 +25,7 @@ it('切换会话后批量操作停止,不以新身份继续下一项', async (
finish = resolve
}))
const pending = batchFiles('delete', ['a.md', 'b.md'])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new')
finish({ ok: true })
await pending
@@ -33,7 +37,7 @@ it('等待笔记保存时换会话,不再使用新身份导出旧选择', asyn
vi.mocked(flush).mockImplementation(() => new Promise((resolve) => {
finish = resolve
}))
const request = vi.spyOn(workspaceApi, 'exportFiles')
const request = vi.spyOn(filesApi, 'exportFilesApi')
const pending = exportFiles(['a.md'])
user.setToken('rms_new')
finish()
+11 -7
View File
@@ -1,9 +1,12 @@
import { exportFilesApi } from '@@/apis/files'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
import { basename, joinPath } from '@@/utils/paths'
import { isCancel } from 'axios'
import { workspaceApi } from '@/pages/workspace/apis'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { basename, joinPath } from '../../common/utils/paths'
import { minimalPaths } from './selection'
import { flush, move, refreshTree, remove, ws } from './workspace'
@@ -11,13 +14,13 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des
const user = useUserStore(pinia)
const version = user.getSessionVersion()
const subjects = minimalPaths(paths)
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
if (action === 'delete' && !await confirmAction(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
const failures: string[] = []
let succeeded = 0
for (const path of subjects) {
try {
user.assertSession(version)
const result = action === 'move' ? await move(path, joinPath(destination, basename(path))) : await remove(path)
const result = action === 'move' ? await move(path, joinPath(destination, basename(path)), 'caller') : await remove(path, 'caller')
if (result.ok) succeeded++
else failures.push(`${path}:${result.error}`)
} catch (cause) {
@@ -34,6 +37,7 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des
toast(`刷新目录失败:${String(cause)}`)
}
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`)
return failures.length === 0
}
export async function exportFiles(paths: string[]) {
@@ -45,10 +49,10 @@ export async function exportFiles(paths: string[]) {
await Promise.all(tabs.map(tab => flush(tab.path)))
user.assertSession(version)
if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
const response = await workspaceApi.exportFiles(subjects)
const blob = await response.blob()
const response = await exportFilesApi({ paths: subjects })
const blob = response.data
user.assertSession(version)
const disposition = response.headers.get('Content-Disposition') ?? ''
const disposition = String(response.headers.get('content-disposition') ?? '')
const encoded = /filename\*=utf-8''([^;]+)/i.exec(disposition)?.[1]
const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition)
const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import Icon from '../../../common/components/Icon/index.vue'
import { runCommand } from '../../../common/utils/commands'
import Icon from '@@/components/Icon/index.vue'
import { runCommand } from '@@/utils/commands'
import { toggleSourceMode, ws } from '../workspace'
</script>
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { confirmAction } from '@@/utils/confirm'
import { basename, dirname, isImage, joinPath } from '@@/utils/paths'
import { computed, ref, watch } from 'vue'
import { ApiError } from '@/http/axios'
import Icon from '../../../common/components/Icon/index.vue'
import { basename, dirname, isImage, joinPath } from '../../../common/utils/paths'
import { imageUrl, move, remove, replaceImage, ws } from '../workspace'
const props = defineProps<{ path: string }>()
const dialog = ref<HTMLElement>()
const picker = ref<HTMLInputElement>()
const busy = ref(false)
const error = ref('')
@@ -27,7 +26,6 @@ watch(path, (p) => {
watch(src, () => {
loadError.value = false; dimensions.value = ''
})
onMounted(() => dialog.value?.focus())
function close() {
if (!busy.value) ws.image = null
@@ -53,7 +51,8 @@ async function rename() {
busy.value = true
error.value = ''
try {
await move(path.value, to)
const result = await move(path.value, to, 'caller')
if (!result.ok) error.value = result.error
} catch (err) {
error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录'
} finally {
@@ -72,10 +71,10 @@ async function replace(event: Event) {
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) {
error.value = '请选择与原图片扩展名相同的文件'; return
}
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
busy.value = true
error.value = ''
try {
if (!await confirmAction('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
await replaceImage(path.value, file)
} catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试'
@@ -85,9 +84,10 @@ async function replace(event: Event) {
}
async function trash() {
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
if (busy.value) return
busy.value = true
try {
if (!await confirmAction('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
await remove(path.value)
} finally {
busy.value = false
@@ -96,70 +96,62 @@ async function trash() {
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
<div class="rm-dialog-head">
<h2>图片管理</h2>
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close">
<Icon name="close" />
</button>
<el-dialog :model-value="true" title="图片管理" width="860px" class="rm-image-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="close" @close="close">
<div class="rm-image-body">
<p class="rm-image-path">
{{ path }}
</p>
<div class="rm-image-preview">
<p v-if="!entry">
图片已不存在,请刷新目录
</p>
<p v-else-if="loadError" role="alert">
图片无法预览,文件可能损坏或浏览器不支持此格式。
</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true">
</div>
<div class="rm-image-body">
<p class="rm-image-path">
{{ path }}
</p>
<div class="rm-image-preview">
<p v-if="!entry">
图片已不存在,请刷新目录
</p>
<p v-else-if="loadError" role="alert">
图片无法预览,文件可能损坏或浏览器不支持此格式。
</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true">
</div>
<div class="rm-image-navigation">
<el-button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">
上一张
</el-button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
下一张
</el-button>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<div>
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required>
<span>{{ extension }}</span>
<el-button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
重命名
</el-button>
</div>
</form>
<p class="rm-image-hint">
改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace">
<el-button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">
移动到…
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
移入回收站
</el-button>
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
{{ busy ? '处理中…' : '替换图片' }}
</el-button>
</div>
<div class="rm-image-navigation">
<el-button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">
上一张
</el-button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
下一张
</el-button>
</div>
</section>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<div>
<el-input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
<span>{{ extension }}</span>
<el-button native-type="submit" class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">
重命名
</el-button>
</div>
</form>
<p class="rm-image-hint">
改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace">
<el-button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">
移动到…
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
移入回收站
</el-button>
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
{{ busy ? '处理中…' : '替换图片' }}
</el-button>
</div>
</div>
</el-dialog>
</template>
<style scoped>
<style scoped lang="scss">
.rm-image-dialog {
max-width: 860px;
width: min(860px, calc(100vw - 24px));
@@ -168,7 +160,7 @@ async function trash() {
display: flex;
flex-direction: column;
gap: 14px;
padding: 0 20px 20px;
padding: 0;
overflow-y: auto;
font-size: var(--rm-ui-font-size);
}
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Entry } from '@@/apis/vault/type'
import type { Entry } from '@@/apis/tree/type'
import Icon from '@@/components/Icon/index.vue'
import { toast } from '@@/utils/notifications'
import { basename, hiddenAssetPath, imageInFolder, isImage } from '@@/utils/paths'
import { computed, onMounted, ref } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { basename, hiddenAssetPath, imageInFolder, isImage } from '../../../common/utils/paths'
import { metadata, refreshMetadata } from '../metadata'
import { cleanupImages, imageUrl, openReference, uploadFolderImages, ws } from '../workspace'
@@ -106,21 +106,11 @@ async function clean() {
</el-button>
</header>
<div class="rm-media-mobile-filters">
<select v-model="ws.imagesAll" aria-label="资源范围">
<option :value="false">
当前目录
</option><option :value="true">
整个知识库
</option>
</select><select v-model="usage" aria-label="引用状态">
<option value="all">
全部图片
</option><option value="used" :disabled="!certain">
已被引用
</option><option value="unused" :disabled="!certain">
未被引用
</option>
</select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
<el-select v-model="ws.imagesAll" aria-label="资源范围">
<el-option :value="false" label="当前目录" /><el-option :value="true" label="整个知识库" />
</el-select><el-select v-model="usage" aria-label="引用状态">
<el-option value="all" label="全部图片" /><el-option value="used" :disabled="!certain" label="已被引用" /><el-option value="unused" :disabled="!certain" label="未被引用" />
</el-select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
清理孤立图片
</el-button>
</div>
@@ -138,26 +128,20 @@ async function clean() {
</aside>
<div class="rm-media-content">
<div class="rm-media-toolbar">
<label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片"></label><select v-model="format" aria-label="图片格式">
<option value="">
所有格式
</option><option v-for="type in types" :key="type" :value="type">
{{ type.toUpperCase() }}
</option>
</select><select v-model="sort" aria-label="图片排序">
<option value="modified">
最近修改
</option><option value="name">
名称
</option><option value="size">
文件大小
</option>
</select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
<el-input v-model="query" class="rm-media-search" type="search" clearable placeholder="搜索图片名称或路径…" aria-label="搜索图片">
<template #prefix>
<Icon name="search" />
</template>
</el-input><el-select v-model="format" aria-label="图片格式">
<el-option value="" label="所有格式" /><el-option v-for="type in types" :key="type" :value="type" :label="type.toUpperCase()" />
</el-select><el-select v-model="sort" aria-label="图片排序">
<el-option value="modified" label="最近修改" /><el-option value="name" label="名称" /><el-option value="size" label="文件大小" />
</el-select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
<Icon :name="list ? 'grid' : 'outline'" />
</button>
</div>
<div class="rm-media-upload">
<label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
<label>上传至 <el-select v-model="uploadDir" aria-label="上传目录"><el-option value="" label="根目录 / assets" /><el-option v-for="folder in directories" :key="folder.path" :value="folder.path" :label="`${folder.path} / assets`" /></el-select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
<Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}
</el-button>
</div>
@@ -198,33 +182,52 @@ async function clean() {
</p>
</aside>
</div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)">
<section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片">
<div class="rm-dialog-head">
<h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null">
<Icon name="close" />
</button>
</div><p class="rm-share-note">
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
</p><ul>
<li v-for="image in cleanupCandidates" :key="image.path">
<label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy">{{ image.path }} <small>{{ size(image.size) }}</small></label>
</li>
</ul><p v-if="!cleanupCandidates.length" class="rm-share-note">
当前范围没有可清理的孤立图片。
</p><div class="rm-cleanup-actions">
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
取消
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
移入回收站({{ cleanupSelected.length }})
</el-button>
</div>
</section>
</div>
<el-dialog :model-value="cleanupCandidates !== null" :title="`清理候选 · ${cleanupCandidates?.length ?? 0} 张`" width="640px" class="rm-cleanup-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="() => { if (!busy) cleanupCandidates = null }" @close="cleanupCandidates = null">
<p class="rm-share-note">
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
</p><ul>
<li v-for="image in cleanupCandidates ?? []" :key="image.path">
<el-checkbox-group v-model="cleanupSelected">
<el-checkbox :value="image.path" :disabled="busy">
{{ image.path }} <small>{{ size(image.size) }}</small>
</el-checkbox>
</el-checkbox-group>
</li>
</ul><p v-if="!cleanupCandidates?.length" class="rm-share-note">
当前范围没有可清理的孤立图片。
</p><div class="rm-cleanup-actions">
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
取消
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
移入回收站({{ cleanupSelected.length }})
</el-button>
</div>
</el-dialog>
</section>
</template>
<style scoped>
<style scoped lang="scss">
.rm-media-toolbar :deep(.el-select) {
width: 130px;
}
.rm-media-upload :deep(.el-select) {
width: 240px;
max-width: 100%;
}
.rm-media-mobile-filters :deep(.el-select) {
width: 130px;
}
.rm-media-search.el-input {
padding: 0;
border: 0;
}
:deep(.el-checkbox) {
height: auto;
white-space: normal;
}
:deep(.el-checkbox__label) {
overflow-wrap: anywhere;
}
.rm-media-library {
display: flex;
flex-direction: column;
@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { ImportResult } from '@/pages/workspace/apis/type'
import { computed, onMounted, ref } from 'vue'
import type { ImportResult } from '@@/apis/files/type'
import { dirname } from '@@/utils/paths'
import { computed, ref } from 'vue'
import { ApiError } from '@/http/axios'
import Icon from '../../../common/components/Icon/index.vue'
import { dirname } from '../../../common/utils/paths'
import { expanded } from '../tree'
import { importNotes } from '../workspace'
@@ -17,13 +16,11 @@ const error = ref('')
const result = ref<ImportResult | null>(null)
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
const dialog = ref<HTMLElement>()
const validation = computed(() => {
if (!zipped.value && files.value.some(f => !f.name.endsWith('.md'))) return '请选择 .md 文件,或单独选择一个 ZIP'
if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return '导入文件总量不能超过 100 MB'
return ''
})
onMounted(() => dialog.value?.focus())
function choose(event: Event) {
const input = event.target as HTMLInputElement
@@ -64,70 +61,62 @@ async function submit() {
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
<section ref="dialog" class="rm-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="导入 Markdown" :aria-busy="busy">
<div class="rm-dialog-head">
<h2>导入 Markdown</h2>
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close">
<Icon name="close" />
</button>
<el-dialog :model-value="true" title="导入 Markdown" width="560px" class="rm-import-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="close" @close="close">
<el-form class="rm-import-body" label-position="top" :disabled="busy" @submit.prevent="submit">
<p class="rm-import-target">
导入到:{{ dir || '根目录' }}
</p>
<template v-if="!result">
<label>选择 Markdown 文件或一个 ZIP
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
</label>
<label v-if="zipped">新建文件夹名称
<el-input v-model="folder" required @input="error = ''" />
</label>
<div v-else-if="files.length" class="rm-import-files">
<label v-for="(file, i) in files" :key="i">{{ file.name }}
<el-input v-model="names[i]" required aria-label="导入文件名称" @input="error = ''" />
</label>
</div>
<p class="rm-import-hint">
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
</p>
</template>
<div v-if="result" role="status">
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
<details v-if="result.warnings.length" class="rm-import-warnings">
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
<ul>
<li v-for="warning in result.warnings" :key="warning">
{{ warning }}
</li>
</ul>
</details>
</div>
<form class="rm-import-body" @submit.prevent="submit">
<p class="rm-import-target">
导入到:{{ dir || '根目录' }}
</p>
<template v-if="!result">
<label>选择 Markdown 文件或一个 ZIP
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
</label>
<label v-if="zipped">新建文件夹名称
<input v-model="folder" :disabled="busy" required @input="error = ''">
</label>
<div v-else-if="files.length" class="rm-import-files">
<label v-for="(file, i) in files" :key="i">{{ file.name }}
<input v-model="names[i]" :disabled="busy" required aria-label="导入文件名称" @input="error = ''">
</label>
</div>
<p class="rm-import-hint">
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
</p>
</template>
<div v-if="result" role="status">
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
<details v-if="result.warnings.length" class="rm-import-warnings">
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
<ul>
<li v-for="warning in result.warnings" :key="warning">
{{ warning }}
</li>
</ul>
</details>
</div>
<p v-if="error || validation" class="rm-auth-error" role="alert">
{{ error || validation }}
</p>
<p v-if="busy" role="status">
正在上传并整理,请勿关闭页面…
</p>
<div class="rm-import-actions">
<el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
{{ result ? '完成' : '取消' }}
</el-button>
<el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
{{ busy ? '导入中…' : '导入' }}
</el-button>
</div>
</form>
</section>
</div>
<p v-if="error || validation" class="rm-auth-error" role="alert">
{{ error || validation }}
</p>
<p v-if="busy" role="status">
正在上传并整理,请勿关闭页面…
</p>
<div class="rm-import-actions">
<el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
{{ result ? '完成' : '取消' }}
</el-button>
<el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
{{ busy ? '导入中…' : '导入' }}
</el-button>
</div>
</el-form>
</el-dialog>
</template>
<style scoped>
<style scoped lang="scss">
.rm-import-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 20px 20px;
padding: 4px 0;
overflow-y: auto;
font-size: var(--rm-ui-font-size);
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import { computed, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { metadata, refreshMetadata } from '../metadata'
import { openNote, openReference, ws } from '../workspace'
@@ -74,13 +74,9 @@ watch(() => ws.view, () => {
</el-button>
</header>
<div class="rm-knowledge-tools">
<label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记"></label><select v-model="tag" aria-label="筛选标签">
<option value="">
全部标签
</option><option v-for="[name, count] in tags" :key="name" :value="name">
#{{ name }} · {{ count }}
</option>
</select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active">仅当前笔记关联</label>
<label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记"></label><el-select v-model="tag" aria-label="筛选标签">
<el-option value="" label="全部标签" /><el-option v-for="[name, count] in tags" :key="name" :value="name" :label="`#${name} · ${count}`" />
</el-select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active">仅当前笔记关联</label>
</div>
<p v-if="metadata.error" role="alert">
{{ metadata.error }} <el-button class="rm-btn" @click="refreshMetadata">
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '../../../common/utils/paths'
import type { InputInstance } from 'element-plus'
import Icon from '@@/components/Icon/index.vue'
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '@@/utils/paths'
import { isCancel } from 'axios'
import { computed, ref, watch } from 'vue'
import { batchFiles } from '../batch'
import { fuzzyMatch, segments } from '../fuzzy'
import { minimalPaths } from '../selection'
@@ -11,10 +13,12 @@ import { move, ws } from '../workspace'
const props = defineProps<{ path: string | string[] }>()
const emit = defineEmits<{ close: [] }>()
const input = ref<HTMLInputElement>()
const input = ref<InputInstance>()
const list = ref<HTMLElement>()
const query = ref('')
const selected = ref(0)
const busy = ref(false)
const error = ref('')
const paths = computed(() => minimalPaths(typeof props.path === 'string' ? [props.path] : props.path))
const title = computed(() => paths.value.length > 1 ? `${paths.value.length} 项` : noteTitle(paths.value[0]))
@@ -38,59 +42,71 @@ const items = computed(() => {
watch(query, () => (selected.value = 0))
watch(selected, i => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
function choose(i: number) {
async function choose(i: number) {
const target = items.value[i]
if (!target) return
emit('close')
if (paths.value.length === 1) void move(paths.value[0], joinPath(target.path, basename(paths.value[0])))
else void batchFiles('move', paths.value, target.path)
if (!target || busy.value) return
busy.value = true; error.value = ''
try {
if (paths.value.length === 1) {
const result = await move(paths.value[0], joinPath(target.path, basename(paths.value[0])), 'caller')
if (!result.ok) {
error.value = result.error; return
}
} else if (!await batchFiles('move', paths.value, target.path)) {
error.value = '部分项目移动失败,请核对目录后重试'; return
}
emit('close')
} catch (cause) {
if (!isCancel(cause)) error.value = String(cause)
} finally {
busy.value = false
}
}
function close() {
if (!busy.value) emit('close')
}
function opened() {
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
}
function onKeydown(e: KeyboardEvent) {
const n = items.value.length
if (e.key === 'Escape') emit('close')
if (e.key === 'Escape') close()
else if (e.key === 'ArrowDown' && n) selected.value = (selected.value + 1) % n
else if (e.key === 'ArrowUp' && n) selected.value = (selected.value - 1 + n) % n
else if (e.key === 'Enter' && !e.isComposing && n) choose(selected.value)
else return
e.preventDefault()
}
onMounted(() => {
// 触屏设备上不自动弹出键盘,文件夹不多时直接点选更方便
if (!matchMedia('(pointer: coarse)').matches) input.value?.focus()
})
</script>
<template>
<div class="rm-switcher-backdrop" @mousedown.self="emit('close')">
<div class="rm-switcher" role="dialog" :aria-label="`移动“${title}”`">
<div class="rm-search-box">
<el-dialog :model-value="true" :title="`移动“${title}”`" width="560px" class="rm-picker-dialog" :show-close="!busy" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :before-close="close" @close="close" @opened="opened">
<el-input ref="input" v-model="query" :disabled="busy" :placeholder="`把“${title}”移动到…`" aria-label="搜索目标文件夹" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<template #prefix>
<Icon name="folder" />
<input ref="input" v-model="query" class="rm-search-input" :placeholder="`把“${title}”移动到…`" enterkeyhint="go" @keydown="onKeydown">
<button class="rm-icon-btn" title="取消" @click="emit('close')">
<Icon name="close" />
</button>
</div>
<ul v-if="items.length" ref="list" class="rm-switcher-list">
<li
v-for="(item, i) in items"
:key="item.path"
class="rm-switcher-item"
:class="{ active: i === selected }"
@click="choose(i)"
@mousemove="selected = i"
>
<Icon name="folder" />
<span class="rm-switcher-title">
<template v-if="item.path === ''">根目录</template>
<span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">
没有可移动到的文件夹
</p>
</div>
</div>
</template>
</el-input>
<p v-if="error" role="alert" class="rm-auth-error">
{{ error }}
</p>
<ul v-if="items.length" ref="list" class="rm-switcher-list">
<li
v-for="(item, i) in items"
:key="item.path"
class="rm-switcher-item"
:class="{ active: i === selected }"
@click="choose(i)"
@mousemove="selected = i"
>
<Icon name="folder" />
<span class="rm-switcher-title">
<template v-if="item.path === ''">根目录</template>
<span v-for="(s, j) in segments(item.path, item.hits)" v-else :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">
没有可移动到的文件夹
</p>
</el-dialog>
</template>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { EditorState } from '@codemirror/state'
import Icon from '@@/components/Icon/index.vue'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { availableCommands, commandKeys, formatKeys } from '../../../common/utils/commands'
import { dirname, NOTE_EXT, noteTitle } from '../../../common/utils/paths'
import type { InputInstance } from 'element-plus'
import Icon from '@@/components/Icon/index.vue'
import { availableCommands, commandKeys, formatKeys } from '@@/utils/commands'
import { dirname, NOTE_EXT, noteTitle } from '@@/utils/paths'
import { computed, ref, watch } from 'vue'
import { fuzzyMatch, segments } from '../fuzzy'
import { createNoteAt, openAt, ws } from '../workspace'
@@ -22,7 +23,7 @@ interface Item {
run: () => void
}
const input = ref<HTMLInputElement>()
const input = ref<InputInstance>()
const list = ref<HTMLElement>()
const query = ref(ws.switcher ?? '')
const selected = ref(0)
@@ -148,45 +149,38 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault()
}
onMounted(() => input.value?.focus())
function opened() {
input.value?.focus()
}
</script>
<template>
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
<div class="rm-switcher" role="dialog" :aria-label="commandMode ? '命令面板' : '快速打开'">
<div class="rm-search-box">
<el-dialog :model-value="true" :title="commandMode ? '命令面板' : '快速打开'" width="560px" class="rm-picker-dialog" @close="cancel" @opened="opened">
<el-input ref="input" v-model="query" :placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'" :aria-label="commandMode ? '搜索命令' : '搜索笔记'" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<template #prefix>
<Icon :name="commandMode ? 'command' : 'search'" />
<input
ref="input"
v-model="query"
class="rm-search-input"
:placeholder="commandMode ? '输入命令名称' : '输入笔记名称,回车打开或新建;输入 > 执行命令'"
enterkeyhint="go"
@keydown="onKeydown"
@blur="close"
>
</div>
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
<li
v-for="(item, i) in items"
:key="item.key"
class="rm-switcher-item"
:class="{ active: i === selected }"
@click="choose(i)"
@mousemove="selected = i"
>
<Icon :name="item.icon" />
<span class="rm-switcher-title">
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
<span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }">
<span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
</p>
</div>
</div>
</template>
</el-input>
<ul v-if="items.length" ref="list" class="rm-switcher-list" @mousedown.prevent>
<li
v-for="(item, i) in items"
:key="item.key"
class="rm-switcher-item"
:class="{ active: i === selected }"
@click="choose(i)"
@mousemove="selected = i"
>
<Icon :name="item.icon" />
<span class="rm-switcher-title">
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
<span v-if="item.detail" class="rm-switcher-dir" :class="{ 'rm-switcher-keys': item.icon === 'command' }">
<span v-for="(s, j) in segments(item.detail, item.detailHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
</span>
</li>
</ul>
<p v-else class="rm-tree-empty">
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
</p>
</el-dialog>
</template>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { SearchResult } from '@/pages/workspace/apis/type'
import type { SearchResult } from '@@/apis/search/type'
import { searchApi } from '@@/apis/search'
import Icon from '@@/components/Icon/index.vue'
import { toast } from '@@/utils/notifications'
import { dirname, noteTitle } from '@@/utils/paths'
import { computed, onMounted, ref, watch } from 'vue'
import { workspaceApi } from '@/pages/workspace/apis'
import Icon from '../../../common/components/Icon/index.vue'
import { dirname, noteTitle } from '../../../common/utils/paths'
import { openAt, ws } from '../workspace'
const SEARCH_DELAY = 150
@@ -30,7 +32,7 @@ async function run(q: string) {
return
}
try {
const r = await workspaceApi.search(q)
const r = (await searchApi({ q })).data.results
if (id !== seq) return
results.value = r
selected.value = 0
@@ -1,15 +1,18 @@
<script setup lang="ts">
import type { InlineStyles } from '@@/markdown/inline-styles'
import type { EditorSelection } from '@codemirror/state'
import type { InlineStyles } from '../../../common/markdown/inline-styles'
import type { FormatCommand } from '../editor/format'
import Icon from '@@/components/Icon/index.vue'
import InlineAppearance from '@@/components/InlineAppearance/index.vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import InlineAppearance from '../../../common/components/InlineAppearance/index.vue'
import { settings } from '../../../pinia/stores/settings'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
import { insertLink } from '../editor/format'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from '../editor/inline-styles'
import { activeTab, editorView, onEditorUpdate, ws } from '../workspace'
const { settings } = useSettingsStore(pinia)
const root = ref<HTMLElement>()
const floating = ref<{ left: number, top: number, maxHeight: number, maxWidth: number } | null>(null)
const appearanceOpen = ref(false)
@@ -1,10 +1,10 @@
<script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import PluginMount from '@@/components/PluginMount/index.vue'
import { sidebarViews } from '@@/plugins/registry'
import { commandTitle } from '@@/utils/commands'
import { basename } from '@@/utils/paths'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import PluginMount from '../../../common/components/PluginMount/index.vue'
import { sidebarViews } from '../../../common/plugins/registry'
import { commandTitle } from '../../../common/utils/commands'
import { basename } from '../../../common/utils/paths'
import { dropDirectory, tree, treeIconTarget } from '../tree'
import { createFolder, createNote, move, openSearch, ws } from '../workspace'
import SearchPanel from './SearchPanel.vue'
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Tab } from '../workspace'
import Icon from '@@/components/Icon/index.vue'
import { commandTitle } from '@@/utils/commands'
import { noteTitle } from '@@/utils/paths'
import { nextTick, ref } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { commandTitle } from '../../../common/utils/commands'
import { noteTitle } from '../../../common/utils/paths'
import { closeTab, createNote, ws } from '../workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { TreeIcon } from '@@/apis/vault/type'
import type { TreeIcon } from '@@/apis/tree/type'
import type { IconName } from '@@/utils/icons'
import type { Gemoji } from 'gemoji'
import type { IconName } from '../../../common/utils/icons'
import Icon from '@@/components/Icon/index.vue'
import { iconPaths } from '@@/utils/icons'
import { basename } from '@@/utils/paths'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { iconPaths } from '../../../common/utils/icons'
import { basename } from '../../../common/utils/paths'
import { treeIconTarget } from '../tree'
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from '../tree-icon-catalog'
import { saveTreeIcon } from '../workspace'
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { TreeNode } from '../tree'
import Icon from '@@/components/Icon/index.vue'
import { basename, dirname, inAssetDir, isImage, joinPath } from '@@/utils/paths'
import { computed, nextTick, ref, watch } from 'vue'
import Icon from '../../../common/components/Icon/index.vue'
import { basename, dirname, inAssetDir, isImage, joinPath } from '../../../common/utils/paths'
import { batchFiles, exportFiles } from '../batch'
import { contextSelection, dropDirectory, expanded, selectedPaths, selectPath, treeIconTarget } from '../tree'
import { createFolder, createNote, move, openNote, renameTarget, ws } from '../workspace'
+1 -1
View File
@@ -1,4 +1,4 @@
import { watchFontLoading } from '../../../common/appearance/font-loading'
import { watchFontLoading } from '@@/appearance/font-loading'
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
import { createEditorState, createEditorView } from '../editor'
import sample from './sample.md?raw'
+1 -1
View File
@@ -1,10 +1,10 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { codeRenderers, notePath, pluginRefresh } from '@@/plugins/registry'
import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view'
import { codeRenderers, notePath, pluginRefresh } from '../../../common/plugins/registry'
import { codeContentRange, fenceLanguage } from './codeblocks'
import { editorFocused, setEditorFocus } from './focus'
import { CodeRendererWidget, MathWidget } from './widgets'
+2 -2
View File
@@ -1,11 +1,11 @@
import type { TokenRun } from '@@/highlight/client'
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import type { TokenRun } from '../../../common/highlight/client'
import { cachedTokens, requestTokens } from '@@/highlight/client'
import { syntaxTree } from '@codemirror/language'
import { StateEffect, StateField } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
import { cachedTokens, requestTokens } from '../../../common/highlight/client'
interface CodeBlock {
// 代码内容(不含围栏行)在文档中的范围
+2 -2
View File
@@ -1,13 +1,13 @@
import type { Extension } from '@codemirror/state'
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown'
import { typoraExtensions } from '@@/markdown/extensions'
import { editorExtensions, markdownSyntax, reportEditorFailure } from '@@/plugins/registry'
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { Language } from '@codemirror/language'
import { Compartment, EditorState, Prec } from '@codemirror/state'
import { drawSelection, EditorView, keymap } from '@codemirror/view'
import { typoraExtensions } from '../../../common/markdown/extensions'
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../../../common/plugins/registry'
import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
@@ -1,9 +1,9 @@
import type { TransactionSpec } from '@codemirror/state'
import type { FormatCommand } from './format'
import { renderMarkdown } from '@@/markdown/html'
import { redo, undo } from '@codemirror/commands'
import { EditorSelection, EditorState, Transaction } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../../../common/markdown/html'
import { toggleBold } from './format'
import { createEditorState } from './index'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inline-styles'
@@ -1,10 +1,10 @@
import type { InlineStyles } from '@@/markdown/inline-styles'
import type { EditorState } from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common'
import type { InlineStyles } from '../../../common/markdown/inline-styles'
import type { FormatCommand } from './format'
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
import { ensureSyntaxTree, language } from '@codemirror/language'
import { EditorSelection } from '@codemirror/state'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
interface Wrapper { marks: { from: number, to: number }[] }
interface Segment {
@@ -2,10 +2,10 @@ import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import type { TocEntry } from './widgets'
import { inlineStyleText, stylesFromTag } from '@@/markdown/inline-styles'
import { syntaxTree } from '@codemirror/language'
import { Facet, StateEffect } from '@codemirror/state'
import { Decoration, ViewPlugin } from '@codemirror/view'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
import {
BulletWidget,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Command, KeyBinding } from '@codemirror/view'
import { typoraKeys } from '@@/utils/typora-keys'
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import { typoraKeys } from '../../../common/utils/typora-keys'
import { selectCodeBlock } from './codeblocks'
import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format'
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
+2 -2
View File
@@ -1,12 +1,12 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { renderInlineMarkdown } from '@@/markdown/html'
import { hydrate } from '@@/markdown/render'
import { isolateHistory, redo, undo } from '@codemirror/commands'
import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { renderInlineMarkdown } from '../../../common/markdown/html'
import { hydrate } from '../../../common/markdown/render'
import { editorFocused, setEditorFocus } from './focus'
type Align = '' | 'left' | 'center' | 'right'
+4 -4
View File
@@ -1,8 +1,8 @@
import type { OwnedRenderer } from '../../../common/plugins/registry'
import type { OwnedRenderer } from '@@/plugins/registry'
import { emojiFor, renderMath } from '@@/markdown/render'
import { mountContent } from '@@/plugins/mount'
import { copyText } from '@@/utils/clipboard'
import { EditorView, WidgetType } from '@codemirror/view'
import { emojiFor, renderMath } from '../../../common/markdown/render'
import { mountContent } from '../../../common/plugins/mount'
import { copyText } from '../../../common/utils/clipboard'
export class BulletWidget extends WidgetType {
eq() {
-44
View File
@@ -1,44 +0,0 @@
import { authApi } from '@@/apis/auth'
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { connectEvents } from './events'
afterEach(() => {
vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals()
})
it('首次连接和重连均补同步,停止后不再连接', async () => {
vi.spyOn(authApi, 'ticket').mockResolvedValue({ url: '/api/v1/resources/once/events/', expiresAt: '2030-01-01T00:00:00Z' })
vi.useFakeTimers()
class Socket {
static CLOSED = 3
static instances: Socket[] = []
readyState = 0
onopen?: () => void
onclose?: () => void
close() {
this.readyState = Socket.CLOSED
}
constructor() {
Socket.instances.push(this)
}
}
vi.stubGlobal('WebSocket', Socket)
let synchronized = 0
const stop = connectEvents({ onEvents: () => {}, onResync: () => {
synchronized++
} })
await vi.waitFor(() => expect(Socket.instances).toHaveLength(1))
Socket.instances[0].onopen!()
expect(synchronized).toBe(1)
Socket.instances[0].onclose!()
await vi.advanceTimersByTimeAsync(1000)
Socket.instances[1].onopen!()
expect(synchronized).toBe(2)
stop()
Socket.instances[1].onclose!()
vi.advanceTimersByTime(30000)
expect(Socket.instances).toHaveLength(2)
expect(authApi.ticket).toHaveBeenCalledTimes(2)
})
-65
View File
@@ -1,65 +0,0 @@
import { authApi } from '@@/apis/auth'
// 订阅仓库事件;每次连接领取一次性票据,重连后补全量同步。
import { isCancel } from 'axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
path?: string
from?: string
rev?: string
}
export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void, onResync: () => void }): () => void {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
let socket: WebSocket | undefined
let delay = 1000
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
let connecting = false
function reconnect() {
if (stopped || version !== user.getSessionVersion()) return
timer = setTimeout(() => void connect(), delay)
delay = Math.min(delay * 2, 30000)
}
async function connect() {
if (stopped || connecting) return
connecting = true
try {
const ticket = await authApi.ticket({ kind: 'events' })
user.assertSession(version)
if (stopped) return
const url = new URL(ticket.url, location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(url)
socket = ws
ws.onopen = () => {
delay = 1000
handlers.onResync()
}
ws.onmessage = event => handlers.onEvents(JSON.parse(event.data))
ws.onclose = reconnect
} catch (error) {
if (!isCancel(error)) reconnect()
} finally {
connecting = false
}
}
void connect()
const onVisible = () => {
if (document.visibilityState === 'visible' && (!socket || socket.readyState === WebSocket.CLOSED)) {
clearTimeout(timer)
void connect()
}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
stopped = true
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisible)
socket?.close()
}
}
+3 -1
View File
@@ -6,12 +6,14 @@ import { CanceledError } from 'axios'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import { pinia } from '@/pinia'
import * as preferences from '@/pinia/stores/settings'
import { useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import Sidebar from './components/Sidebar.vue'
import Workspace from './index.vue'
import { flushAll, readyToReload } from './workspace'
const preferences = useSettingsStore(pinia)
vi.mock('./workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('./workspace')>()
return { ...actual, flushAll: vi.fn(), readyToReload: vi.fn(), startWorkspace: vi.fn(), stopWorkspace: vi.fn() }
+9 -6
View File
@@ -1,21 +1,22 @@
<script setup lang="ts">
import AppStatus from '@@/components/AppStatus/index.vue'
import Icon from '@@/components/Icon/index.vue'
import { registerCommand } from '@@/utils/commands'
import { toast } from '@@/utils/notifications'
import { noteTitle } from '@@/utils/paths'
import { isCancel } from 'axios'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ApiError } from '@/http/axios'
import SettingsDialog from '@/pages/settings/index.vue'
import { pinia } from '@/pinia'
import { SettingsAppearanceError, SIDEBAR_WIDTH, useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import Icon from '../../common/components/Icon/index.vue'
import { registerCommand } from '../../common/utils/commands'
import { noteTitle } from '../../common/utils/paths'
import { settings, SettingsAppearanceError, SIDEBAR_WIDTH, updateSettings } from '../../pinia/stores/settings'
import SettingsDialog from '../settings/index.vue'
import EditorChrome from './components/EditorChrome.vue'
import EditorPane from './components/EditorPane.vue'
import ImageDialog from './components/ImageDialog.vue'
import ImageGallery from './components/ImageGallery.vue'
import ImageGallery from './components/ImageGallery.vue'
import ImportDialog from './components/ImportDialog.vue'
import KnowledgePanel from './components/KnowledgePanel.vue'
import MoveDialog from './components/MoveDialog.vue'
@@ -27,6 +28,8 @@ import Sidebar from './components/Sidebar.vue'
import TabBar from './components/TabBar.vue'
import { flushAll, readyToReload, restore, startWorkspace, stopWorkspace, ws } from './workspace'
const { settings, updateSettings } = useSettingsStore(pinia)
async function prepareReload() {
await flushAll(); return readyToReload()
}
+3 -3
View File
@@ -1,14 +1,14 @@
import type { NoteMetadata } from '@/pages/workspace/apis/type'
import type { NoteMetadata } from '@@/apis/metadata/type'
import { getMetadataApi } from '@@/apis/metadata'
import { isCancel } from 'axios'
import { reactive } from 'vue'
import { workspaceApi } from '@/pages/workspace/apis'
export const metadata = reactive({ notes: [] as NoteMetadata[], ready: false, error: '' })
let request = 0
export async function refreshMetadata() {
const current = ++request
try {
const result = await workspaceApi.metadata()
const result = (await getMetadataApi()).data
if (current !== request) return
metadata.notes = result.notes
metadata.ready = result.ready
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IconName } from '@@/utils/icons'
import type { Gemoji } from 'gemoji'
import type { IconName } from '../../common/utils/icons'
export const iconLabels: Record<IconName, string> = {
plus: '加号',
+3 -2
View File
@@ -1,8 +1,9 @@
import type { App } from 'vue'
import * as treeApi from '@@/apis/tree'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { createApp, nextTick } from 'vue'
import { workspaceApi } from '@/pages/workspace/apis'
import Sidebar from './components/Sidebar.vue'
import { expanded, selectedPaths, treeIconTarget } from './tree'
import * as workspace from './workspace'
@@ -134,7 +135,7 @@ describe('文件树图标操作', () => {
it('图标请求沿用认证、禁缓存和 null 重置契约', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"code":0,"data":{},"message":"成功"}'))
await workspaceApi.setTreeIcon('folder/a.md', null)
await treeApi.setTreeIconApi({ path: 'folder/a.md', icon: null })
const request = fetch.mock.calls[0]![0] as Request
expect(request.url).toBe('http://localhost:3000/api/v1/tree-icon')
expect(request.method).toBe('PUT')
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Entry, TreeIcon } from '@@/apis/vault/type'
import type { Entry, TreeIcon } from '@@/apis/tree/type'
import { basename, dirname, hiddenAssetPath } from '@@/utils/paths'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { basename, dirname, hiddenAssetPath } from '../../common/utils/paths'
import { rangePaths } from './selection'
import { ws } from './workspace'
+106 -90
View File
@@ -1,8 +1,14 @@
import type { Draft } from './drafts'
import { pluginsApi } from '@@/apis/plugins'
import { preferencesApi } from '@@/apis/preferences'
import { trashApi } from '@@/apis/trash'
import { vaultApi } from '@@/apis/vault'
import * as assetsApi from '@@/apis/assets'
import { connectEventsApi } from '@@/apis/events'
import * as filesApi from '@@/apis/files'
import * as fontsApi from '@@/apis/fonts'
import * as pluginsApi from '@@/apis/plugins'
import * as settingsApi from '@@/apis/settings'
import * as trashApi from '@@/apis/trash'
import * as treeApi from '@@/apis/tree'
import * as noteFiles from '@@/utils/note-files'
// @vitest-environment happy-dom
import { toasts } from '@@/utils/notifications'
import { EditorView } from '@codemirror/view'
@@ -10,16 +16,16 @@ import { flushPromises } from '@vue/test-utils'
import { CanceledError, isCancel } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/http/axios'
import { workspaceApi } from '@/pages/workspace/apis'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { apiSuccess } from '../../../tests/api-response'
import { pluginEvents } from '../../common/plugins/registry'
import { drafts } from './drafts'
import { insertImageFiles } from './editor/paste'
import { connectEvents } from './events'
import { attachView, closeTab, createNoteAt, editorStateOf, flush, importNotes, keepLocalVersion, move, openNote, readyToReload, refreshTree, remove, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
vi.mock('@@/apis/events', () => ({ connectEventsApi: vi.fn(() => () => {}) }))
let view: EditorView | undefined
beforeEach(() => {
@@ -35,21 +41,21 @@ afterEach(() => {
describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
const tree = vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }] }))
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
const saved = new Map<string, string>()
vi.spyOn(workspaceApi, 'writeFile').mockImplementation(async (path, content) => {
saved.set(path, content); return 'r2'
vi.spyOn(filesApi, 'writeFileApi').mockImplementation(async ({ path, content }) => {
saved.set(path, content); return apiSuccess({ rev: await ('r2') })
})
await startWorkspace()
const v = await edit('from/note.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
tree.mockResolvedValue(apiSuccess({ entries: [{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }] }))
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
expect(drafts.move).toHaveBeenCalledWith('from', 'to')
expect(editorStateOf('from/note.md')).toBeUndefined()
@@ -62,16 +68,16 @@ describe('事件同步', () => {
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(vaultApi, 'tree').mockResolvedValue(entries)
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries }))
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const read = vi.spyOn(vaultApi, 'readFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
const read = vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
await startWorkspace()
await openNote('sync.md')
const handlers = vi.mocked(connectEvents).mock.lastCall![0]
const handlers = vi.mocked(connectEventsApi).mock.lastCall![0]
const observed: string[] = []
let resyncs = 0
const changed = () => observed.push(editorStateOf('sync.md')!.doc.toString())
@@ -94,7 +100,7 @@ describe('事件同步', () => {
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r3' }])
await vi.waitFor(() => expect(toasts.some(toast => toast.text.includes('正文读取失败'))).toBe(true))
expect(observed).toEqual(['新正文'])
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
vi.mocked(treeApi.getTreeApi).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
handlers.onResync()
await vi.waitFor(() => expect(toasts.some(toast => toast.text.includes('目录读取失败'))).toBe(true))
expect(resyncs).toBe(0)
@@ -111,7 +117,7 @@ describe('事件同步', () => {
})
async function edit(path: string) {
vi.spyOn(vaultApi, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(noteFiles, 'readNoteFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
ws.entries.push({ path, dir: false, size: 0, mtime: 0 })
await openNote(path)
@@ -122,7 +128,7 @@ async function edit(path: string) {
describe('笔记打开与创建', () => {
it('从图片库新建或打开已有笔记时显示笔记视图', async () => {
vi.spyOn(workspaceApi, 'writeFile').mockResolvedValue('r1')
vi.spyOn(filesApi, 'writeFileApi').mockResolvedValue(apiSuccess({ rev: 'r1' }))
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
ws.images = ''
ws.view = 'graph'
@@ -141,10 +147,10 @@ describe('笔记打开与创建', () => {
const path = `pending-create-${succeeds}.md`
let finish!: (rev: string) => void
let fail!: (error: Error) => void
vi.spyOn(workspaceApi, 'writeFile').mockImplementation(() => new Promise((resolve, reject) => {
vi.spyOn(filesApi, 'writeFileApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof filesApi.writeFileApi>>['data']['rev']>((resolve, reject) => {
finish = resolve; fail = reject
}))
vi.spyOn(vaultApi, 'readFile').mockRejectedValue(new ApiError(404, '文件不存在'))
})).then(value => apiSuccess({ rev: value })))
vi.spyOn(noteFiles, 'readNoteFile').mockRejectedValue(new ApiError(404, '文件不存在'))
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const creating = createNoteAt(path, '创建内容')
const opening = openNote(path)
@@ -167,7 +173,7 @@ describe('笔记打开与创建', () => {
describe('保存与关闭', () => {
it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => {
const v = await edit('conflict.md')
vi.spyOn(workspaceApi, 'writeFile').mockRejectedValue(new ApiError(412, '版本冲突', { rev: 'r2' }))
vi.spyOn(filesApi, 'writeFileApi').mockRejectedValue(new ApiError(412, '版本冲突', { rev: 'r2' }))
const saved = new Map<string, Draft>()
const put = vi.spyOn(drafts, 'put').mockImplementation(async (draft) => {
saved.set(draft.path, draft); return draft.path
@@ -188,13 +194,13 @@ describe('保存与关闭', () => {
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
const contents: string[] = []
let finish!: (rev: string) => void
vi.spyOn(workspaceApi, 'writeFile').mockImplementation(async (_path, content) => {
vi.spyOn(filesApi, 'writeFileApi').mockImplementation(async ({ path: _path, content }) => {
contents.push(content)
return contents.length === 1
return apiSuccess({ rev: await (contents.length === 1
? new Promise<string>((resolve) => {
finish = resolve
})
: 'r3'
: 'r3') })
})
const tab = ws.tabs.find(tab => tab.path === 'race.md')!
Object.assign(tab, { status: 'conflict', conflictRev: 'r2' })
@@ -213,13 +219,13 @@ describe('保存与关闭', () => {
const v = await edit('网页.md')
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
const contents = new Map<string, string>()
vi.spyOn(workspaceApi, 'writeFile').mockImplementation(async (path, content) => {
contents.set(path, content); return 'r2'
vi.spyOn(filesApi, 'writeFileApi').mockImplementation(async ({ path, content }) => {
contents.set(path, content); return apiSuccess({ rev: await ('r2') })
})
let finish!: (link: string) => void
vi.spyOn(workspaceApi, 'importImage').mockImplementation(() => new Promise<string>((resolve) => {
vi.spyOn(assetsApi, 'importImageApi').mockImplementation(() => (new Promise<string>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess({ link: value })))
const data = new DataTransfer()
data.setData('text/plain', '网页标题')
data.setData('text/html', '<h1>网页标题</h1><img src="https://example.com/a.png">')
@@ -243,13 +249,13 @@ describe('保存与关闭', () => {
const v = await edit('image.md')
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
const contents = new Map<string, string>()
vi.spyOn(workspaceApi, 'writeFile').mockImplementation(async (path, content) => {
contents.set(path, content); return 'r2'
vi.spyOn(filesApi, 'writeFileApi').mockImplementation(async ({ path, content }) => {
contents.set(path, content); return apiSuccess({ rev: await ('r2') })
})
let finish!: (link: string) => void
vi.spyOn(workspaceApi, 'upload').mockImplementation(() => new Promise<string>((resolve) => {
vi.spyOn(assetsApi, 'uploadImageApi').mockImplementation(() => (new Promise<string>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess({ link: value })))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
@@ -267,14 +273,14 @@ describe('保存与关闭', () => {
it('图片上传期间不能移动笔记或其父目录', async () => {
const v = await edit('upload/note.md')
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }] }))
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
vi.spyOn(workspaceApi, 'writeFile').mockResolvedValue('r2')
const moving = vi.spyOn(workspaceApi, 'move').mockResolvedValue(new Response())
vi.spyOn(filesApi, 'writeFileApi').mockResolvedValue(apiSuccess({ rev: 'r2' }))
const moving = vi.spyOn(filesApi, 'moveFileApi').mockResolvedValue(apiSuccess(new Response()))
let finish!: (link: string) => void
vi.spyOn(workspaceApi, 'upload').mockImplementation(() => new Promise((resolve) => {
vi.spyOn(assetsApi, 'uploadImageApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof assetsApi.uploadImageApi>>['data']['link']>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess({ link: value })))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pending-move')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
@@ -292,12 +298,12 @@ describe('保存与关闭', () => {
const v = await edit('moving.md')
v.dispatch({ selection: { anchor: 0, head: v.state.doc.length } })
let finish!: (response: Response) => void
vi.spyOn(workspaceApi, 'move').mockImplementation(() => new Promise((resolve) => {
vi.spyOn(filesApi, 'moveFileApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof filesApi.moveFileApi>>['data']>((resolve) => {
finish = resolve
}))
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'moved.md', dir: false, size: 0, mtime: 0 }])
const upload = vi.spyOn(workspaceApi, 'upload')
const importing = vi.spyOn(workspaceApi, 'importImage')
})).then(value => apiSuccess(value)))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'moved.md', dir: false, size: 0, mtime: 0 }] }))
const upload = vi.spyOn(assetsApi, 'uploadImageApi')
const importing = vi.spyOn(assetsApi, 'importImageApi')
const moving = move('moving.md', 'moved.md')
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])).toBe(true)
@@ -318,8 +324,8 @@ describe('保存与关闭', () => {
it('草稿迁移失败时不移动文件,服务端失败时恢复草稿路径', async () => {
await edit('draft-move.md')
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }])
const moving = vi.spyOn(workspaceApi, 'move').mockRejectedValue(new ApiError(500, '移动失败'))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }] }))
const moving = vi.spyOn(filesApi, 'moveFileApi').mockRejectedValue(new ApiError(500, '移动失败'))
vi.mocked(drafts.move).mockRejectedValueOnce(new Error('草稿不可写'))
await move('draft-move.md', 'draft-moved.md')
expect(moving).not.toHaveBeenCalled()
@@ -337,25 +343,25 @@ describe('导入', () => {
ws.tabs = []
const result = { paths: ['导入'], notes: 1, assets: 0, warnings: [] }
let finish!: (value: typeof result) => void
vi.spyOn(workspaceApi, 'importNotes').mockImplementation(() => new Promise((resolve) => {
vi.spyOn(filesApi, 'importNotesApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof filesApi.importNotesApi>>['data']>((resolve) => {
finish = resolve
}))
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: '导入', dir: true, size: 0, mtime: 0 }])
})).then(value => apiSuccess(value)))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: '导入', dir: true, size: 0, mtime: 0 }] }))
const importing = importNotes('', '导入', [new File(['正文'], 'test.md')])
expect(readyToReload()).toBe(false)
finish(result)
expect(await importing).toEqual(result)
expect(ws.entries.some(e => e.path === '导入')).toBe(true)
expect(readyToReload()).toBe(true)
vi.mocked(workspaceApi.importNotes).mockResolvedValueOnce(result)
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
vi.mocked(filesApi.importNotesApi).mockResolvedValueOnce(apiSuccess(result))
vi.mocked(treeApi.getTreeApi).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
expect(await importNotes('', '导入', [])).toEqual(result)
expect(toasts.some(t => t.text.includes('导入已完成,目录刷新失败'))).toBe(true)
})
it('失败保留错误并释放操作占用', async () => {
ws.tabs = []
vi.spyOn(workspaceApi, 'importNotes').mockRejectedValue(new ApiError(409, '目标已存在'))
vi.spyOn(filesApi, 'importNotesApi').mockRejectedValue(new ApiError(409, '目标已存在'))
await expect(importNotes('', '导入', [])).rejects.toMatchObject({ status: 409, message: '目标已存在' })
expect(readyToReload()).toBe(true)
})
@@ -365,18 +371,18 @@ describe('图片管理', () => {
it('替换期间阻止刷新,完成后更新列表;刷新失败不伪装成替换失败', async () => {
ws.tabs = []
let finish!: (value: Response) => void
vi.spyOn(workspaceApi, 'replaceImage').mockImplementation(() => new Promise((resolve) => {
vi.spyOn(assetsApi, 'replaceImageApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof assetsApi.replaceImageApi>>['data']>((resolve) => {
finish = resolve
}))
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
})).then(value => apiSuccess(value)))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }] }))
const replacing = replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(readyToReload()).toBe(false)
finish(new Response())
await replacing
expect(ws.entries.find(e => e.path === 'assets/a.png')?.mtime).toBe(2)
expect(readyToReload()).toBe(true)
vi.mocked(workspaceApi.replaceImage).mockResolvedValueOnce(new Response())
vi.mocked(vaultApi.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
vi.mocked(assetsApi.replaceImageApi).mockResolvedValueOnce(apiSuccess(new Response()))
vi.mocked(treeApi.getTreeApi).mockRejectedValueOnce(new ApiError(500, '读取失败'))
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(toasts.some(t => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
})
@@ -384,8 +390,8 @@ describe('图片管理', () => {
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
ws.tabs = []
ws.active = null
vi.spyOn(trashApi, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
vi.spyOn(trashApi, 'restoreTrashApi').mockResolvedValue(apiSuccess({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 }))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }] }))
await restore('trash-image')
expect(ws.image).toBe('assets/a.png')
expect(ws.tabs).toEqual([])
@@ -398,21 +404,21 @@ describe('文件操作的保存前提', () => {
const path = `保存失败-${action}.md`
const v = await edit(path)
vi.spyOn(drafts, 'put').mockImplementation(async draft => draft.path)
const saving = vi.spyOn(workspaceApi, 'writeFile').mockRejectedValue(new ApiError(403, '禁止写入'))
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path, dir: false, size: 0, mtime: 0 }])
const saving = vi.spyOn(filesApi, 'writeFileApi').mockRejectedValue(new ApiError(403, '禁止写入'))
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path, dir: false, size: 0, mtime: 0 }] }))
v.dispatch({ changes: { from: 0, insert: '未保存' } })
const result = action === 'move' ? await move(path, '目标.md') : await remove(path)
expect(result.ok).toBe(false)
expect(ws.active).toBe(path)
expect(editorStateOf(path)?.doc.toString()).toBe('未保存原文')
saving.mockResolvedValue('r2')
saving.mockResolvedValue(apiSuccess({ rev: 'r2' }))
await flush(path)
await refreshTree()
await closeTab(path)
})
})
vi.mock('@@/apis/auth/resources', () => ({
vi.mock('@@/utils/resource-tickets', () => ({
prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
@@ -427,15 +433,15 @@ it('失效会话的保存结果不覆盖重新登录后的标签页,也不安
const v = await edit('session.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '旧会话输入' } })
let finish!: (rev: string) => void
const write = vi.spyOn(workspaceApi, 'writeFile').mockImplementation(() => new Promise((resolve) => {
const write = vi.spyOn(filesApi, 'writeFileApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof filesApi.writeFileApi>>['data']['rev']>((resolve) => {
finish = resolve
}))
})).then(value => apiSuccess({ rev: value })))
const saved = flush('session.md')
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new')
stopWorkspace()
view?.destroy(); view = undefined
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
vi.mocked(noteFiles.readNoteFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
await openNote('session.md')
finish('old-rev')
await saved
@@ -449,9 +455,9 @@ it('失效会话的保存结果不覆盖重新登录后的标签页,也不安
it('切换会话后新目录读取不等待已取消的旧目录请求', async () => {
const user = useUserStore(pinia)
let finish!: (entries: []) => void
const tree = vi.spyOn(vaultApi, 'tree').mockImplementationOnce(() => new Promise((resolve) => {
const tree = vi.spyOn(treeApi, 'getTreeApi').mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof treeApi.getTreeApi>>['data']['entries']>((resolve) => {
finish = resolve
})).mockResolvedValue([{ path: 'new.md', dir: false, size: 0, mtime: 0 }])
})).then(value => apiSuccess({ entries: value }))).mockResolvedValue(apiSuccess({ entries: [{ path: 'new.md', dir: false, size: 0, mtime: 0 }] }))
const previous = refreshTree().catch(error => error)
await vi.waitFor(() => expect(tree).toHaveBeenCalledTimes(1))
user.setToken('rms_new')
@@ -471,13 +477,13 @@ it('切换会话后新目录读取不等待已取消的旧目录请求', async (
it('新工作区不继承旧会话未完成的文件操作计数', async () => {
const user = useUserStore(pinia)
let cancel!: (error: Error) => void
vi.spyOn(workspaceApi, 'writeFile').mockImplementationOnce(() => new Promise((_resolve, reject) => {
vi.spyOn(filesApi, 'writeFileApi').mockImplementationOnce(() => (new Promise<Awaited<ReturnType<typeof filesApi.writeFileApi>>['data']['rev']>((_resolve, reject) => {
cancel = reject
}))
})).then(value => apiSuccess({ rev: value })))
const creating = createNoteAt('old.md')
user.setToken('rms_new')
stopWorkspace()
const tree = vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'new.md', dir: false, size: 0, mtime: 0 }])
const tree = vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'new.md', dir: false, size: 0, mtime: 0 }] }))
const current = refreshTree().catch(error => error)
try {
await vi.waitFor(() => expect(tree).toHaveBeenCalledTimes(1))
@@ -491,10 +497,10 @@ it('新工作区不继承旧会话未完成的文件操作计数', async () => {
it('外部移动事件等待草稿后换会话,不再映射新工作区的同名笔记', async () => {
const user = useUserStore(pinia)
vi.spyOn(vaultApi, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }] }))
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
vi.spyOn(drafts, 'all').mockResolvedValue([])
await startWorkspace()
await edit('from/note.md')
@@ -506,12 +512,12 @@ it('外部移动事件等待草稿后换会话,不再映射新工作区的同
})
completed = true
})
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }])
vi.mocked(connectEventsApi).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
user.setToken('rms_new')
stopWorkspace()
view?.destroy(); view = undefined
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
vi.mocked(noteFiles.readNoteFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
await openNote('from/note.md')
finish()
await vi.waitFor(() => expect(completed).toBe(true))
@@ -525,9 +531,9 @@ it('旧网页粘贴在换会话后不继续下载下一批图片或修改同名
const user = useUserStore(pinia)
const v = await edit('paste-session.md')
const finish: ((link: string) => void)[] = []
const importing = vi.spyOn(workspaceApi, 'importImage').mockImplementation(() => new Promise((resolve) => {
const importing = vi.spyOn(assetsApi, 'importImageApi').mockImplementation(() => (new Promise<Awaited<ReturnType<typeof assetsApi.importImageApi>>['data']['link']>((resolve) => {
finish.push(resolve)
}))
})).then(value => apiSuccess({ link: value })))
const data = new DataTransfer()
data.setData('text/html', Array.from({ length: 4 }, (_, i) => `<img src="https://example.com/${i}.png">`).join(''))
data.setData('text/plain', '剪贴板内容')
@@ -536,7 +542,7 @@ it('旧网页粘贴在换会话后不继续下载下一批图片或修改同名
user.setToken('rms_new')
stopWorkspace()
view?.destroy(); view = undefined
vi.mocked(vaultApi.readFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
vi.mocked(noteFiles.readNoteFile).mockResolvedValue({ content: '新会话正文', rev: 'new-rev' })
await openNote('paste-session.md')
toasts.splice(0)
finish.forEach(resolve => resolve('./assets/old.png'))
@@ -549,15 +555,25 @@ it('旧网页粘贴在换会话后不继续下载下一批图片或修改同名
})
it('工作区关闭后不响应其全局新建快捷键', async () => {
vi.spyOn(vaultApi, 'tree').mockResolvedValue([])
vi.spyOn(preferencesApi, 'settings').mockResolvedValue({})
vi.spyOn(preferencesApi, 'fonts').mockResolvedValue([])
vi.spyOn(pluginsApi, 'plugins').mockResolvedValue([])
vi.spyOn(treeApi, 'getTreeApi').mockResolvedValue(apiSuccess({ entries: [] }))
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
vi.spyOn(drafts, 'all').mockResolvedValue([])
const write = vi.spyOn(workspaceApi, 'writeFile').mockResolvedValue('unused')
const write = vi.spyOn(filesApi, 'writeFileApi').mockResolvedValue(apiSuccess({ rev: 'unused' }))
await startWorkspace()
stopWorkspace()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', code: 'KeyN', ctrlKey: true, bubbles: true, cancelable: true }))
expect(write).not.toHaveBeenCalled()
expect(ws.tabs).toEqual([])
})
it('弹窗接管移动错误时只返回失败,由调用方展示;目录操作仍保留默认提示', async () => {
toasts.splice(0)
ws.entries = [{ path: 'already.md', dir: false, size: 0, mtime: 0 }]
const result = await move('source.md', 'already.md', 'caller')
expect(result).toEqual({ ok: false, error: '“already.md”已存在' })
expect(toasts).toHaveLength(0)
await move('source.md', 'already.md')
expect(toasts.map(item => item.text)).toEqual(['“already.md”已存在'])
})
+63 -57
View File
@@ -1,37 +1,43 @@
import type { Entry, TreeIcon } from '@@/apis/vault/type'
import type { VaultEvent } from '@@/apis/events/type'
import type { Entry, TreeIcon } from '@@/apis/tree/type'
import type { PluginAPI } from '@@/plugin-api'
import type { VaultEvent } from './events'
import type { SettingsSection } from '@/pinia/stores/settings'
import { imageResourceRoot, prepareResources } from '@@/apis/auth/resources'
import { trashApi } from '@@/apis/trash'
import { vaultApi } from '@@/apis/vault'
import { cleanupImagesApi, importImageApi, replaceImageApi, uploadFolderImageApi, uploadImageApi } from '@@/apis/assets'
import { connectEventsApi } from '@@/apis/events'
import { createFolderApi, deleteFileApi, getRawFileApi, importNotesApi, moveFileApi, writeFileApi } from '@@/apis/files'
import { restoreTrashApi } from '@@/apis/trash'
import { getTreeApi, setTreeIconApi } from '@@/apis/tree'
import { appearancePaused } from '@@/appearance/appearance'
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { decodeLinkDestination } from '@@/markdown/links'
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '@@/plugins/host'
import { notePath, pluginEvents, pluginRefresh, pluginsPaused, setPluginEditorHandlers } from '@@/plugins/registry'
import { handleShortcut, registerCommand } from '@@/utils/commands'
import { readNoteFile } from '@@/utils/note-files'
import { toast } from '@@/utils/notifications'
import { basename, dirname, encodePath, isImage, joinPath, NOTE_EXT, resolveAssetUrl } from '@@/utils/paths'
import { imageResourceRoot, prepareResources } from '@@/utils/resource-tickets'
import { typoraKeys } from '@@/utils/typora-keys'
import { trackVisualViewport } from '@@/utils/viewport'
import { Annotation, EditorState, Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { CanceledError, isCancel } from 'axios'
import { nextTick, reactive, watch } from 'vue'
import { ApiError, encodePath, requestRaw } from '@/http/axios'
import { workspaceApi } from '@/pages/workspace/apis'
import { ApiError } from '@/http/axios'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
import { useUserStore } from '@/pinia/stores/user'
import { appearancePaused } from '../../common/appearance/appearance'
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { decodeLinkDestination } from '../../common/markdown/links'
import { pluginEditorFailure, refreshPlugins, startPlugins, stopPlugins } from '../../common/plugins/host'
import { notePath, pluginEvents, pluginRefresh, pluginsPaused, setPluginEditorHandlers } from '../../common/plugins/registry'
import { handleShortcut, registerCommand } from '../../common/utils/commands'
import { basename, dirname, isImage, joinPath, NOTE_EXT, resolveAssetUrl } from '../../common/utils/paths'
import { typoraKeys } from '../../common/utils/typora-keys'
import { trackVisualViewport } from '../../common/utils/viewport'
import { loadSettings, settings } from '../../pinia/stores/settings'
import { drafts } from './drafts'
import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor'
import { refreshImages } from './editor/livepreview'
import { editorCommands } from './editor/shortcuts'
import { connectEvents } from './events'
import { refreshMetadata } from './metadata'
const { loadSettings, settings } = useSettingsStore(pinia)
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
export interface Tab {
@@ -227,11 +233,11 @@ function buildState(path: string, doc: string): EditorState {
},
upload: (file) => {
if (!isCurrentNote()) throw new CanceledError('粘贴所属笔记已关闭')
return workspaceApi.upload(ref.path, file)
return uploadImageApi({ note: ref.path, file }).then(({ data }) => data.link)
},
importImage: (url) => {
if (!isCurrentNote()) throw new CanceledError('粘贴所属笔记已关闭')
return workspaceApi.importImage(ref.path, url)
return importImageApi({ note: ref.path, url }).then(({ data }) => data.link)
},
update: (change) => {
if (!isCurrentNote()) return
@@ -281,7 +287,7 @@ const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options)
if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, '写入必须指定非空版本号或明确新建')
const initialRevision = tab?.rev
const initialSequence = saved?.seq ?? 0
const rev = await workspaceApi.writeFile(path, content, 'create' in options ? '' : options.ifMatch)
const rev = (await writeFileApi({ path, content, ifMatch: 'create' in options ? '' : options.ifMatch })).data.rev
// 用户可能已完成下一次保存,或已收到更新版本;迟到响应不能退回正文与 rev。
if (tab && tabOf(path) === tab && tab.rev === initialRevision && (slots.get(path)?.seq ?? 0) === initialSequence && !tab.dirty && tab.status === 'ready' && !slots.get(path)?.inflight) {
replaceDoc(path, content); tab.rev = rev
@@ -358,7 +364,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
// 草稿只是兜底,写入失败不影响保存本身
const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch(err => console.warn('写入本地草稿失败', err))
try {
const rev = await workspaceApi.writeFile(path, content, baseRev, keepalive)
const rev = (await writeFileApi({ path, content, ifMatch: baseRev, keepalive })).data.rev
await draftSaved
user.assertSession(version)
tab.rev = rev
@@ -412,7 +418,7 @@ export async function useServerVersion(path: string) {
const tab = tabOf(path)
if (!tab) return
try {
const { content, rev } = await vaultApi.readFile(path)
const { content, rev } = await readNoteFile(path)
replaceDoc(path, content)
const s = slot(path)
s.savedSeq = s.seq
@@ -438,7 +444,7 @@ export async function keepLocalVersion(path: string) {
const seq = s.seq
s.inflight = (async () => {
try {
const rev = await workspaceApi.writeFile(path, content, tab.conflictRev ?? '')
const rev = (await writeFileApi({ path, content, ifMatch: tab.conflictRev ?? '' })).data.rev
s.savedSeq = seq
Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined })
if (s.seq === seq) await drafts.delete(path)
@@ -524,7 +530,7 @@ export async function openNote(path: string) {
ws.tabs.push(tab)
ws.active = path
try {
const { content, rev } = await vaultApi.readFile(path)
const { content, rev } = await readNoteFile(path)
editorStates.set(path, buildState(path, content))
const t = tabOf(path)
if (t) Object.assign(t, { rev, status: 'ready' })
@@ -651,7 +657,7 @@ async function fetchTree() {
const edits = localTreeEdits
let entries: Entry[]
try {
entries = await vaultApi.tree()
entries = (await getTreeApi()).data.entries
user.assertSession(version)
} catch (err) {
if (isCancel(err)) return
@@ -707,7 +713,7 @@ async function withOp<T>(fn: () => Promise<T>): Promise<T> {
}
export async function saveTreeIcon(path: string, icon: TreeIcon | null) {
await withOp(() => workspaceApi.setTreeIcon(path, icon))
await withOp(() => setTreeIconApi({ path, icon }).then(({ data }) => data))
try {
await refreshTree()
} catch (err) {
@@ -745,7 +751,7 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() })))
return withOp(async () => {
try {
const rev = await workspaceApi.writeFile(path, content, '')
const rev = (await writeFileApi({ path, content, ifMatch: '' })).data.rev
ws.tabs.push({ path, rev, status: 'ready', dirty: false })
editorStates.set(path, buildState(path, content))
ws.active = path
@@ -762,7 +768,7 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
export async function importNotes(dir: string, folder: string, files: File[]) {
// refreshTree 必须等 withOp 结束,否则会等待自身释放操作计数。
const result = await withOp(() => workspaceApi.importNotes(dir, folder, files))
const result = await withOp(() => importNotesApi({ dir, folder, files }).then(({ data }) => data))
ws.searchOpen = false
try {
await refreshTree()
@@ -779,7 +785,7 @@ export async function createFolder(dir: string) {
ws.renaming = path
await withOp(async () => {
try {
await workspaceApi.mkdir(path)
await createFolderApi({ path })
} catch (err) {
if (isCancel(err)) return
ws.entries = ws.entries.filter(e => e.path !== path)
@@ -798,37 +804,37 @@ export function renameTarget(path: string, name: string): string | null {
}
export type FileResult = { ok: true } | { ok: false, error: string }
function operationFailed(error: string): FileResult {
toast(error)
function operationFailed(error: string, owner: 'operation' | 'caller'): FileResult {
if (owner === 'operation') toast(error)
return { ok: false, error }
}
export async function move(from: string, to: string): Promise<FileResult> {
export async function move(from: string, to: string, errorOwner: 'operation' | 'caller' = 'operation'): Promise<FileResult> {
const version = user.getSessionVersion()
if (from === to) return { ok: true }
if (creating.has(from) && !(await creating.get(from))) return operationFailed('笔记尚未创建成功')
if (creating.has(from) && !(await creating.get(from))) return operationFailed('笔记尚未创建成功', errorOwner)
user.assertSession(version)
if (ws.entries.some(e => e.path === to)) {
return operationFailed(`“${basename(to)}”已存在`)
return operationFailed(`“${basename(to)}”已存在`, errorOwner)
}
const result = await withOp<FileResult>(async () => {
// 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。
const impacted = () => ws.tabs.map(t => t.path)
const busy = () => impacted().some(p => slots.get(p)?.uploads || slots.get(p)?.paused)
if (busy()) {
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动', errorOwner)
}
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(impacted().map(p => flush(p)))
user.assertSession(version)
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
if (busy()) {
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动', errorOwner)
}
if (impacted().some((p) => {
const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready')
})) {
return operationFailed('请先保存笔记并解决冲突,再移动文件')
return operationFailed('请先保存笔记并解决冲突,再移动文件', errorOwner)
}
const paused = impacted().map(slot)
paused.forEach(s => s.paused++)
@@ -846,7 +852,7 @@ export async function move(from: string, to: string): Promise<FileResult> {
remapRecent(from, to)
if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image
if (ws.images) ws.images = remapPath(ws.images, from, to) ?? ws.images
await workspaceApi.move(from, to)
await moveFileApi({ from, to })
} catch (err) {
if (isCancel(err)) throw err
if (remapped) {
@@ -863,7 +869,7 @@ export async function move(from: string, to: string): Promise<FileResult> {
toast(`恢复原路径草稿失败:${cause instanceof Error ? cause.message : errorText(cause)}`)
}
}
return operationFailed(`移动失败:${err instanceof Error ? err.message : errorText(err)}`)
return operationFailed(`移动失败:${err instanceof Error ? err.message : errorText(err)}`, errorOwner)
} finally {
paused.forEach(s => s.paused--)
}
@@ -873,7 +879,7 @@ export async function move(from: string, to: string): Promise<FileResult> {
if (!tab) return
if (tab.dirty) {
// 移动期间又有输入:本地内容仍含旧链接,交给用户选择
const { rev } = await vaultApi.readFile(p)
const { rev } = await readNoteFile(p)
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
} else {
await syncOpenTab(tab)
@@ -890,10 +896,10 @@ export async function move(from: string, to: string): Promise<FileResult> {
return result
}
export async function remove(path: string): Promise<FileResult> {
export async function remove(path: string, errorOwner: 'operation' | 'caller' = 'operation'): Promise<FileResult> {
const version = user.getSessionVersion()
const entry = ws.entries.find(e => e.path === path)
if (!entry) return operationFailed('文件已不存在')
if (!entry) return operationFailed('文件已不存在', errorOwner)
return withOp<FileResult>(async () => {
const paths = affected(path)
await Promise.all(paths.map(p => flush(p)))
@@ -901,18 +907,18 @@ export async function remove(path: string): Promise<FileResult> {
if (paths.some((p) => {
const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready')
})) {
return operationFailed('请先保存笔记并解决冲突,再删除文件')
return operationFailed('请先保存笔记并解决冲突,再删除文件', errorOwner)
}
const snapshot = ws.entries
ws.entries = ws.entries.filter(e => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
for (const p of paths) {
if (!(await closeTab(p))) {
ws.entries = snapshot; return operationFailed('关闭笔记失败,未删除文件')
ws.entries = snapshot; return operationFailed('关闭笔记失败,未删除文件', errorOwner)
}
}
user.assertSession(version)
try {
const item = await workspaceApi.deleteFile(path)
const item = (await deleteFileApi(path)).data
if (ws.image && remapPath(ws.image, path, path) !== null) ws.image = null
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销',
@@ -922,14 +928,14 @@ export async function remove(path: string): Promise<FileResult> {
} catch (err) {
if (isCancel(err)) throw err
ws.entries = snapshot
return operationFailed(`删除失败:${errorText(err)}`)
return operationFailed(`删除失败:${errorText(err)}`, errorOwner)
}
})
}
export async function restore(id: string) {
try {
const item = await trashApi.restore(id)
const item = (await restoreTrashApi(id)).data
await refreshTree()
if (!item.dir && item.path.endsWith(NOTE_EXT)) void openNote(item.path)
else if (!item.dir && isImage(item.path)) ws.image = item.path
@@ -946,7 +952,7 @@ export function imageUrl(path: string): string {
}
export async function replaceImage(path: string, file: File) {
await withOp(() => workspaceApi.replaceImage(path, file))
await withOp(() => replaceImageApi({ path, file }).then(({ data }) => data))
try {
await refreshTree()
} catch (err) {
@@ -961,7 +967,7 @@ async function syncOpenTab(tab: Tab) {
const s = slots.get(tab.path)
if (tab.status !== 'ready' || tab.dirty || s?.inflight) return
try {
const { content, rev } = await vaultApi.readFile(tab.path)
const { content, rev } = await readNoteFile(tab.path)
if (rev === tab.rev || tab.dirty) return
replaceDoc(tab.path, content)
tab.rev = rev
@@ -1056,7 +1062,7 @@ async function recoverDrafts() {
for (const d of await drafts.all()) {
user.assertSession(version)
try {
const { content, rev } = await vaultApi.readFile(d.path)
const { content, rev } = await readNoteFile(d.path)
if (content === d.content) {
await drafts.delete(d.path)
continue
@@ -1120,7 +1126,7 @@ export async function startWorkspace() {
if (!isCancel(err)) toast(`同步失败:${errorText(err)}`)
})
}
const stopEvents = connectEvents({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
const stopEvents = connectEventsApi({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
disconnect = () => {
stopped = true; stopEvents()
}
@@ -1336,8 +1342,8 @@ async function openMarkdownLink(note: string, href: string) {
toast(`链接目标不存在:${target}`); return
}
if (!target.endsWith('.md')) {
const response = await requestRaw('GET', `/raw/${encodePath(target)}`, { errorOwner: 'caller' })
const url = URL.createObjectURL(await response.blob())
const response = await getRawFileApi(target)
const url = URL.createObjectURL(response.data)
const link = Object.assign(document.createElement('a'), { href: url, download: basename(target) }); link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
return
}
@@ -1377,7 +1383,7 @@ export async function uploadFolderImages(folder: string, files: File[]) {
for (const file of files) {
try {
user.assertSession(version)
await workspaceApi.uploadFolderImage(folder, file); uploaded++
await uploadFolderImageApi({ folder, file }); uploaded++
} catch (error) {
if (isCancel(error)) throw error
failures.push(`${file.name}:${errorText(error)}`)
@@ -1393,7 +1399,7 @@ export async function cleanupImages(paths: string[]) {
await flushAll()
user.assertSession(version)
if (!readyToReload()) throw new Error('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再清理')
const result = await withOp(() => workspaceApi.cleanupImages(paths))
const result = await withOp(() => cleanupImagesApi({ paths }).then(({ data }) => data))
await refreshTree()
await refreshMetadata()
return result