feat(i18n): add English and Simplified Chinese

This commit is contained in:
eezd committed 2026-10-26 22:04:33 +08:00
1 parent 38c4c8b781
commit 1e9e0678ee
149 files changed
+5356 -1144

No files matched your search

+5 -2
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import Toasts from '@@/components/Toasts/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import en from 'element-plus/es/locale/lang/en'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useUserStore } from '@/pinia/stores/user'
import { navigationError, router } from '@/router'
@@ -12,10 +15,10 @@ function retry() {
</script>
<template>
<el-config-provider :locale="zhCn">
<el-config-provider :locale="locale === 'zh-CN' ? zhCn : en">
<div v-if="navigationError" class="rm-splash" role="alert">
<p>{{ navigationError.message }}</p><el-button @click="retry">
重试
{{ translate('common.retry_b8784c') }}
</el-button>
</div>
<router-view v-else v-slot="{ Component, route }">
@@ -81,8 +81,8 @@ it('字体失败显示可刷新提示,之后 ready 完成和其他字体加载
await Promise.resolve()
expect(document.documentElement.dataset.rmFonts).toBe('error')
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(document.querySelector('[role="alert"]')!.textContent).toContain('字体加载失败')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('刷新页面')
expect(document.querySelector('[role="alert"]')!.textContent).toContain('Fonts could not be loaded')
expect(document.querySelector('[role="alert"] button')!.textContent).toBe('Refresh page')
fonts.load()
expect(document.documentElement.dataset.rmFonts).toBe('error')
})
+3 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 使用浏览器实际排版触发的字体集合,覆盖首屏、切换笔记与后来出现的字形。
export function watchFontLoading() {
const root = document.documentElement
@@ -11,9 +12,9 @@ export function watchFontLoading() {
panel.className = 'rm-font-error'
panel.setAttribute('role', 'alert')
const message = document.createElement('p')
message.textContent = '字体加载失败,请检查网络后刷新页面。'
message.textContent = translate('common.fonts_could_not_be_loaded_42a574')
const retry = document.createElement('button')
retry.textContent = '刷新页面'
retry.textContent = translate('common.refresh_page_9366e1')
retry.addEventListener('click', () => location.reload())
panel.append(message, retry)
document.body.append(panel)
+1 -1
View File
@@ -5,7 +5,7 @@ import { applyFontCatalog, parseFontCatalog } from './fonts'
describe('导入字体资源边界', () => {
it('拒绝可插入CSS的标识和不支持的格式', () => {
for (const value of [null, {}, [{}], [{ id: 'custom-\');}body{color:red}', name: 'bad', format: 'woff2' }], [{ id: `custom-${'a'.repeat(64)}`, name: 'bad', format: 'exe' }]]) {
expect(() => parseFontCatalog(value)).toThrow('字体列表格式无效')
expect(() => parseFontCatalog(value)).toThrow('Invalid font catalog')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
+2 -1
View File
@@ -1,5 +1,6 @@
import type { CustomFont } from '@@/apis/fonts/type'
import { getFontsApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { fontResourceRoot, prepareResources } from '@@/utils/resource-tickets'
import { shallowRef } from 'vue'
@@ -18,7 +19,7 @@ export function applyFontCatalog(fonts: CustomFont[]) {
}
export function parseFontCatalog(value: unknown): CustomFont[] {
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error('字体列表格式无效')
if (!Array.isArray(value) || value.some(font => !font || typeof font !== 'object' || !/^custom-[a-f0-9]{64}$/.test(font.id) || typeof font.name !== 'string' || !['ttf', 'otf', 'woff', 'woff2'].includes(font.format))) throw new Error(translate('common.invalid_font_catalog_b577df'))
return value
}
+13 -4
View File
@@ -1,15 +1,24 @@
import { t as translate } from '@@/i18n/runtime'
// 编辑器排版设置;账户、快捷键等设置不进入此模块。
const maple = { label: 'Maple Mono Normal NL NF CN', css: '\'Maple Mono Normal NL NF CN\', monospace' }
export const TEXT_FONTS = {
maple,
sans: { label: 'Manrope · 清晰无衬线', css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' },
serif: { label: 'Noto Serif SC · 思源宋体', css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' },
system: { label: '系统字体', css: 'system-ui, sans-serif' },
sans: { get label() {
return translate('common.manrope_sans_serif_a8923c')
}, css: '\'Manrope\', \'PingFang SC\', \'Microsoft YaHei\', sans-serif' },
serif: { get label() {
return translate('common.noto_serif_sc_serif_18133d')
}, css: '\'Noto Serif SC\', \'Source Han Serif SC\', serif' },
system: { get label() {
return translate('common.system_font_96a2a0')
}, css: 'system-ui, sans-serif' },
}
export const CODE_FONTS = {
maple,
jetbrains: { label: 'JetBrains Mono', css: '\'JetBrains Mono\', ui-monospace, monospace' },
system: { label: '系统等宽字体', css: 'ui-monospace, monospace' },
system: { get label() {
return translate('common.system_monospace_e32ca0')
}, css: 'ui-monospace, monospace' },
}
export const UI_FONT_SIZE = { min: 14, max: 20, default: 14 }
export const FONT_PRESETS: Record<string, { label: string, css: string }> = { ...TEXT_FONTS, jetbrains: CODE_FONTS.jetbrains }
+10 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { online, waitingWorker } from '@@/utils/pwa'
import { ref } from 'vue'
@@ -14,7 +16,7 @@ async function update() {
busy.value = true
try {
if (!await props.beforeReload()) {
toast('请先完成保存、图片上传或文件操作,再更新应用')
toast(translate('common.finish_saving_uploading_images_or_b9e96e'))
return
}
if (!activated.value && worker) {
@@ -25,11 +27,11 @@ async function update() {
clearTimeout(timer)
worker.removeEventListener('statechange', finish)
if (worker.state === 'activated') resolve()
else reject(new Error('新版本激活失败'))
else reject(new Error(translate('common.could_not_activate_the_new_c0fd5b')))
}
const timer = setTimeout(() => {
worker.removeEventListener('statechange', finish)
reject(new Error('等待新版本超时'))
reject(new Error(translate('common.timed_out_waiting_for_the_57775d')))
}, 15000)
worker.addEventListener('statechange', finish)
worker.postMessage('ACTIVATE')
@@ -39,9 +41,9 @@ async function update() {
}
// 激活期间仍可能继续输入,刷新前再次确认;保留按钮供保存后重试。
if (await props.beforeReload()) location.reload()
else toast('仍有未完成的保存或操作,请完成后再次点击刷新')
else toast(translate('common.some_changes_or_operations_are_a5fda9'))
} catch {
toast('暂时无法更新,请稍后重试')
toast(translate('common.cannot_update_right_now_please_c0de6a'))
} finally {
busy.value = false
}
@@ -50,11 +52,11 @@ async function update() {
<template>
<div v-if="!online || waitingWorker || activated" class="rm-app-status" role="status">
<span v-if="!online">离线,无法加载或保存笔记</span>
<span v-if="!online">{{ translate('common.offline_notes_cannot_be_loaded_8ecb92') }}</span>
<template v-else-if="waitingWorker || activated">
<span>有新版本</span>
<span>{{ translate('common.update_available_daedea') }}</span>
<el-button class="rm-btn" :disabled="busy" @click="update">
{{ busy ? '正在保存…' : '保存并刷新' }}
{{ busy ? translate('common.saving_6bdb44') : translate('common.save_and_refresh_d080ec') }}
</el-button>
</template>
</div>
+13 -11
View File
@@ -3,6 +3,8 @@ import type { Settings } from '@/pinia/stores/settings'
import { deleteFontApi, importFontApi } from '@@/apis/fonts'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { computed, ref } from 'vue'
@@ -48,7 +50,7 @@ async function save(patch: Partial<Settings>) {
try {
await loadSettings()
} catch (reload) {
error.value += `;重新读取设置失败:${String(reload)}`
error.value += translate('common.could_not_reload_settings_86605a', { p0: String(reload) })
}
return false
}
@@ -63,21 +65,21 @@ async function importFont(event: Event) {
busy.value = true
error.value = ''
try {
if (file.size > 30 * 1024 * 1024) throw new Error('字体文件超过 30 MB')
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error('请选择 TTF、OTF、WOFF 或 WOFF2 字体')
if (file.size > 30 * 1024 * 1024) throw new Error(translate('common.font_files_must_not_exceed_cc001c'))
if (!/\.(ttf|otf|woff2?)$/i.test(file.name)) throw new Error(translate('common.choose_a_ttf_otf_woff_eb6ab7'))
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer()
try {
await new FontFace('rawmark-import-check', data).load()
} catch (cause) {
throw new Error('无法解析字体文件,请选择有效的字体。', { cause })
throw new Error(translate('common.could_not_read_this_font_ecc62f'), { cause })
}
user.assertSession(version)
const font = (await importFontApi(file)).data
await loadFonts()
user.assertSession(version)
if (!await save({ [props.setting]: font.id })) return
toast('字体已导入')
toast(translate('common.font_imported_3dc232'))
} catch (cause) {
if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause)
@@ -100,7 +102,7 @@ async function remove(id: string) {
await deleteFontApi(id)
await loadSettings()
}
toast('字体已删除,使用它的位置已恢复 Maple')
toast(translate('common.font_deleted_its_uses_have_b83e92'))
} catch (cause) {
if (isCancel(cause)) return
error.value = cause instanceof Error ? cause.message : String(cause)
@@ -115,22 +117,22 @@ async function remove(id: string) {
<el-select :id="id" :model-value="settings[setting]" :disabled="busy" :loading="busy" filterable popper-class="rm-font-options" @change="choose">
<el-option v-for="font in fonts" :key="font.id" :label="font.name" :value="font.id">
<div class="rm-font-option">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small>
<el-button v-else text size="small" :aria-label="`删除字体 ${font.name}`" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<span>{{ font.name }}</span><small v-if="font.id === 'maple'">{{ translate('common.default_844b8c') }}</small>
<el-button v-else text size="small" :aria-label="translate('common.delete_font_3a6b32', { p0: font.name })" :disabled="busy" @mousedown.prevent.stop @click.stop="remove(font.id)">
<Icon name="trash" />
</el-button>
</div>
</el-option>
<template #footer>
<el-button class="rm-font-import" :disabled="busy" @click="fileInput!.click()">
<Icon name="import" />导入字体
<Icon name="import" />{{ translate('common.import_font_50751a') }}
</el-button>
<p class="rm-share-note">
TTF、OTF、WOFF、WOFF2,最多 30 MB。
{{ translate('common.ttf_otf_woff_or_woff_0cc484') }}
</p>
</template>
</el-select>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="`导入字体 ${id}`" @change="importFont">
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="translate('common.import_font_183cd9', { p0: id })" @change="importFont">
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { InlineStyles } from '../../markdown/inline-styles'
import { t as translate } from '@@/i18n/runtime'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
@@ -44,49 +46,49 @@ function switchSize() {
</script>
<template>
<div class="rm-inline-appearance" role="region" aria-label="文字外观">
<div class="rm-inline-appearance" role="region" :aria-label="translate('common.text_appearance_09f8f2')">
<fieldset>
<legend>配色</legend><div class="rm-inline-swatches">
<button type="button" title="默认配色" aria-label="默认配色" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })">
<legend>{{ translate('common.color_scheme_2ae75e') }}</legend><div class="rm-inline-swatches">
<button type="button" :title="translate('common.default_color_scheme_7f0813')" :aria-label="translate('common.default_color_scheme_7f0813')" @mousedown.prevent @click="emit('change', { 'color': undefined, 'background-color': undefined })">
A
</button>
<button v-for="(color, index) in foreground" :key="index" type="button" :title="`配色 ${index + 1}`" :aria-label="`配色 ${index + 1}`" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">
<button v-for="(color, index) in foreground" :key="index" type="button" :title="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :aria-label="translate('common.color_scheme_d2f13c', { p0: index + 1 })" :style="{ color, backgroundColor: background[index] }" @mousedown.prevent @click="emit('change', { color, 'background-color': background[index] })">
A
</button>
</div>
</fieldset>
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', colors: background }]" :key="group.key">
<fieldset v-for="group in [{ key: 'color', get title() { return translate('common.text_color_fcaea5') }, colors: foreground }, { key: 'background-color', get title() { return translate('common.background_color_6d2b29') }, colors: background }]" :key="group.key">
<legend>{{ group.title }}</legend><div class="rm-inline-swatches">
<button type="button" :title="`默认${group.title}`" :aria-label="`默认${group.title}`" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">
<button type="button" :title="translate('common.default_4a2e3e', { p0: group.title })" :aria-label="translate('common.default_4a2e3e', { p0: group.title })" @mousedown.prevent @click="emit('change', { [group.key]: undefined })">
↺
</button>
<button v-for="(color, index) in group.colors" :key="color" type="button" :title="`${group.title} ${index + 1}`" :aria-label="`${group.title} ${index + 1}`" :aria-pressed="styles[group.key as keyof InlineStyles] === color" :style="group.key === 'color' ? { color } : { backgroundColor: color, color: foreground[index] }" @mousedown.prevent @click="emit('change', { [group.key]: color })">
A
</button>
<label class="rm-inline-custom" :title="`自定义${group.title}`"><span>自定义</span><input type="color" :aria-label="`自定义${group.title}`" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label>
<label class="rm-inline-custom" :title="translate('common.custom_fc732e', { p0: group.title })"><span>{{ translate('common.custom_4eafa9') }}</span><input type="color" :aria-label="translate('common.custom_fc732e', { p0: group.title })" :value="styles[group.key as keyof InlineStyles] ?? (group.key === 'color' ? '#333333' : '#fff88f')" @change="emit('change', { [group.key]: ($event.target as HTMLInputElement).value })"></label>
</div>
</fieldset>
<fieldset>
<legend>字体效果</legend><div class="rm-inline-effects">
<button type="button" title="空心" aria-label="空心" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">
<legend>{{ translate('common.text_effects_b8be49') }}</legend><div class="rm-inline-effects">
<button type="button" :title="translate('common.outline_837aa5')" :aria-label="translate('common.outline_837aa5')" :aria-pressed="!!styles['-webkit-text-stroke']" :style="{ WebkitTextStroke: HOLLOW, WebkitTextFillColor: 'transparent' }" @mousedown.prevent @click="emit('change', { '-webkit-text-stroke': styles['-webkit-text-stroke'] ? undefined : HOLLOW, '-webkit-text-fill-color': styles['-webkit-text-stroke'] ? undefined : 'transparent' })">
A
</button>
<button type="button" title="投影" aria-label="投影" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">
<button type="button" :title="translate('common.shadow_caabf3')" :aria-label="translate('common.shadow_caabf3')" :aria-pressed="!!styles['text-shadow']" :style="{ textShadow: SHADOW }" @mousedown.prevent @click="emit('change', { 'text-shadow': styles['text-shadow'] ? undefined : SHADOW })">
A
</button>
</div>
</fieldset>
<label class="rm-inline-control">字体<select aria-label="局部字体" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">继承正文</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<label class="rm-inline-control">{{ translate('common.font_078838') }}<select :aria-label="translate('common.selection_font_3ff21e')" :value="styles['font-family'] ?? ''" @change="emit('change', { 'font-family': ($event.target as HTMLSelectElement).value || undefined })"><option value="">{{ translate('common.inherit_body_font_20476e') }}</option><option v-for="font in INLINE_FONTS" :key="font.css" :value="font.css">{{ font.label }}</option></select></label>
<div class="rm-inline-control">
<span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize"> 相对字号</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">
默认
<span>{{ translate('common.font_size_f71561') }}</span><label><input v-model="relative" type="checkbox" :aria-label="translate('common.relative_font_size_45afe1')" @change="switchSize"> {{ translate('common.relative_font_size_45afe1') }}</label><el-button native-type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">
{{ translate('common.default_844b8c') }}
</el-button>
</div>
<form class="rm-inline-size" @submit.prevent="setSize">
<input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号滑块" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" aria-label="局部字号" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span>
<input v-model.number="size" type="range" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_slider_494d79')" @change="setSize"><input v-model.number="size" type="number" :min="relative ? 56 : 9" :max="relative ? 450 : 72" step="1" :aria-label="translate('common.selection_font_size_5a5162')" @change="setSize"><span>{{ relative ? '%' : 'px' }}</span>
</form>
<el-button native-type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">
清除样式
{{ translate('common.clear_appearance_384fbf') }}
</el-button>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Locale } from '@@/i18n/runtime'
import { locale } from '@@/i18n/runtime'
defineEmits<{ change: [value: Locale] }>()
</script>
<template>
<el-select :model-value="locale" aria-label="Language / 语言" style="width: 160px" @change="$emit('change', $event)">
<el-option label="English" value="en" />
<el-option label="简体中文" value="zh-CN" />
</el-select>
</template>
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "Retry",
"fonts_could_not_be_loaded_42a574": "Fonts could not be loaded. Check your connection and refresh the page.",
"refresh_page_9366e1": "Refresh page",
"invalid_font_catalog_b577df": "Invalid font catalog",
"manrope_sans_serif_a8923c": "Manrope · Sans serif",
"noto_serif_sc_serif_18133d": "Noto Serif SC · Serif",
"system_font_96a2a0": "System font",
"system_monospace_e32ca0": "System monospace",
"finish_saving_uploading_images_or_b9e96e": "Finish saving, uploading images, or working with files before updating the app",
"could_not_activate_the_new_c0fd5b": "Could not activate the new version",
"timed_out_waiting_for_the_57775d": "Timed out waiting for the new version",
"some_changes_or_operations_are_a5fda9": "Some changes or operations are still pending. Finish them before refreshing",
"cannot_update_right_now_please_c0de6a": "Cannot update right now. Please try again later",
"offline_notes_cannot_be_loaded_8ecb92": "Offline. Notes cannot be loaded or saved",
"update_available_daedea": "Update available",
"saving_6bdb44": "Saving…",
"save_and_refresh_d080ec": "Save and refresh",
"could_not_reload_settings_86605a": "; could not reload settings: {p0}",
"font_files_must_not_exceed_cc001c": "Font files must not exceed 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "Choose a TTF, OTF, WOFF, or WOFF2 font",
"could_not_read_this_font_ecc62f": "Could not read this font. Choose a valid font file.",
"font_imported_3dc232": "Font imported",
"font_deleted_its_uses_have_b83e92": "Font deleted. Its uses have been reset to Maple",
"default_844b8c": "Default",
"delete_font_3a6b32": "Delete font {p0}",
"import_font_50751a": "Import font",
"ttf_otf_woff_or_woff_0cc484": "TTF, OTF, WOFF, or WOFF2, up to 30 MB.",
"import_font_183cd9": "Import font {p0}",
"text_appearance_09f8f2": "Text appearance",
"color_scheme_2ae75e": "Color scheme",
"default_color_scheme_7f0813": "Default color scheme",
"color_scheme_d2f13c": "Color scheme {p0}",
"text_color_fcaea5": "Text color",
"background_color_6d2b29": "Background color",
"default_4a2e3e": "Default {p0}",
"custom_fc732e": "Custom {p0}",
"custom_4eafa9": "Custom",
"text_effects_b8be49": "Text effects",
"outline_837aa5": "Outline",
"shadow_caabf3": "Shadow",
"font_078838": "Font",
"selection_font_3ff21e": "Selection font",
"inherit_body_font_20476e": "Inherit body font",
"font_size_f71561": "Font size",
"relative_font_size_45afe1": "Relative font size",
"selection_font_size_slider_494d79": "Selection font size slider",
"selection_font_size_5a5162": "Selection font size",
"clear_appearance_384fbf": "Clear appearance",
"this_plugin_has_stopped_and_796b24": "This plugin has stopped and can no longer use the SDK",
"plugin_cleanup_failed_6388d7": "Plugin “{p0}” cleanup failed",
"plugin_cleanup_exceeded_seconds_32945c": "Plugin “{p0}” cleanup exceeded 5 seconds",
"failed_and_has_been_disabled_aa6412": "{p0} failed and has been disabled on this page. Retry in plugin settings",
"plugin_44a1b7": "Plugin “{p0}”: {p1}",
"invalid_plugin_registration_id_9702fe": "Invalid plugin registration ID",
"plugin_views_require_a_title_79ae1b": "Plugin views require a title and a mount function",
"view_1c5c06": "View",
"invalid_or_duplicate_command_f6503b": "Invalid or duplicate command",
"command_condition_df314d": "Command condition",
"command_928f87": "Command",
"a_code_block_renderer_must_952f83": "A code block renderer must be a function",
"code_block_rendering_1d4ae4": "Code block rendering",
"a_cleanup_callback_must_be_6ce04e": "A cleanup callback must be a function",
"invalid_vault_event_type_8621e4": "Invalid vault event type",
"vault_event_03582e": "Vault event",
"plugin_stopped_9ac692": "Plugin stopped",
"plugin_activation_exceeded_seconds_cffeae": "Plugin activation exceeded 10 seconds",
"a_plugin_must_export_an_1fbc50": "A plugin must export an activation function as its default export",
"activation_must_return_a_cleanup_ea3a8c": "Activation must return a cleanup function",
"activation_dd1286": "Activation",
"plugin_host_stopped_901067": "Plugin host stopped",
"plugin_refresh_superseded_2fa245": "Plugin refresh superseded",
"could_not_load_plugins_a1c343": "Could not load plugins: {p0}",
"editor_extension_28bb35": "Editor extension",
"plugin_view_rendering_exceeded_seconds_0d20d2": "Plugin view rendering exceeded 10 seconds",
"plugin_mount_must_return_a_c66e82": "Plugin mount must return a cleanup function",
"duplicate_plugin_registration_cannot_replace_fbc39b": "Duplicate plugin registration cannot replace existing functionality",
"invalid_code_block_language_name_8de01a": "Invalid code block language name",
"empty_diagram_37edb7": "Empty diagram",
"invalid_diagram_syntax_333e74": "Invalid diagram syntax",
"built_in_diagram_rendering_failed_e93cc6": "Built-in diagram rendering failed. Please retry",
"command_not_found_1cdb0c": "Command not found: {p0}",
"confirm_action_f8347f": "Confirm action",
"confirm_fac2a6": "Confirm",
"cancel_2cd0f3": "Cancel",
"invalid_shortcut_format_5e48e7": "Invalid shortcut format",
"space_bfa4a6": "Space",
"image_resource_authentication_is_not_73d285": "Image resource authentication is not ready",
"font_resource_authentication_is_not_796b9b": "Font resource authentication is not ready",
"invalid_server_response_protocol_194dc7": "Invalid server response protocol",
"request_failed_126fbf": "Request failed",
"request_failed_http_f21a09": "Request failed (HTTP {p0})",
"session_changed_073f28": "Session changed",
"sign_in_1e2df9": "Sign in",
"api_tokens_79db53": "API tokens",
"sign_out_498e1d": "Sign out",
"edit_051836": "Edit",
"delete_2f9daa": "Delete",
"restore_e0534b": "Restore",
"import_576d81": "Import",
"trash_ba35dc": "Trash",
"network_error_check_your_connection_566064": "Network error. Check your connection",
"name_d44e9b": "Name",
"copy_63d90d": "Copy",
"could_not_delete_9619d7": "Could not delete: {p0}",
"settings_df3d58": "Settings",
"search_results_88d72e": "Search results",
"appearance_86a63f": "Appearance",
"sign_out_3ab8cc": "Sign out",
"narrow_a614db": "Narrow",
"standard_6bea77": "Standard",
"wide_8dd966": "Wide",
"full_width_8ecc7d": "Full width",
"settings_saved_but_the_theme_9f87f8": "Settings saved, but the theme could not be applied: {p0}",
"invalid_shortcut_settings_2f07e6": "Invalid shortcut settings",
"could_not_save_settings_1cdd7f": "Could not save settings: {p0}",
"invalid_identity_information_e69b1a": "Invalid identity information",
"setup_65622e": "Setup",
"access_denied_25fb07": "Access denied",
"page_not_found_e75737": "Page not found",
"network_error": "Network error",
"move": "Move",
"network_or_local_storage_error": "Network or local storage error"
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { applyLocale, DEFAULT_LOCALE, locale, t } from './runtime'
const catalogs = import.meta.glob<Record<string, string>>(['./*.json', '../../pages/*/locales/*.json'], { eager: true, import: 'default' })
afterEach(() => applyLocale(DEFAULT_LOCALE))
it('默认英文,语言切换同时更新 HTML 语言与带参数提示', () => {
expect(DEFAULT_LOCALE).toBe('en')
applyLocale('en')
expect(document.documentElement.lang).toBe('en')
expect(t('common.retry_b8784c')).toBe('Retry')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('Heading 2')
expect(t('workspace.notes_fd9460', { count: 1 })).toBe('1 note')
expect(t('workspace.notes_fd9460', { count: 2 })).toBe('2 notes')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('Permanently deleted 1 item; 2 failed')
applyLocale('zh-CN')
expect(locale.value).toBe('zh-CN')
expect(document.documentElement.lang).toBe('zh-CN')
expect(t('common.retry_b8784c')).toBe('重试')
expect(t('workspace.heading_a4f510', { p0: 2 })).toBe('设置 2 级标题')
expect(t('settings.permanently_deleted_with_failures', { count: 1, failed: 2 })).toBe('已永久删除 1 项,2 项失败')
})
it('每个模块的英文与中文消息键和插值参数一致', () => {
for (const [path, en] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
const zh = catalogs[path.replace('/en.json', '/zh-CN.json')]!
expect(Object.keys(en).sort(), path).toEqual(Object.keys(zh).sort())
for (const [key, value] of Object.entries(en)) {
expect(zh[key], `${path}: ${key}`).toBeTruthy()
expect([...new Set([...value.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort(), key)
.toEqual([...new Set([...zh[key]!.matchAll(/\{(\w+)\}/g)].map(m => m[1]))].sort())
}
}
})
it('源码中使用的消息键均存在,默认英文不遗留中文界面文案', () => {
const sources = import.meta.glob<string>(['../../**/*.ts', '../../**/*.vue', '!../../**/*.test.ts'], { eager: true, query: '?raw', import: 'default' })
for (const [path, source] of Object.entries(sources)) {
for (const [, key] of source.matchAll(/(?:translate|\bt)\('((?:common|workspace|settings|login|error)\.[^']+)'/g)) {
const [module, id] = key!.split('.')
const catalogPath = module === 'common' ? './en.json' : `../../pages/${module}/locales/en.json`
expect(catalogs[catalogPath]?.[id!], `${path}: ${key}`).toBeTruthy()
}
}
for (const [path, catalog] of Object.entries(catalogs).filter(([path]) => path.endsWith('/en.json'))) {
expect(/[\u3400-\u9FFF]/.test(JSON.stringify(catalog)), path).toBe(false)
}
})
+36
View File
@@ -0,0 +1,36 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhCN from './zh-CN.json'
export const LOCALES = ['en', 'zh-CN'] as const
export type Locale = typeof LOCALES[number]
export const DEFAULT_LOCALE: Locale = 'en'
export function isLocale(value: unknown): value is Locale {
return value === 'en' || value === 'zh-CN'
}
const cached = typeof localStorage === 'undefined' ? {} : JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
const initialLocale = isLocale(cached.locale) ? cached.locale : DEFAULT_LOCALE
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: false,
messages: { 'en': { common: en }, 'zh-CN': { common: zhCN } },
})
export const locale = i18n.global.locale
export function t(key: string, params: Record<string, unknown> = {}): string {
return i18n.global.t(key, params)
}
export function applyLocale(value: Locale) {
locale.value = value
if (typeof document !== 'undefined') document.documentElement.lang = value
}
/** 登录页仅缓存显示语言;登录后由服务器账户设置覆盖。 */
export function chooseAnonymousLocale(value: Locale) {
const cached = JSON.parse(localStorage.getItem('rawmark.settings') ?? '{}')
localStorage.setItem('rawmark.settings', JSON.stringify({ ...cached, locale: value }))
applyLocale(value)
}
applyLocale(initialLocale)
+124
View File
@@ -0,0 +1,124 @@
{
"retry_b8784c": "重试",
"fonts_could_not_be_loaded_42a574": "字体加载失败,请检查网络后刷新页面。",
"refresh_page_9366e1": "刷新页面",
"invalid_font_catalog_b577df": "字体列表格式无效",
"manrope_sans_serif_a8923c": "Manrope · 清晰无衬线",
"noto_serif_sc_serif_18133d": "Noto Serif SC · 思源宋体",
"system_font_96a2a0": "系统字体",
"system_monospace_e32ca0": "系统等宽字体",
"finish_saving_uploading_images_or_b9e96e": "请先完成保存、图片上传或文件操作,再更新应用",
"could_not_activate_the_new_c0fd5b": "新版本激活失败",
"timed_out_waiting_for_the_57775d": "等待新版本超时",
"some_changes_or_operations_are_a5fda9": "仍有未完成的保存或操作,请完成后再次点击刷新",
"cannot_update_right_now_please_c0de6a": "暂时无法更新,请稍后重试",
"offline_notes_cannot_be_loaded_8ecb92": "离线,无法加载或保存笔记",
"update_available_daedea": "有新版本",
"saving_6bdb44": "正在保存…",
"save_and_refresh_d080ec": "保存并刷新",
"could_not_reload_settings_86605a": ";重新读取设置失败:{p0}",
"font_files_must_not_exceed_cc001c": "字体文件超过 30 MB",
"choose_a_ttf_otf_woff_eb6ab7": "请选择 TTF、OTF、WOFF 或 WOFF2 字体",
"could_not_read_this_font_ecc62f": "无法解析字体文件,请选择有效的字体。",
"font_imported_3dc232": "字体已导入",
"font_deleted_its_uses_have_b83e92": "字体已删除,使用它的位置已恢复 Maple",
"default_844b8c": "默认",
"delete_font_3a6b32": "删除字体 {p0}",
"import_font_50751a": "导入字体",
"ttf_otf_woff_or_woff_0cc484": "TTF、OTF、WOFF、WOFF2,最多 30 MB。",
"import_font_183cd9": "导入字体 {p0}",
"text_appearance_09f8f2": "文字外观",
"color_scheme_2ae75e": "配色",
"default_color_scheme_7f0813": "默认配色",
"color_scheme_d2f13c": "配色 {p0}",
"text_color_fcaea5": "字体颜色",
"background_color_6d2b29": "背景色",
"default_4a2e3e": "默认{p0}",
"custom_fc732e": "自定义{p0}",
"custom_4eafa9": "自定义",
"text_effects_b8be49": "字体效果",
"outline_837aa5": "空心",
"shadow_caabf3": "投影",
"font_078838": "字体",
"selection_font_3ff21e": "局部字体",
"inherit_body_font_20476e": "继承正文",
"font_size_f71561": "字号",
"relative_font_size_45afe1": "相对字号",
"selection_font_size_slider_494d79": "局部字号滑块",
"selection_font_size_5a5162": "局部字号",
"clear_appearance_384fbf": "清除样式",
"this_plugin_has_stopped_and_796b24": "插件已停止,不能继续调用 SDK",
"plugin_cleanup_failed_6388d7": "插件“{p0}”清理失败",
"plugin_cleanup_exceeded_seconds_32945c": "插件“{p0}”清理超过 5 秒",
"failed_and_has_been_disabled_aa6412": "{p0}失败,已在当前页面停用;可在插件设置中重试",
"plugin_44a1b7": "插件“{p0}”:{p1}",
"invalid_plugin_registration_id_9702fe": "插件注册 id 无效",
"plugin_views_require_a_title_79ae1b": "插件视图必须提供标题与挂载函数",
"view_1c5c06": "视图",
"invalid_or_duplicate_command_f6503b": "命令无效或重复注册",
"command_condition_df314d": "命令条件",
"command_928f87": "命令",
"a_code_block_renderer_must_952f83": "代码块渲染器必须为函数",
"code_block_rendering_1d4ae4": "代码块渲染",
"a_cleanup_callback_must_be_6ce04e": "清理回调必须为函数",
"invalid_vault_event_type_8621e4": "仓库事件类型无效",
"vault_event_03582e": "仓库事件",
"plugin_stopped_9ac692": "插件已停止",
"plugin_activation_exceeded_seconds_cffeae": "插件激活超过 10 秒",
"a_plugin_must_export_an_1fbc50": "插件必须默认导出激活函数",
"activation_must_return_a_cleanup_ea3a8c": "激活返回值必须为清理函数",
"activation_dd1286": "激活",
"plugin_host_stopped_901067": "插件宿主已停止",
"plugin_refresh_superseded_2fa245": "插件刷新已过期",
"could_not_load_plugins_a1c343": "读取插件列表失败:{p0}",
"editor_extension_28bb35": "编辑器扩展",
"plugin_view_rendering_exceeded_seconds_0d20d2": "插件视图渲染超过 10 秒",
"plugin_mount_must_return_a_c66e82": "插件挂载返回值必须为清理函数",
"duplicate_plugin_registration_cannot_replace_fbc39b": "插件重复注册,不能覆盖已有功能",
"invalid_code_block_language_name_8de01a": "代码块语言名无效",
"empty_diagram_37edb7": "空图表",
"invalid_diagram_syntax_333e74": "图表语法错误",
"built_in_diagram_rendering_failed_e93cc6": "内置图表渲染失败,请重试",
"command_not_found_1cdb0c": "命令不存在:{p0}",
"confirm_action_f8347f": "确认操作",
"confirm_fac2a6": "确定",
"cancel_2cd0f3": "取消",
"invalid_shortcut_format_5e48e7": "快捷键格式无效",
"space_bfa4a6": "空格",
"image_resource_authentication_is_not_73d285": "图片资源认证尚未就绪",
"font_resource_authentication_is_not_796b9b": "字体资源认证尚未就绪",
"invalid_server_response_protocol_194dc7": "服务器响应协议无效",
"request_failed_126fbf": "请求失败",
"request_failed_http_f21a09": "请求失败(HTTP {p0})",
"session_changed_073f28": "会话已变化",
"sign_in_1e2df9": "登录",
"api_tokens_79db53": "API 令牌",
"sign_out_498e1d": "退出",
"edit_051836": "编辑",
"delete_2f9daa": "删除",
"restore_e0534b": "恢复",
"import_576d81": "导入",
"trash_ba35dc": "回收站",
"network_error_check_your_connection_566064": "网络错误,请检查连接",
"name_d44e9b": "名称",
"copy_63d90d": "复制",
"could_not_delete_9619d7": "删除失败:{p0}",
"settings_df3d58": "设置",
"search_results_88d72e": "搜索结果",
"appearance_86a63f": "外观",
"sign_out_3ab8cc": "退出登录",
"narrow_a614db": "窄",
"standard_6bea77": "标准",
"wide_8dd966": "宽",
"full_width_8ecc7d": "全宽",
"settings_saved_but_the_theme_9f87f8": "设置已保存,但主题应用失败:{p0}",
"invalid_shortcut_settings_2f07e6": "快捷键设置格式无效",
"could_not_save_settings_1cdd7f": "设置保存失败:{p0}",
"invalid_identity_information_e69b1a": "身份信息格式错误",
"setup_65622e": "初始化",
"access_denied_25fb07": "访问受限",
"page_not_found_e75737": "页面不存在",
"network_error": "网络错误",
"move": "移动",
"network_or_local_storage_error": "网络或本地存储错误"
}
+2 -2
View File
@@ -37,7 +37,7 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
await vi.waitFor(() => expect(pluginState.status.fault?.cause).toBe(cause))
expect(pluginState.status.fault?.phase).toBe('error')
expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false)
expect(notices.some(message => message.includes('命令失败'))).toBe(true)
expect(notices.some(message => message.includes('Command failed'))).toBe(true)
list.mockClear()
await retryPlugin('fault')
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
@@ -64,7 +64,7 @@ it('启动失败由入口通知一次,取消刷新不产生故障状态', asyn
await vi.waitFor(() => expect(notice).toHaveBeenCalledOnce())
list.mockResolvedValueOnce(apiSuccess({ plugins: [] }))
await refreshPlugins()
list.mockRejectedValueOnce(new CanceledError('会话已变化'))
list.mockRejectedValueOnce(new CanceledError('Session changed'))
await expect(refreshPlugins()).rejects.toSatisfy(isCancel)
expect(pluginState.error).toBe('')
expect(notice).toHaveBeenCalledOnce()
+29 -28
View File
@@ -2,6 +2,7 @@ import type { InstalledPlugin } from '@@/apis/plugins/type'
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '@@/plugin-api'
import { getPluginDataApi, getPluginsApi, savePluginDataApi } from '@@/apis/plugins'
import { getTreeApi } from '@@/apis/tree'
import { t as translate } from '@@/i18n/runtime'
import { readNoteFile } from '@@/utils/note-files'
import { pluginResourceURL } from '@@/utils/resource-tickets'
@@ -35,12 +36,12 @@ function status(id: string, phase: string, error = '', cause?: unknown) {
pluginState.status[id] = { phase, error, cause, cleanupErrors: pluginState.status[id]?.cleanupErrors }
}
function assertActive(instance: Instance) {
if (instance.stopped || !running) throw new Error('插件已停止,不能继续调用 SDK')
if (instance.stopped || !running) throw new Error(translate('common.this_plugin_has_stopped_and_796b24'))
}
function cleanupFailure(instance: Instance, cause: unknown) {
instance.cleanupErrors.push(cause)
adapter.notice(`插件“${instance.info.name}”清理失败`)
adapter.notice(translate('common.plugin_cleanup_failed_6388d7', { p0: instance.info.name }))
}
function track(instance: Instance, cleanup: AsyncCleanup): () => void {
@@ -75,27 +76,27 @@ async function stop(instance: Instance) {
}),
])
clearTimeout(timer!)
if (!finished) adapter.notice(`插件“${instance.info.name}”清理超过 5 秒`)
if (!finished) adapter.notice(translate('common.plugin_cleanup_exceeded_seconds_32945c', { p0: instance.info.name }))
}
function fail(instance: Instance, phase: string, cause: unknown) {
if (instance.stopped) return
const message = `${phase}失败,已在当前页面停用;可在插件设置中重试`
const message = translate('common.failed_and_has_been_disabled_aa6412', { p0: phase })
faults.set(instance.info.id, instance.info.revision)
status(instance.info.id, 'error', message, cause)
adapter.notice(`插件“${instance.info.name}”:${message}`)
adapter.notice(translate('common.plugin_44a1b7', { p0: instance.info.name, p1: message }))
void stop(instance)
}
function localID(instance: Instance, id: string) {
if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error('插件注册 id 无效')
if (!/^[a-z0-9][\w.-]{0,63}$/i.test(id)) throw new Error(translate('common.invalid_plugin_registration_id_9702fe'))
return `plugin:${instance.info.id}:${id}`
}
function addView(instance: Instance, map: typeof sidebarViews, view: PluginView) {
assertActive(instance)
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error('插件视图必须提供标题与挂载函数')
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, '视图', cause) }))
if (!view.title.trim() || typeof view.mount !== 'function') throw new Error(translate('common.plugin_views_require_a_title_79ae1b'))
return track(instance, registerEntry(map, localID(instance, view.id), { ...view, owner: instance.info.id, fail: cause => fail(instance, translate('common.view_1c5c06'), cause) }))
}
async function createAPI(instance: Instance): Promise<PluginAPI> {
@@ -109,20 +110,20 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
addCommand(command) {
check()
const commandID = localID(instance, command.id)
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error('命令无效或重复注册')
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some(c => c.id === commandID)) throw new Error(translate('common.invalid_or_duplicate_command_f6503b'))
return track(instance, registerCommand({ ...command, id: commandID, keys: [], when: () => {
if (instance.stopped) return false
try {
return command.when?.() ?? true
} catch (cause) {
fail(instance, '命令条件', cause); return false
fail(instance, translate('common.command_condition_df314d'), cause); return false
}
}, run: () => {
if (instance.stopped) return
try {
void Promise.resolve(command.run()).catch(cause => fail(instance, '命令', cause))
void Promise.resolve(command.run()).catch(cause => fail(instance, translate('common.command_928f87'), cause))
} catch (cause) {
fail(instance, '命令', cause)
fail(instance, translate('common.command_928f87'), cause)
}
} }))
},
@@ -136,13 +137,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
},
registerCodeBlockRenderer(language, render) {
check()
if (typeof render !== 'function') throw new Error('代码块渲染器必须为函数')
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, '代码块渲染', cause) }))
if (typeof render !== 'function') throw new Error(translate('common.a_code_block_renderer_must_952f83'))
return track(instance, registerCodeRenderer(language, { owner: id, render, fail: cause => fail(instance, translate('common.code_block_rendering_1d4ae4'), cause) }))
},
addSidebarView: view => addView(instance, sidebarViews, view),
addSettingTab: view => addView(instance, settingTabs, view),
registerCleanup: (cleanup) => {
if (typeof cleanup !== 'function') throw new Error('清理回调必须为函数')
if (typeof cleanup !== 'function') throw new Error(translate('common.a_cleanup_callback_must_be_6ce04e'))
return track(instance, cleanup)
},
vault: {
@@ -157,13 +158,13 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
},
on<K extends keyof VaultEvents>(event: K, callback: (value: VaultEvents[K]) => void | Promise<void>) {
check()
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error('仓库事件类型无效')
if (!['changed', 'moved', 'deleted', 'resync'].includes(event)) throw new Error(translate('common.invalid_vault_event_type_8621e4'))
const listener = (e: Event) => {
if (instance.stopped) return
try {
void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, '仓库事件', cause))
void Promise.resolve(callback(event === 'resync' ? undefined : (e as CustomEvent).detail)).catch(cause => fail(instance, translate('common.vault_event_03582e'), cause))
} catch (cause) {
fail(instance, '仓库事件', cause)
fail(instance, translate('common.vault_event_03582e'), cause)
}
}
pluginEvents.addEventListener(event, listener)
@@ -197,9 +198,9 @@ async function activate(instance: Instance) {
const signal = instance.controller.signal
let onAbort: () => void = () => {}
const interrupted = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error('插件已停止'))
onAbort = () => reject(new Error(translate('common.plugin_stopped_9ac692')))
signal.addEventListener('abort', onAbort, { once: true })
timer = setTimeout(() => reject(new Error('插件激活超过 10 秒')), 10_000)
timer = setTimeout(() => reject(new Error(translate('common.plugin_activation_exceeded_seconds_cffeae'))), 10_000)
})
const pending = (async () => {
const sdk = await createAPI(instance)
@@ -209,9 +210,9 @@ async function activate(instance: Instance) {
assertActive(instance)
const module = await import(/* @vite-ignore */ url)
assertActive(instance)
if (typeof module.default !== 'function') throw new Error('插件必须默认导出激活函数')
if (typeof module.default !== 'function') throw new Error(translate('common.a_plugin_must_export_an_1fbc50'))
const cleanup: unknown = await module.default(sdk)
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error('激活返回值必须为清理函数')
if (cleanup !== undefined && typeof cleanup !== 'function') throw new Error(translate('common.activation_must_return_a_cleanup_ea3a8c'))
if (cleanup) {
if (instance.stopped) {
try {
@@ -228,7 +229,7 @@ async function activate(instance: Instance) {
await Promise.race([pending, interrupted])
if (!instance.stopped) status(instance.info.id, 'active')
} catch (cause) {
fail(instance, '激活', cause)
fail(instance, translate('common.activation_dd1286'), cause)
} finally {
clearTimeout(timer!)
signal.removeEventListener('abort', onAbort)
@@ -256,12 +257,12 @@ async function pump() {
}
export async function refreshPlugins() {
if (!running) throw new CanceledError('插件宿主已停止')
if (!running) throw new CanceledError(translate('common.plugin_host_stopped_901067'))
const currentSession = session
const sequence = ++request
try {
const installed = (await getPluginsApi()).data.plugins
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
pluginState.installed = installed
pluginState.error = ''
pluginState.errorCause = undefined
@@ -277,9 +278,9 @@ export async function refreshPlugins() {
}
void pump()
} catch (cause) {
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
if (!running || currentSession !== session || sequence !== request) throw new CanceledError(translate('common.plugin_refresh_superseded_2fa245'))
if (isCancel(cause)) throw cause
pluginState.error = `读取插件列表失败:${cause instanceof Error ? cause.message : String(cause)}`
pluginState.error = translate('common.could_not_load_plugins_a1c343', { p0: cause instanceof Error ? cause.message : String(cause) })
pluginState.errorCause = cause
for (const instance of instances.values()) {
status(instance.info.id, 'paused')
@@ -319,7 +320,7 @@ export function pluginEditorFailure(error: unknown) {
queueMicrotask(() => {
for (const owner of owners) {
const instance = instances.get(owner)
if (instance) fail(instance, '编辑器扩展', error)
if (instance) fail(instance, translate('common.editor_extension_28bb35'), error)
}
})
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { AsyncCleanup, Awaitable, MountContext } from '@@/plugin-api'
import { t as translate } from '@@/i18n/runtime'
// 每次挂载使用独立容器;超时或停用后的异步渲染只能修改已脱离页面的容器。
export function mountContent(
@@ -30,14 +31,14 @@ export function mountContent(
const timer = setTimeout(() => {
if (!pending || stopped) return
stop()
fail(new Error('插件视图渲染超过 10 秒'))
fail(new Error(translate('common.plugin_view_rendering_exceeded_seconds_0d20d2')))
}, 10_000)
void Promise.resolve().then(() => {
if (!stopped) return mount({ container, signal: controller.signal })
}).then((result) => {
pending = false
clearTimeout(timer)
if (result !== undefined && typeof result !== 'function') throw new Error('插件挂载返回值必须为清理函数')
if (result !== undefined && typeof result !== 'function') throw new Error(translate('common.plugin_mount_must_return_a_c66e82'))
if (stopped) {
if (result) runCleanup(result)
} else {
+8 -5
View File
@@ -1,6 +1,7 @@
import type { CodeBlockRenderer, PluginView } from '@@/plugin-api'
import type { Extension } from '@codemirror/state'
import type { MarkdownExtension } from '@lezer/markdown'
import { t as translate } from '@@/i18n/runtime'
import { Facet, StateEffect } from '@codemirror/state'
import { shallowReactive } from 'vue'
import { renderMermaid } from '../markdown/render'
@@ -33,7 +34,7 @@ export function pluginsChanged() {
}
export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => void {
if (map.has(key)) throw new Error('插件重复注册,不能覆盖已有功能')
if (map.has(key)) throw new Error(translate('common.duplicate_plugin_registration_cannot_replace_fbc39b'))
map.set(key, value)
try {
pluginsChanged()
@@ -49,7 +50,7 @@ export function registerEntry<K, V>(map: Map<K, V>, key: K, value: V): () => voi
export function registerCodeRenderer(language: string, value: OwnedRenderer) {
const key = language.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error('代码块语言名无效')
if (!/^[a-z0-9][a-z0-9_+-]*$/.test(key)) throw new Error(translate('common.invalid_code_block_language_name_8de01a'))
return registerEntry(codeRenderers, key, value)
}
@@ -57,7 +58,7 @@ registerCodeRenderer('mermaid', {
owner: 'rawmark-mermaid',
async render({ source, container, signal }) {
if (!source.trim()) {
container.textContent = '空图表'; return
container.textContent = translate('common.empty_diagram_37edb7'); return
}
try {
const svg = await renderMermaid(source)
@@ -65,8 +66,10 @@ registerCodeRenderer('mermaid', {
} catch (error) {
if (signal.aborted) return
container.className = 'cm-rm-render-error'
container.textContent = error instanceof Error ? error.message : '图表语法错误'
container.textContent = error instanceof Error ? error.message : translate('common.invalid_diagram_syntax_333e74')
}
},
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { detail: '内置图表渲染失败,请重试' })),
fail: () => window.dispatchEvent(new CustomEvent('rawmark-plugin-notice', { get detail() {
return translate('common.built_in_diagram_rendering_failed_e93cc6')
} })),
})
+2 -1
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
import { shallowReactive } from 'vue'
import { pinia } from '@/pinia'
@@ -55,7 +56,7 @@ export function commandKeys(command: Command): string[] {
export function commandTitle(id: string): string {
const command = commands.get(id)
if (!command) throw new Error(`命令不存在:${id}`)
if (!command) throw new Error(translate('common.command_not_found_1cdb0c', { p0: id }))
const keys = commandKeys(command)
return command.title + (keys.length ? `(${keys.map(formatKeys).join(' / ')})` : '')
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { t as translate } from '@@/i18n/runtime'
import { ElMessageBox } from 'element-plus'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
/** 确认等待期间换会话时,旧操作视为取消。 */
export async function confirmAction(message: string, title = '确认操作'): Promise<boolean> {
export async function confirmAction(message: string, title = translate('common.confirm_action_f8347f')): Promise<boolean> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
try {
await ElMessageBox.confirm(message, title, { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', distinguishCancelAndClose: true })
await ElMessageBox.confirm(message, title, { get confirmButtonText() {
return translate('common.confirm_fac2a6')
}, get cancelButtonText() {
return translate('common.cancel_2cd0f3')
}, type: 'warning', distinguishCancelAndClose: true })
return version === user.getSessionVersion()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return false
+5 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 应用、编辑器和快捷键录入共用同一套按键表示。
export const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
const punctuation: Record<string, string> = { BracketLeft: '[', BracketRight: ']', Backquote: '`' }
@@ -13,7 +14,7 @@ function physicalCharacter(event: KeyboardEvent): string | undefined {
export function normalizeKeys(value: string): string {
const parts = value.split('+')
const key = parts.pop()
if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error('快捷键格式无效')
if (!key || parts.some(part => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error(translate('common.invalid_shortcut_format_5e48e7'))
return [...modifiers.filter(part => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+')
}
@@ -44,5 +45,7 @@ export function recordedKeys(event: KeyboardEvent): string | null {
}
export function formatKeys(value: string): string {
return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', Space: '空格', Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
return value.split('+').map(part => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', get Space() {
return translate('common.space_bfa4a6')
}, Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { createResourceTicketApi } from '@@/apis/auth'
import { t as translate } from '@@/i18n/runtime'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
@@ -28,11 +29,11 @@ export async function prepareResources() {
user.assertSession(current)
}
export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪')
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.image_resource_authentication_is_not_73d285'))
return imageRoot
}
export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪')
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error(translate('common.font_resource_authentication_is_not_796b9b'))
return fontRoot
}
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
+12
View File
@@ -1,3 +1,4 @@
import { applyLocale } from '@@/i18n/runtime'
import { readNoteFile } from '@@/utils/note-files'
import { toasts } from '@@/utils/notifications'
import { isCancel } from 'axios'
@@ -13,6 +14,7 @@ const user = useUserStore(pinia)
const permission = usePermissionStore(pinia)
const payload = (data: unknown, status = 200, code = 0, message = '成功') => new Response(JSON.stringify({ code, data, message }), { status, headers: { 'Content-Type': 'application/json' } })
beforeEach(() => {
applyLocale('en')
user.setToken('rms_test'); toasts.splice(0)
})
afterEach(() => {
@@ -24,6 +26,7 @@ it('使用 Bearer 和统一 JSON,省略 Cookie 且禁止缓存', async () => {
expect(await request({ method: 'post', url: '/test', data: { a: 1 } })).toEqual({ code: 0, data: { path: 'a.md' }, message: '成功' })
const req = fetch.mock.calls[0]![0] as Request
expect(req.headers.get('Authorization')).toBe('Bearer rms_test')
expect(req.headers.get('Accept-Language')).toBe('en')
expect(req.credentials).toBe('omit')
expect(fetch.mock.calls[0]![1]).toEqual(expect.objectContaining({ cache: 'no-store' }))
expect(await req.text()).toBe('{"a":1}')
@@ -110,3 +113,12 @@ it('读取笔记的原始响应解码期间切换会话,旧正文仍须拒绝'
expect(user.token).toBe('rms_new')
expect(toasts).toHaveLength(0)
})
it('语言切换仅影响后续请求,不取消业务请求或改变会话', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(payload({ ok: true }))
const version = user.getSessionVersion()
applyLocale('zh-CN')
await request({ method: 'get', url: '/localized' })
expect((fetch.mock.calls[0]![0] as Request).headers.get('Accept-Language')).toBe('zh-CN')
expect(user.getSessionVersion()).toBe(version)
})
+6 -4
View File
@@ -1,4 +1,5 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
import { locale, t as translate } from '@@/i18n/runtime'
import { reportError } from '@@/utils/notifications'
import axios, { AxiosHeaders, CanceledError, isAxiosError, isCancel } from 'axios'
import { pinia } from '@/pinia'
@@ -19,7 +20,7 @@ const instance = axios.create({ baseURL: '/api/v1', adapter: 'fetch', withCreden
function envelope(value: unknown, status: number): ApiResponseData<unknown> {
if (!value || typeof value !== 'object' || !('code' in value) || typeof value.code !== 'number'
|| !Number.isInteger(value.code) || !('data' in value) || !('message' in value) || typeof value.message !== 'string') {
throw new ApiError(status, '服务器响应协议无效')
throw new ApiError(status, translate('common.invalid_server_response_protocol_194dc7'))
}
return value as ApiResponseData<unknown>
}
@@ -27,7 +28,7 @@ function errorData(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
}
async function failure(error: unknown): Promise<ApiError> {
if (!isAxiosError(error) || !error.response) return error instanceof ApiError ? error : new ApiError(0, error instanceof Error ? error.message : '请求失败')
if (!isAxiosError(error) || !error.response) return error instanceof ApiError ? error : new ApiError(0, error instanceof Error ? error.message : translate('common.request_failed_126fbf'))
const response = error.response
let body: unknown = response.data
if (body instanceof Blob) body = await body.text()
@@ -35,7 +36,7 @@ async function failure(error: unknown): Promise<ApiError> {
try {
body = JSON.parse(body)
} catch {
return new ApiError(response.status, `请求失败(HTTP ${response.status})`)
return new ApiError(response.status, translate('common.request_failed_http_f21a09', { p0: response.status }))
}
}
try {
@@ -53,6 +54,7 @@ export async function request<T>(config: RequestConfig): Promise<T | AxiosRespon
const version = user.getSessionVersion()
const token = config.anonymous === true ? '' : user.token
const headers = new AxiosHeaders(config.headers as ConstructorParameters<typeof AxiosHeaders>[0])
headers.set('Accept-Language', locale.value)
headers.set('Authorization', token ? `Bearer ${token}` : undefined)
try {
const response = await instance.request<T, AxiosResponse<T, unknown, AxiosHeaders>>({
@@ -70,7 +72,7 @@ export async function request<T>(config: RequestConfig): Promise<T | AxiosRespon
return config.returnResponse ? response : response.data
} catch (cause) {
if (isCancel(cause)) throw cause
if (version !== user.getSessionVersion()) throw new CanceledError('会话已变化')
if (version !== user.getSessionVersion()) throw new CanceledError(translate('common.session_changed_073f28'))
const error = await failure(cause)
user.assertSession(version)
if (error.status === 401 && token) user.invalidate(version)
+13
View File
@@ -0,0 +1,13 @@
import { i18n } from '@@/i18n/runtime'
import errorEn from './pages/error/locales/en.json'
import errorZh from './pages/error/locales/zh-CN.json'
import loginEn from './pages/login/locales/en.json'
import loginZh from './pages/login/locales/zh-CN.json'
import settingsEn from './pages/settings/locales/en.json'
import settingsZh from './pages/settings/locales/zh-CN.json'
import workspaceEn from './pages/workspace/locales/en.json'
import workspaceZh from './pages/workspace/locales/zh-CN.json'
i18n.global.mergeLocaleMessage('en', { error: errorEn, login: loginEn, settings: settingsEn, workspace: workspaceEn })
i18n.global.mergeLocaleMessage('zh-CN', { error: errorZh, login: loginZh, settings: settingsZh, workspace: workspaceZh })
export { i18n }
+2 -1
View File
@@ -6,6 +6,7 @@ import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { router } from '@/router'
import App from './App.vue'
import { i18n } from './i18n'
import 'element-plus/dist/index.css'
import '@@/appearance/base.css'
import '@@/assets/styles/app.css'
@@ -14,7 +15,7 @@ import 'virtual:uno.css'
watchFontLoading()
const app = createApp(App)
app.use(pinia).use(router)
app.use(i18n).use(pinia).use(router)
const user = useUserStore(pinia)
window.addEventListener('storage', user.syncStorage)
watch(() => user.sessionVersion, () => {
+5 -1
View File
@@ -1,3 +1,7 @@
<script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
</script>
<template>
<div class="error">
<div class="error-svg">
@@ -5,7 +9,7 @@
</div>
<router-link to="/">
<el-button type="primary">
回到首页
{{ translate('error.back_to_home_6774a7') }}
</el-button>
</router-link>
</div>
+3
View File
@@ -0,0 +1,3 @@
{
"back_to_home_6774a7": "Back to home"
}
+3
View File
@@ -0,0 +1,3 @@
{
"back_to_home_6774a7": "回到首页"
}
+24 -15
View File
@@ -4,7 +4,10 @@ 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 LocaleSelect from '@@/components/LocaleSelect/index.vue'
import { chooseAnonymousLocale, locale, t as translate } from '@@/i18n/runtime'
import { isCancel } from 'axios'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -18,11 +21,15 @@ const user = useUserStore()
const isSetup = computed(() => route.name === 'Setup')
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 rules = computed<FormRules<typeof fields>>(() => ({
password: [{ required: true, get message() {
return translate('login.enter_your_password_728a7b')
}, trigger: 'blur' }, { validator: (_rule, value: string, callback) => callback(isSetup.value && value.length < 8 ? new Error(translate('login.password_must_contain_at_least_8c1f11')) : undefined), trigger: 'blur' }],
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.password ? new Error(translate('login.passwords_do_not_match_588077')) : undefined), trigger: 'blur' }],
captchaCode: [{ pattern: /^\d{6}$/, required: true, get message() {
return translate('login.enter_the_six_digit_verification_d746b8')
}, trigger: 'blur' }],
}))
const captcha = ref<Captcha>()
const captchaError = ref('')
const error = ref('')
@@ -45,6 +52,7 @@ async function refresh() {
if (id === refreshID) refreshing.value = false
}
}
watch(locale, () => form.value?.clearValidate())
watch(isSetup, (setup) => {
++refreshID; captcha.value = undefined; error.value = ''; if (!setup) void refresh()
form.value?.clearValidate()
@@ -61,7 +69,7 @@ async function submit() {
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('登录响应格式错误')
if (typeof result.token !== 'string' || !result.token.startsWith('rms_') || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error(translate('login.invalid_login_response_b83520'))
user.setToken(result.token)
const version = user.getSessionVersion()
await user.getInfo()
@@ -83,25 +91,26 @@ async function submit() {
<div class="rm-auth">
<el-form ref="form" class="rm-auth-card" :model="fields" :rules="rules" label-position="top" :disabled="busy" @submit.prevent="submit">
<h1>RawMark</h1>
<LocaleSelect @change="chooseAnonymousLocale" />
<p class="rm-auth-hint">
{{ isSetup ? '首次使用,请设置登录密码。' : '登录你的 Markdown 知识库' }}
{{ isSetup ? translate('login.welcome_set_a_password_to_cb8457') : translate('login.sign_in_to_your_markdown_4bb381') }}
</p>
<el-form-item label="密码" prop="password">
<el-form-item :label="translate('login.password_a621ab')" 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-form-item v-if="isSetup" :label="translate('login.confirm_password_81b17b')" prop="confirm">
<el-input v-model="fields.confirm" type="password" autocomplete="new-password" show-password />
</el-form-item>
<template v-else>
<el-form-item label="验证码" prop="captchaCode">
<el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" placeholder="输入图片中的六位数字" />
<el-form-item :label="translate('login.verification_code_bb015c')" prop="captchaCode">
<el-input v-model="fields.captchaCode" autocomplete="off" inputmode="numeric" maxlength="6" :placeholder="translate('login.enter_the_six_digits_in_bb5543')" />
</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>
<el-button :disabled="refreshing || busy" class="rm-captcha-image" :aria-label="translate('login.refresh_verification_code_bc5b83')" @click="refresh">
<img v-if="captcha" :src="captcha.imageDataUrl" :alt="translate('login.login_verification_code_3060b7')"><span v-else>{{ refreshing ? translate('login.loading_verification_code_8a7493') : translate('login.click_to_reload_the_verification_97c218') }}</span>
</el-button>
<el-button text :disabled="refreshing || busy" @click="refresh">
换一张
{{ translate('login.refresh_code_fa28ea') }}
</el-button>
</div>
<p v-if="captchaError" class="rm-auth-error" role="alert">
@@ -112,7 +121,7 @@ async function submit() {
{{ error }}
</p>
<el-button type="primary" native-type="submit" :loading="busy" :disabled="!isSetup && (!captcha || refreshing)">
{{ isSetup ? '完成设置' : '登录' }}
{{ isSetup ? translate('login.finish_setup_fda1eb') : translate('common.sign_in_1e2df9') }}
</el-button>
</el-form>
</div>
+19
View File
@@ -0,0 +1,19 @@
{
"enter_your_password_728a7b": "Enter your password",
"password_must_contain_at_least_8c1f11": "Password must contain at least 8 characters",
"passwords_do_not_match_588077": "Passwords do not match",
"enter_the_six_digit_verification_d746b8": "Enter the six-digit verification code",
"invalid_login_response_b83520": "Invalid login response",
"welcome_set_a_password_to_cb8457": "Welcome! Set a password to get started.",
"sign_in_to_your_markdown_4bb381": "Sign in to your Markdown workspace",
"password_a621ab": "Password",
"confirm_password_81b17b": "Confirm password",
"verification_code_bb015c": "Verification code",
"enter_the_six_digits_in_bb5543": "Enter the six digits in the image",
"refresh_verification_code_bc5b83": "Refresh verification code",
"login_verification_code_3060b7": "Login verification code",
"loading_verification_code_8a7493": "Loading verification code…",
"click_to_reload_the_verification_97c218": "Click to reload the verification code",
"refresh_code_fa28ea": "Refresh code",
"finish_setup_fda1eb": "Finish setup"
}
+19
View File
@@ -0,0 +1,19 @@
{
"enter_your_password_728a7b": "请输入密码",
"password_must_contain_at_least_8c1f11": "密码至少 8 位",
"passwords_do_not_match_588077": "两次输入的密码不一致",
"enter_the_six_digit_verification_d746b8": "请输入六位数字验证码",
"invalid_login_response_b83520": "登录响应格式错误",
"welcome_set_a_password_to_cb8457": "首次使用,请设置登录密码。",
"sign_in_to_your_markdown_4bb381": "登录你的 Markdown 知识库",
"password_a621ab": "密码",
"confirm_password_81b17b": "确认密码",
"verification_code_bb015c": "验证码",
"enter_the_six_digits_in_bb5543": "输入图片中的六位数字",
"refresh_verification_code_bc5b83": "刷新验证码",
"login_verification_code_3060b7": "登录验证码",
"loading_verification_code_8a7493": "正在加载验证码…",
"click_to_reload_the_verification_97c218": "点击重新加载验证码",
"refresh_code_fa28ea": "换一张",
"finish_setup_fda1eb": "完成设置"
}
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { changePasswordApi } from '@@/apis/auth'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { reactive, ref } from 'vue'
@@ -14,9 +16,13 @@ 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' }],
current: [{ required: true, get message() {
return translate('settings.enter_your_current_password_f1790d')
}, trigger: 'blur' }],
next: [{ required: true, min: 8, get message() {
return translate('settings.new_password_must_contain_at_2f1f54')
}, trigger: 'blur' }],
confirm: [{ validator: (_rule, value: string, callback) => callback(value !== fields.next ? new Error(translate('settings.new_passwords_do_not_match_c5575a')) : undefined), trigger: 'blur' }],
}
const error = ref('')
const busy = ref(false)
@@ -30,7 +36,7 @@ async function submit() {
await changePasswordApi({ current: fields.current, next: fields.next })
Object.assign(fields, { current: '', next: '', confirm: '' })
form.value!.clearValidate()
toast('登录密码已修改,其他设备需要重新登录')
toast(translate('settings.password_changed_other_devices_must_04f0de'))
} catch (cause) {
if (!isCancel(cause)) error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
@@ -42,37 +48,37 @@ async function submit() {
<template>
<section id="rm-setting-password" tabindex="-1">
<h3 class="rm-settings-group-title">
修改登录密码
{{ translate('settings.change_password_81ecab') }}
</h3>
<p class="rm-share-note">
修改后其他设备需要重新登录。
{{ translate('settings.other_devices_must_sign_in_2c516e') }}
</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-form-item :label="translate('settings.current_password_a114cf')" 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-form-item :label="translate('settings.new_password_515e9c')" 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-form-item :label="translate('settings.confirm_new_password_6fde05')" 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">
修改密码
{{ translate('settings.change_password_08d008') }}
</el-button>
</el-form>
</section>
<section id="rm-setting-tokens" tabindex="-1">
<h3 class="rm-settings-group-title">
API 令牌
{{ translate('common.api_tokens_79db53') }}
</h3><TokensPanel />
</section>
<SettingRow id="logout">
<el-button @click="$emit('logout')">
<Icon name="logout" />退出
<Icon name="logout" />{{ translate('common.sign_out_498e1d') }}
</el-button>
</SettingRow>
</template>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { Theme } from '@@/apis/themes/type'
import type { CssSnippet } from '@@/appearance/appearance'
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 { t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
@@ -18,7 +20,9 @@ import SettingRow from './SettingRow.vue'
const { loadSettings, reloadTheme, settings, updateSettings } = useSettingsStore(pinia)
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
const themes = ref<Theme[]>([{ id: 'default', get name() {
return translate('common.default_844b8c')
}, version: '1' }])
const error = ref('')
const busy = ref(false)
const editing = ref<string | null>(null)
@@ -26,13 +30,17 @@ const draft = ref({ name: '', css: '' })
const missingTheme = computed(() => !themes.value.some(theme => theme.id === settings.theme))
const fixedMode = computed(() => fixedThemeMode(settings.theme))
function themeName(theme: Theme) {
return theme.id === 'default' ? translate('common.default_844b8c') : theme.id === 'white' || theme.id === 'black' ? translate(`settings.theme_${theme.id}`) : theme.name
}
async function refreshThemes() {
error.value = ''
try {
themes.value = (await getThemesApi()).data.themes
await reloadTheme()
} catch (err) {
error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络'
error.value = err instanceof ApiError ? err.message : translate('settings.could_not_load_themes_check_8e9224')
}
}
onMounted(refreshThemes)
@@ -47,9 +55,9 @@ async function change(patch: Partial<Settings>): Promise<boolean> {
if (err instanceof SettingsAppearanceError) {
toast(err.message); return true
}
toast(`保存外观失败:${err instanceof ApiError ? err.message : '网络错误,请检查连接'}`)
toast(translate('settings.could_not_save_appearance_70bfd0', { p0: err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064') }))
void loadSettings().catch((err) => {
if (!isCancel(err)) toast(`重新读取外观失败:${err instanceof ApiError ? err.message : '网络错误'}`)
if (!isCancel(err)) toast(translate('settings.could_not_reload_appearance_acc776', { p0: err instanceof ApiError ? err.message : translate('common.network_error') }))
})
return false
} finally {
@@ -84,7 +92,7 @@ async function remove(snippet: CssSnippet) {
if (busy.value) return
busy.value = true
try {
if (!await confirmAction(`删除 CSS 片段“${snippet.name}”?`)) return
if (!await confirmAction(translate('settings.delete_css_snippet_bb7d52', { p0: snippet.name }))) return
if (await change({ cssSnippets: settings.cssSnippets.filter(s => s.id !== snippet.id) })) {
if (editing.value === snippet.id) editing.value = null
}
@@ -97,37 +105,37 @@ async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS, cssSnippets: settings.cssSnippets.map(s => ({ ...s, enabled: false })) })) {
return
}
toast('已恢复默认外观与排版,CSS 片段已停用并保留')
toast(translate('settings.default_appearance_and_typography_restored_85037c'))
}
</script>
<template>
<p v-if="appearancePaused" class="rm-appearance-note">
已临时停用主题和 CSS。可恢复默认外观后重新打开页面。
{{ translate('settings.themes_and_css_are_temporarily_0c0bf0') }}
</p>
<SettingRow id="theme-select" control="rm-theme-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-option v-for="theme in themes" :key="theme.id" :value="theme.id" :label="themeName(theme)" />
<el-option v-if="missingTheme" :value="settings.theme" :label="translate('settings.unavailable_6145a5', { p0: settings.theme })" />
</el-select>
<el-button class="rm-btn" :disabled="busy" @click="refreshThemes">
刷新列表
{{ translate('settings.refresh_list_e28be7') }}
</el-button>
</SettingRow>
<SettingRow id="color-mode">
<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'] })" />
<el-segmented :model-value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" :options="[{ value: 'light', get label() { return translate('settings.light_aa0819') } }, { value: 'dark', get label() { return translate('settings.dark_a6b75d') } }, { value: 'system', get label() { return translate('settings.system_217cfe') } }]" :aria-label="translate('settings.color_mode_65260b')" @change="change({ colorMode: $event as Settings['colorMode'] })" />
</SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">
{{ error }}
</p>
<p v-if="fixedMode" class="rm-appearance-note">
白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。
{{ translate('settings.the_light_and_dark_themes_b3b0c9') }}
</p>
<details class="rm-settings-details rm-appearance-advanced">
<summary>自定义 CSS 与恢复</summary>
<summary>{{ translate('settings.custom_css_and_recovery_cc220b') }}</summary>
<SettingRow id="css-snippets">
<el-button class="rm-btn" :disabled="busy" @click="edit()">
新建片段
{{ translate('settings.new_snippet_b2808d') }}
</el-button>
</SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
@@ -135,30 +143,30 @@ async function reset() {
{{ snippet.name }}
</el-checkbox>
<el-button class="rm-btn" :disabled="busy" @click="edit(snippet)">
编辑
{{ translate('common.edit_051836') }}
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">
删除
{{ translate('common.delete_2f9daa') }}
</el-button>
</div>
<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>{{ translate('settings.snippet_name_5679e2') }}<el-input v-model="draft.name" :aria-label="translate('settings.snippet_name_5679e2')" 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">
保存并应用
{{ translate('settings.save_and_apply_a7ec01') }}
</el-button> <el-button class="rm-btn" native-type="button" @click="editing = null">
取消
{{ translate('common.cancel_2cd0f3') }}
</el-button>
</div>
</el-form>
<SettingRow id="reset-appearance">
<el-button class="rm-btn" :disabled="busy" @click="reset">
恢复默认外观与排版
{{ translate('settings.reset_appearance_and_typography_c73c09') }}
</el-button>
</SettingRow>
<p class="rm-appearance-note">
若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。
{{ translate('settings.if_custom_styles_make_the_aed659') }} <code>?reset-appearance</code>{{ translate('settings.to_the_url_to_temporarily_b75ce2') }}
</p>
</details>
</template>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import FontPicker from '@@/components/FontPicker/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { ref, watch } from 'vue'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change'
@@ -21,7 +23,7 @@ function changeFont(value: string) {
<template>
<h3 class="rm-settings-group-title">
正文与代码
{{ translate('settings.body_text_and_code_095337') }}
</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })">
<FontPicker id="rm-text-font" setting="textFont" />
@@ -37,18 +39,18 @@ function changeFont(value: string) {
</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) })" />
<el-segmented :model-value="settings.contentWidth" :options="CONTENT_WIDTHS" :aria-label="translate('settings.content_width_e92272')" @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>
<summary>{{ translate('settings.typography_preview_31ac73') }}</summary><article class="rm-typography-preview">
<h2>{{ translate('settings.give_your_words_room_to_f3720b') }}</h2><p>{{ translate('settings.connect_your_ideas_with_simple_d14ce8') }}</p><p>{{ translate('settings.real_content_natural_typography_and_9322bc') }}</p><code>const idea = 'Hello, RawMark'</code>
</article>
</details>
<h3 class="rm-settings-group-title">
编辑行为
{{ translate('settings.editing_behavior_870d78') }}
</h3>
<SettingRow id="selection-toolbar" control="rm-selection-toolbar">
<el-switch id="rm-selection-toolbar" :model-value="settings.selectionToolbar" @change="change({ selectionToolbar: Boolean($event) })" />
@@ -58,7 +60,7 @@ function changeFont(value: string) {
</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-option v-for="n in TAB_SIZES" :key="n" :value="n" :label="translate('settings.columns_dc30c8', { p0: n })" />
</el-select>
</SettingRow>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { UI_FONT_SIZE } from '@@/appearance/typography'
import FontPicker from '@@/components/FontPicker/index.vue'
import LocaleSelect from '@@/components/LocaleSelect/index.vue'
import { DEFAULTS, useSettingsStore } from '@/pinia/stores/settings'
import { useSettingsChange } from '../composables/use-settings-change'
import AppearanceSettings from './AppearanceSettings.vue'
@@ -11,6 +12,9 @@ const change = useSettingsChange()
</script>
<template>
<SettingRow id="locale" control="rm-locale">
<LocaleSelect id="rm-locale" @change="change({ locale: $event })" />
</SettingRow>
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })">
<FontPicker id="rm-ui-font" setting="uiFont" />
</SettingRow>
@@ -1,9 +1,11 @@
<script setup lang="ts">
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 { locale, t as translate } from '@@/i18n/runtime'
import { refreshPlugins } from '@@/plugins/host'
import { confirmAction } from '@@/utils/confirm'
@@ -38,7 +40,7 @@ onMounted(async () => {
try {
const status = (await getMaintenanceStatusApi()).data; if (mounted) last.value = status
} catch (cause) {
if (mounted) error.value = `读取维护状态失败:${String(cause)}`
if (mounted) error.value = translate('settings.could_not_load_maintenance_status_51d391', { p0: String(cause) })
}
})
async function prepare() {
@@ -75,16 +77,16 @@ async function download(response: AxiosResponse<Blob, unknown, AxiosHeaders>, fa
const url = URL.createObjectURL(blob)
const link = Object.assign(document.createElement('a'), { href: url, download: name })
link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
message.value = '导出文件已生成'
message.value = translate('settings.export_file_generated_9d8f6c')
}
async function exportPackage(kind: BackupKind) {
await perform('正在导出…', async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`))
await perform(translate('settings.exporting_62731d'), async () => download(await exportBackupApi(kind), `rawmark-${kind}.zip`))
}
async function exportMarkdown() {
await perform('正在导出笔记…', async () => download(await exportNotesApi(), 'rawmark-notes.zip'))
await perform(translate('settings.exporting_notes_015ed1'), async () => download(await exportNotesApi(), 'rawmark-notes.zip'))
}
async function exportSystemLogs() {
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
await perform(translate('settings.exporting_logs_2a89f7'), async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
}
async function syncPreferences(completed: string, version: number) {
try {
@@ -98,7 +100,7 @@ async function syncPreferences(completed: string, version: number) {
} catch (cause) {
user.assertSession(version)
if (isCancel(cause)) throw cause
throw new Error(`${completed},但同步失败:${cause instanceof Error ? cause.message : String(cause)}`, { cause })
throw new Error(translate('settings.but_synchronization_failed_54aebd', { p0: completed, p1: cause instanceof Error ? cause.message : String(cause) }), { cause })
}
message.value = completed
}
@@ -107,46 +109,46 @@ async function reset() {
busy.value = true
let confirmed = false
try {
confirmed = await confirmAction('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')
confirmed = await confirmAction(translate('settings.reset_all_rawmark_preferences_shortcuts_9ab27e'))
} finally {
busy.value = false
}
if (!confirmed) return
await perform('正在重置设置…', async (version) => {
await resetSettingsApi(); await syncPreferences('设置已重置,原设置已备份', version)
await perform(translate('settings.resetting_settings_39808b'), async (version) => {
await resetSettingsApi(); await syncPreferences(translate('settings.settings_reset_previous_settings_backed_f4a4fa'), version)
})
}
async function restore(kind: BackupKind, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]; input.value = ''
if (!file) return
await perform('正在上传…', async () => {
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error('备份压缩大小须在 0–2 GiB 之间')
await perform(translate('settings.uploading_6148b3'), async () => {
if (file.size <= 0 || file.size > 2 * 1024 ** 3) throw new Error(translate('settings.the_compressed_backup_must_be_cdce88'))
const version = user.getSessionVersion()
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('已取消上传')
if (!mounted) throw new Error(translate('settings.upload_canceled_fb2c92'))
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
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)}%`
if (result.offset !== offset + chunk.size) throw new Error(translate('settings.the_server_returned_inconsistent_upload_dd61f8'))
offset = result.offset; progress.value = translate('settings.uploading_38466c', { p0: Math.round(offset / file.size * 100) })
}
progress.value = '正在校验备份…'
progress.value = translate('settings.validating_backup_7a1f32')
const summary = (await validateBackupApi(id)).data
if (!mounted) throw new Error('已取消导入')
if (!mounted) throw new Error(translate('settings.import_canceled_08d7a9'))
const description = kind === 'full'
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
if (!await confirmAction(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
? translate('settings.all_instance_data_will_be_4b1eb6')
: translate('settings.all_preferences_and_plugin_configuration_11b0e4')
if (!await confirmAction(translate('settings.backup_date_entries_expanded_size_b6755e', { p0: new Date(summary.createdAt).toLocaleString(locale.value), p1: summary.entries, p2: (summary.size / 1024 ** 2).toFixed(2), p3: description }))) return
await prepare()
user.assertSession(version)
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
progress.value = kind === 'full' ? translate('settings.restoring_instance_keep_this_page_405c69') : translate('settings.importing_settings_078500')
if (kind === 'settings') {
await importSettingsApi(id); submitted = true
await syncPreferences('设置与资源已导入,原设置已备份', version)
await syncPreferences(translate('settings.settings_and_resources_imported_previous_f96d82'), version)
} else {
await restoreBackupApi(id); submitted = true
user.invalidate(version)
@@ -160,29 +162,29 @@ async function restore(kind: BackupKind, event: Event) {
}
} catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
}
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
throw new Error(translate('settings.restore_request_submitted_the_service_b1d2c2'))
}
} finally {
if (!submitted && version === user.getSessionVersion()) {
try {
await cancelBackupUploadApi(id)
} catch (cause) {
if (mounted) error.value = `清理上传暂存失败:${String(cause)}`
if (mounted) error.value = translate('settings.could_not_clean_up_the_ca0ac3', { p0: String(cause) })
}
}
}
})
}
async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
await maintainIndexApi(operation); message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
await perform(operation === 'optimize' ? translate('settings.optimizing_index_0aabbf') : translate('settings.rebuilding_index_ae8fea'), async () => {
await maintainIndexApi(operation); message.value = operation === 'optimize' ? translate('settings.index_optimized_dfe862') : translate('settings.index_rebuilt_d1763b')
})
}
async function clearTemp() {
await perform('正在清理临时文件…', async () => {
await perform(translate('settings.cleaning_temporary_files_a62be6'), async () => {
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')}`
message.value = translate('settings.removed_files_freed_mib_027d71', { p0: result.files, p1: (result.bytes / 1024 ** 2).toFixed(2) })
if (result.failed.length) error.value = translate('settings.some_files_could_not_be_628a24', { p0: result.failed.join('\n') })
})
}
function importNotes() {
@@ -193,55 +195,55 @@ function importNotes() {
<template>
<div class="rm-maintenance" :aria-busy="busy">
<h3 class="rm-settings-group-title">
备份与迁移
{{ translate('settings.backup_and_migration_97dce0') }}
</h3>
<SettingRow id="backup-full">
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('full')">
导出
{{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="fullInput!.click()">
恢复
{{ translate('common.restore_e0534b') }}
</el-button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)">
</SettingRow>
<SettingRow id="backup-notes">
<el-button class="rm-btn" :disabled="busy" @click="exportMarkdown">
导出
{{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="importNotes">
导入
{{ translate('common.import_576d81') }}
</el-button>
</SettingRow>
<SettingRow id="backup-settings">
<el-button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">
导出
{{ translate('settings.export_764204') }}
</el-button><el-button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">
导入
{{ translate('common.import_576d81') }}
</el-button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)">
</SettingRow>
<h3 class="rm-settings-group-title">
维护工具
{{ translate('settings.maintenance_tools_051093') }}
</h3>
<SettingRow id="index-optimize">
<el-button class="rm-btn" :disabled="busy" @click="index('optimize')">
优化
{{ translate('settings.optimize_e1d83f') }}
</el-button>
</SettingRow>
<SettingRow id="index-rebuild">
<el-button class="rm-btn" :disabled="busy" @click="index('rebuild')">
重建
{{ translate('settings.rebuild_4b8d9d') }}
</el-button>
</SettingRow>
<SettingRow id="temp-clear">
<el-button class="rm-btn" :disabled="busy" @click="clearTemp">
清理
{{ translate('settings.clean_up_9d2998') }}
</el-button>
</SettingRow>
<SettingRow id="settings-reset">
<el-button class="rm-btn" :disabled="busy" @click="reset">
重置
{{ translate('settings.reset_cb5d68') }}
</el-button>
</SettingRow>
<SettingRow id="logs-export">
<el-button class="rm-btn" :disabled="busy" @click="exportSystemLogs">
导出
{{ translate('settings.export_764204') }}
</el-button>
</SettingRow>
<p v-if="busy" role="status" aria-live="polite">
@@ -254,10 +256,10 @@ function importNotes() {
{{ error }}
</p>
<p v-if="last?.error" class="rm-auth-error" role="alert">
上次维护失败:{{ last.error }}
{{ translate('settings.last_maintenance_failed_283226') }}{{ last.error }}
</p>
<p v-if="last?.backup" class="rm-share-note">
操作前备份:backups/{{ last.backup }}
{{ translate('settings.pre_operation_backup_backups_b966a1') }}{{ last.backup }}
</p>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { InstalledPlugin } from '@@/apis/plugins/type'
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import { installPluginApi, togglePluginApi, uninstallPluginApi } from '@@/apis/plugins'
import PluginMount from '@@/components/PluginMount/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { pluginState, refreshPlugins, retryPlugin } from '@@/plugins/host'
import { pluginsPaused, settingTabs } from '@@/plugins/registry'
import { confirmAction } from '@@/utils/confirm'
@@ -15,7 +17,17 @@ const error = ref('')
const fileInput = ref<HTMLInputElement>()
const selected = ref('')
const currentTab = computed(() => settingTabs.get(selected.value))
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
const labels: Record<string, string> = { get loading() {
return translate('settings.loading_d04fcb')
}, get active() {
return translate('settings.running_1f0eb9')
}, get error() {
return translate('settings.disabled_after_an_error_e8b7b5')
}, get disabled() {
return translate('settings.disabled_a8c369')
}, get paused() {
return translate('settings.temporarily_skipped_9f45ce')
} }
async function perform(operation: () => Promise<unknown> = refreshPlugins, confirmation?: string) {
if (busy.value) return
@@ -39,13 +51,13 @@ async function install(event: Event) {
if (!file) return
await perform(async () => {
await installPluginApi(file); await refreshPlugins()
}, '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
}, translate('settings.plugins_have_full_access_to_7743b6'))
}
async function remove(plugin: InstalledPlugin) {
await perform(async () => {
await uninstallPluginApi(plugin.id); await refreshPlugins()
}, `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
}, translate('settings.uninstall_plugin_the_plugin_will_6aa869', { p0: plugin.name }))
}
function toggle(plugin: InstalledPlugin) {
@@ -58,60 +70,60 @@ onMounted(() => perform())
<template>
<p v-if="pluginsPaused" class="rm-appearance-note">
已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。
{{ translate('settings.external_plugins_are_temporarily_skipped_f85ce0') }}
</p>
<div class="rm-setting">
<span class="rm-setting-label">已安装插件</span>
<span class="rm-setting-label">{{ translate('settings.installed_plugins_e476c3') }}</span>
<el-button class="rm-btn" :disabled="busy" @click="perform()">
刷新列表
{{ translate('settings.refresh_list_e28be7') }}
</el-button>
<el-button class="rm-btn" :disabled="busy" @click="fileInput?.click()">
安装 ZIP
{{ translate('settings.install_zip_4f1723') }}
</el-button>
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install">
</div>
<p class="rm-appearance-note">
插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。
{{ translate('settings.plugins_have_full_access_to_0cee40') }}
</p>
<p v-if="error || pluginState.error" class="rm-appearance-note rm-auth-error">
{{ error || pluginState.error }}
</p>
<p v-if="!pluginState.installed.length && !pluginState.error" class="rm-appearance-note">
尚未安装插件。
{{ translate('settings.no_plugins_installed_eaaa06') }}
</p>
<el-table :data="pluginState.installed" row-key="id" empty-text="尚未安装插件" :aria-busy="busy">
<el-table :data="pluginState.installed" row-key="id" :empty-text="translate('settings.no_plugins_installed_9d17e5')" :aria-busy="busy">
<!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="插件" min-width="170">
<el-table-column :label="translate('settings.plugins_e806fb')" 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">
<strong>{{ plugin.name }}</strong><small class="rm-plugin-version">{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? translate('settings.waiting_to_load_e1cfcb') : translate('settings.disabled_a8c369')) }}</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">
<el-table-column :label="translate('settings.enable_f4f0ea')" width="72">
<template #default="{ row: plugin }">
<el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="`启用 ${plugin.name}`" @change="toggle(plugin)" />
<el-switch :model-value="plugin.enabled" :disabled="busy" :aria-label="translate('settings.enable_2a670f', { p0: plugin.name })" @change="toggle(plugin)" />
</template>
</el-table-column>
<!-- @vue-generic {InstalledPlugin} -->
<el-table-column label="操作" width="130">
<el-table-column :label="translate('settings.actions_ed31fb')" width="130">
<template #default="{ row: plugin }">
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="perform(() => retryPlugin(plugin.id))">
重试
{{ translate('common.retry_b8784c') }}
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
卸载
{{ translate('settings.uninstall_06bc14') }}
</el-button>
</template>
</el-table-column>
</el-table>
<p class="rm-appearance-note">
若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。
{{ translate('settings.if_a_plugin_makes_the_46626f') }} <code>?disable-plugins</code> {{ translate('settings.to_the_app_url_to_db1658') }}
</p>
<template v-if="settingTabs.size">
<div class="rm-setting">
<label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
<el-select id="rm-plugin-tab" v-model="selected" clearable placeholder="选择插件设置页">
<label for="rm-plugin-tab" class="rm-setting-label">{{ translate('settings.plugin_settings_b1bc55') }}</label>
<el-select id="rm-plugin-tab" v-model="selected" clearable :placeholder="translate('settings.choose_a_plugin_settings_page_7171ad')">
<el-option v-for="[id, tab] in settingTabs" :key="id" :value="id" :label="tab.title" />
</el-select>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { SettingId } from '../settings-catalog'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { computed } from 'vue'
import { SETTING_ITEMS } from '../settings-catalog'
@@ -19,7 +21,7 @@ const item = computed(() => SETTING_ITEMS[props.id])
</div>
<div class="rm-setting-control">
<slot />
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="`重置${item.title}`" :aria-label="`重置${item.title}`" @click="$emit('reset')">
<button v-if="resettable" class="rm-icon-btn rm-setting-reset" :title="translate('settings.reset_6b6fa6', { p0: item.title })" :aria-label="translate('settings.reset_6b6fa6', { p0: item.title })" @click="$emit('reset')">
<Icon name="restore" />
</button>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { Command } from '@@/utils/commands'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { allCommands, commandKeys, formatKeys, shortcutConflict } from '@@/utils/commands'
import { recordedKeys } from '@@/utils/keys'
import { toast } from '@@/utils/notifications'
@@ -35,8 +37,8 @@ async function save(shortcuts: Record<string, string[]>) {
if (cause instanceof SettingsAppearanceError) {
toast(cause.message); return
}
toast(`保存快捷键失败:${cause instanceof Error ? cause.message : String(cause)}`)
await loadSettings().catch(cause => toast(`重新读取设置失败:${String(cause)}`))
toast(translate('settings.could_not_save_shortcuts_d58683', { p0: cause instanceof Error ? cause.message : String(cause) }))
await loadSettings().catch(cause => toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(cause) })))
} finally {
busy.value = false
}
@@ -57,7 +59,7 @@ async function capture(event: KeyboardEvent, command: Command) {
if (!key) return
const conflict = shortcutConflict(command.id, [key])
if (conflict) {
error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return
error.value = translate('settings.conflicts_with_choose_a_different_b5848b', { p0: conflict.title }); return
}
const keys = [...new Set([...commandKeys(command), key])]
recording.value = null
@@ -67,7 +69,7 @@ async function capture(event: KeyboardEvent, command: Command) {
async function reset(command: Command) {
const conflict = shortcutConflict(command.id, command.keys ?? [])
if (conflict) {
error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return
error.value = translate('settings.the_default_shortcut_conflicts_with_a89537', { p0: conflict.title }); return
}
const next = { ...settings.shortcuts }
delete next[command.id]
@@ -79,18 +81,18 @@ async function reset(command: Command) {
<template>
<div class="rm-shortcut-settings">
<div class="rm-shortcut-toolbar">
<el-input v-model="query" type="search" clearable 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="translate('settings.search_shortcuts_49504f')" :placeholder="translate('settings.search_commands_or_shortcuts_890754')" /><el-button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">
{{ translate('settings.reset_all_shortcuts_1853c7') }}
</el-button>
</div>
<p class="rm-share-note">
点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。
{{ translate('settings.click_add_then_press_a_cf4a60') }}
</p>
<p class="rm-share-note">
浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。
{{ translate('settings.browsers_may_reserve_ctrl_cmd_8d4bc8') }}
</p>
<p class="rm-settings-result-count" role="status">
{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}
{{ filteredCommands.length }} / {{ commands.length }} {{ translate('settings.commands_6aa9ce') }}{{ filteredCommands.length ? '' : translate('settings.no_matching_shortcuts_7e3955') }}
</p>
<p v-if="error" role="alert" class="rm-auth-error">
{{ error }}
@@ -100,14 +102,14 @@ async function reset(command: Command) {
<div class="rm-shortcut-bindings">
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
<kbd>{{ formatKeys(key) }}</kbd>
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
<button class="rm-icon-btn" :disabled="busy" :aria-label="translate('settings.remove_from_6909d6', { p0: command.title, p1: formatKeys(key) })" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="16" /></button>
</span>
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null">
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly :aria-label="translate('settings.press_a_shortcut_662b79')" :placeholder="translate('settings.press_a_shortcut_652436')" @keydown.prevent.stop="capture($event, command)" @blur="recording = null">
<el-button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">
添加
{{ translate('settings.add_7a8a11') }}
</el-button>
<el-button class="rm-btn" :disabled="busy" @click="reset(command)">
重置
{{ translate('settings.reset_cb5d68') }}
</el-button>
</div>
</div>
@@ -2,6 +2,8 @@
import type { StorageUsage } from '@@/apis/storage/type'
import { getStorageApi } from '@@/apis/storage'
import { locale, t as translate } from '@@/i18n/runtime'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import MaintenancePanel from './MaintenancePanel.vue'
@@ -18,7 +20,19 @@ onBeforeUnmount(() => {
mounted = false
})
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
const names: Record<StorageUsage['categories'][number]['id'], string> = { get vault() {
return translate('settings.vault_7e16a8')
}, get databases() {
return translate('settings.databases_263200')
}, get fonts() {
return translate('common.font_078838')
}, get themes() {
return translate('settings.themes_788db1')
}, get plugins() {
return translate('settings.plugins_e806fb')
}, get other() {
return translate('settings.other_d2909f')
} }
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
const segments = computed(() => {
let offset = 0
@@ -33,9 +47,9 @@ const segments = computed(() => {
function size(value: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
const unit = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0
return `${(value / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
return `${(value / 1024 ** unit).toLocaleString(locale.value, { maximumFractionDigits: unit ? 2 : 0 })} ${units[unit]}`
}
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
const updated = computed(() => usage.value ? new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
async function scan() {
if (busy.value) return
busy.value = true
@@ -44,7 +58,7 @@ async function scan() {
const next = (await getStorageApi()).data
if (mounted) usage.value = next
} catch (cause) {
if (mounted) error.value = `统计失败:${String(cause)}`
if (mounted) error.value = translate('settings.could_not_calculate_storage_usage_6db82c', { p0: String(cause) })
} finally {
if (mounted) busy.value = false
}
@@ -55,50 +69,50 @@ onMounted(scan)
<template>
<div>
<h3 class="rm-settings-group-title">
空间占用
{{ translate('settings.storage_usage_37ac17') }}
</h3>
<SettingRow id="storage">
<el-button class="rm-btn" :disabled="busy" @click="scan">
{{ busy ? '统计中…' : '刷新' }}
{{ busy ? translate('settings.calculating_884dbb') : translate('settings.refresh_aee887') }}
</el-button>
</SettingRow>
<p class="rm-share-note">
统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。
{{ translate('settings.includes_application_data_and_optional_95f18a') }}
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
</p>
<div v-if="usage" class="rm-storage" :aria-busy="busy">
<div class="rm-storage-summary">
<svg viewBox="0 0 120 120" role="img" aria-label="各类存储空间占比">
<svg viewBox="0 0 120 120" role="img" :aria-label="translate('settings.storage_usage_by_category_614032')">
<circle cx="60" cy="60" r="46" fill="none" stroke="var(--rm-divider)" stroke-width="14" />
<circle v-for="segment in segments" :key="segment.id" cx="60" cy="60" r="46" pathLength="100" fill="none" :stroke="segment.color" stroke-width="14" :stroke-dasharray="`${segment.percent} ${100 - segment.percent}`" :stroke-dashoffset="-segment.offset" transform="rotate(-90 60 60)" />
</svg>
<div><span>总占用</span><strong :title="`${usage.totalSize} 字节`">{{ size(usage.totalSize) }}</strong><small>统计时间:{{ updated }}</small></div>
<div><span>{{ translate('settings.total_usage_d3d98e') }}</span><strong :title="translate('settings.bytes_8180a4', { p0: usage.totalSize })">{{ size(usage.totalSize) }}</strong><small>{{ translate('settings.calculated_239bdc') }}{{ updated }}</small></div>
</div>
<dl class="rm-storage-list">
<template v-for="segment in segments" :key="segment.id">
<div>
<dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="`${segment.size} 字节`">
<dt><i :style="{ background: segment.color }" />{{ names[segment.id] }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: segment.size })">
{{ size(segment.size) }} <small>{{ segment.percent.toFixed(1) }}%</small>
</dd>
</div>
<template v-if="segment.id === 'fonts'">
<div class="rm-storage-sub">
<dt>内置可选字体</dt><dd>{{ size(usage.builtinFontsSize) }}</dd>
<dt>{{ translate('settings.optional_built_in_fonts_05b309') }}</dt><dd>{{ size(usage.builtinFontsSize) }}</dd>
</div>
<div class="rm-storage-sub">
<dt>导入字体</dt><dd>{{ size(usage.customFontsSize) }}</dd>
<dt>{{ translate('common.import_font_50751a') }}</dt><dd>{{ size(usage.customFontsSize) }}</dd>
</div>
</template>
<template v-if="segment.id === 'vault'">
<div class="rm-storage-sub">
<dt>附件</dt><dd :title="`${usage.assetsSize} 字节`">
<dt>{{ translate('settings.attachments_c7225d') }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: usage.assetsSize })">
{{ size(usage.assetsSize) }} <small>{{ (usage.totalSize ? usage.assetsSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
</dd>
</div>
<div class="rm-storage-sub">
<dt>回收站</dt><dd :title="`${usage.trashSize} 字节`">
<dt>{{ translate('common.trash_ba35dc') }}</dt><dd :title="translate('settings.bytes_8180a4', { p0: usage.trashSize })">
{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small>
</dd>
</div>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ApiToken } from '@@/apis/tokens/type'
import type { FormInstance, FormRules } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { createTokenApi, deleteTokenApi, getTokensApi } from '@@/apis/tokens'
import { locale, t as translate } from '@@/i18n/runtime'
import { copyText } from '@@/utils/clipboard'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
@@ -16,15 +18,17 @@ 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 rules: FormRules<typeof fields> = { name: [{ required: true, whitespace: true, get message() {
return translate('settings.enter_a_token_name_3d7107')
}, trigger: 'blur' }] }
const user = useUserStore()
const busy = ref(false)
// 刚创建的令牌明文,只在本次对话框中显示
const created = ref<string | null>(null)
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
function errorText(err: unknown) {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064')
}
async function load() {
@@ -33,7 +37,7 @@ async function load() {
try {
tokens.value = (await getTokensApi()).data.tokens
} catch (cause) {
if (!isCancel(cause)) error.value = `读取 API 令牌失败:${errorText(cause)}`
if (!isCancel(cause)) error.value = translate('settings.could_not_load_api_tokens_98ef9a', { p0: errorText(cause) })
} finally {
loading.value = false
}
@@ -52,26 +56,26 @@ async function create() {
fields.name = ''; form.value!.clearValidate()
} catch (err) {
if (isCancel(err)) return
toast(`创建失败:${errorText(err)}`)
toast(translate('settings.could_not_create_token_c6c409', { p0: errorText(err) }))
} finally {
busy.value = false
}
}
async function copy() {
toast((await copyText(created.value!)) ? '令牌已复制' : '复制失败,请手动选中复制')
toast((await copyText(created.value!)) ? translate('settings.token_copied_6cca3e') : translate('settings.copy_failed_select_and_copy_cbc861'))
}
async function revoke(t: ApiToken) {
if (busy.value) return
busy.value = true
try {
if (!await confirmAction(`吊销“${t.name}”?使用它的脚本将立即无法访问。`)) return
if (!await confirmAction(translate('settings.revoke_scripts_using_this_token_f32cc1', { p0: 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)}`)
toast(translate('settings.could_not_revoke_token_8976da', { p0: errorText(err) }))
} finally {
busy.value = false
}
@@ -83,57 +87,57 @@ onMounted(load)
<template>
<div>
<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 :label="translate('common.name_d44e9b')" prop="name">
<el-input v-model="fields.name" autocomplete="off" :placeholder="translate('settings.e_g_backup_script_da8956')" 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-form-item :label="translate('settings.permissions_978cbc')">
<el-select v-model="fields.scope" :aria-label="translate('settings.permissions_978cbc')">
<el-option value="read" :label="translate('settings.read_only_3b5ec3')" /><el-option value="write" :label="translate('settings.read_and_write_d5c332')" />
</el-select>
</el-form-item>
<el-button class="rm-btn rm-btn-primary" native-type="submit" :disabled="busy">
创建
{{ translate('settings.create_cde2cd') }}
</el-button>
</el-form>
<div v-if="created" class="rm-token-created">
<p>令牌只显示这一次,请立即保存:</p>
<p>{{ translate('settings.this_token_is_shown_only_88a1ba') }}</p>
<div class="rm-token-value">
<code>{{ created }}</code>
<el-button class="rm-btn" @click="copy">
复制
{{ translate('common.copy_63d90d') }}
</el-button>
</div>
</div>
<p class="rm-share-note">
调用时带上请求头 <code>Authorization: Bearer 令牌</code>;只读令牌只能读取。接口说明见
{{ translate('settings.send_the_request_header_02d3d2') }} <code>{{ translate('settings.authorization_bearer_token_3abd9b') }}</code>{{ translate('settings.read_only_tokens_can_only_38418a') }}
<a href="/api/v1/openapi.json" target="_blank" rel="noopener">openapi.json</a>。
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }} <el-button text :disabled="loading" @click="load">
重试
{{ translate('common.retry_b8784c') }}
</el-button>
</p>
<p v-if="loading" role="status">
读取中…
{{ translate('settings.loading_346ff6') }}
</p>
<el-table v-else-if="tokens" :data="tokens" row-key="id" empty-text="还没有 API 令牌">
<el-table v-else-if="tokens" :data="tokens" row-key="id" :empty-text="translate('settings.no_api_tokens_yet_6eae29')">
<!-- @vue-generic {ApiToken} -->
<el-table-column label="名称" min-width="140">
<el-table-column :label="translate('common.name_d44e9b')" 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>
<strong>{{ token.name }}</strong><small class="rm-token-time">{{ fmt.format(token.createdAt) }} {{ translate('settings.created_3567a7') }} {{ token.lastUsedAt ? translate('settings.last_used_b78fad', { p0: fmt.format(token.lastUsedAt) }) : translate('settings.never_used_458529') }}</small>
</template>
</el-table-column>
<!-- @vue-generic {ApiToken} -->
<el-table-column label="权限" width="70">
<el-table-column :label="translate('settings.permissions_978cbc')" width="70">
<template #default="{ row: token }">
{{ token.scope === 'write' ? '读写' : '只读' }}
{{ token.scope === 'write' ? translate('settings.read_and_write_d5c332') : translate('settings.read_only_3b5ec3') }}
</template>
</el-table-column>
<!-- @vue-generic {ApiToken} -->
<el-table-column label="操作" width="70">
<el-table-column :label="translate('settings.actions_ed31fb')" width="70">
<template #default="{ row: token }">
<el-button text type="danger" :disabled="busy" @click="revoke(token)">
吊销
{{ translate('settings.revoke_d8c68b') }}
</el-button>
</template>
</el-table-column>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { TrashItem } from '@@/apis/trash/type'
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
import { clearTrashApi, getTrashApi, purgeTrashApi } from '@@/apis/trash'
import Icon from '@@/components/Icon/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
import { isImage } from '@@/utils/paths'
@@ -35,7 +37,7 @@ async function load() {
items.value = (await getTrashApi()).data.items
error.value = ''
} catch (cause) {
error.value = `读取回收站失败:${String(cause)}`
error.value = translate('settings.could_not_load_trash_0d559f', { p0: String(cause) })
}
}
@@ -43,7 +45,7 @@ async function retention(value: string) {
const days = value.trim() ? Number(value) : NaN
if (!Number.isSafeInteger(days) || days < 0) {
retentionInput.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除')
toast(translate('settings.enter_a_non_negative_number_aff4a0'))
return
}
if (days === settings.trashRetentionDays) return
@@ -52,10 +54,10 @@ async function retention(value: string) {
await updateSettings({ trashRetentionDays: days })
} catch (cause) {
if (isCancel(cause)) return
toast(`保存自动删除期限失败:${String(cause)}`)
toast(translate('settings.could_not_save_trash_retention_db584b', { p0: String(cause) }))
if (!(cause instanceof SettingsAppearanceError)) {
await loadSettings().catch((cause) => {
if (!isCancel(cause)) toast(`重新读取设置失败:${String(cause)}`)
if (!isCancel(cause)) toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(cause) }))
})
}
} finally {
@@ -80,7 +82,7 @@ async function purge(item?: TrashItem) {
failures.value = []
let attempted = false
try {
if (!await confirmAction(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
if (!await confirmAction(item ? translate('settings.permanently_delete_this_cannot_be_8a3d3f', { p0: item.path }) : translate('settings.empty_all_trash_this_cannot_cf818b'))) return
user.assertSession(version)
attempted = true
if (item) {
@@ -88,34 +90,36 @@ async function purge(item?: TrashItem) {
} else {
const result = (await clearTrashApi()).data
failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
toast(result.failed.length
? translate('settings.permanently_deleted_with_failures', { count: result.deleted.length, failed: result.failed.length })
: translate('settings.permanently_deleted_items_4712cc', { count: result.deleted.length }))
}
} catch (cause) {
if (isCancel(cause)) return
toast(`删除失败:${String(cause)}`)
toast(translate('common.could_not_delete_9619d7', { p0: String(cause) }))
} finally {
if (attempted && version === user.getSessionVersion()) await load()
busy.value = false
}
}
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
const fmt = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'short', timeStyle: 'short' }))
onMounted(load)
</script>
<template>
<div>
<SettingRow id="trash-retention" control="rm-trash-retention">
<el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" aria-label="自动删除天数" @change="retention" /><span>天</span>
<el-input id="rm-trash-retention" v-model="retentionInput" type="number" min="0" step="1" :disabled="saving" :aria-label="translate('settings.trash_retention_days_f4ee2a')" @change="retention" /><span>{{ translate('settings.days_49da61') }}</span>
</SettingRow>
<SettingRow id="trash-clear">
<el-button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">
清空全部
{{ translate('settings.empty_trash_1b5909') }}
</el-button>
</SettingRow>
<p v-if="error" role="alert" class="rm-auth-error">
{{ error }} <el-button class="rm-btn" :disabled="busy" @click="load">
重试
{{ translate('common.retry_b8784c') }}
</el-button>
</p>
<ul v-if="failures.length" role="alert">
@@ -124,14 +128,14 @@ onMounted(load)
</li>
</ul>
<p v-if="items && !items.length" class="rm-tree-empty">
回收站是空的
{{ translate('settings.trash_is_empty_9c6705') }}
</p>
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">
读取中…
{{ translate('settings.loading_346ff6') }}
</p>
<el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" empty-text="回收站是空的">
<el-table v-else :data="items ?? []" row-key="id" :aria-busy="busy" :empty-text="translate('settings.trash_is_empty_9c6705')">
<!-- @vue-generic {TrashItem} -->
<el-table-column label="项目" min-width="170">
<el-table-column :label="translate('settings.item_79f326')" 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>
@@ -139,12 +143,12 @@ onMounted(load)
</template>
</el-table-column>
<!-- @vue-generic {TrashItem} -->
<el-table-column label="操作" width="160">
<el-table-column :label="translate('settings.actions_ed31fb')" width="160">
<template #default="{ row: item }">
<el-button text :disabled="busy" @click="doRestore(item)">
恢复
{{ translate('common.restore_e0534b') }}
</el-button><el-button text type="danger" :disabled="busy" @click="purge(item)">
彻底删除
{{ translate('settings.delete_permanently_7aa6d0') }}
</el-button>
</template>
</el-table-column>
@@ -1,4 +1,5 @@
import type { Settings } from '@/pinia/stores/settings'
import { t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { isCancel } from 'axios'
import { SettingsAppearanceError, useSettingsStore } from '@/pinia/stores/settings'
@@ -11,12 +12,12 @@ export function useSettingsChange() {
return true
} catch (cause) {
if (isCancel(cause)) return false
toast(cause instanceof Error ? cause.message : '保存设置失败')
toast(cause instanceof Error ? cause.message : translate('settings.could_not_save_settings_17b49d'))
if (cause instanceof SettingsAppearanceError) return true
try {
await store.loadSettings()
} catch (reload) {
if (!isCancel(reload)) toast(`重新读取设置失败:${String(reload)}`)
if (!isCancel(reload)) toast(translate('settings.could_not_reload_settings_744b6a', { p0: String(reload) }))
}
return false
}
@@ -48,7 +48,7 @@ it('手动插件刷新只请求一次,失败在当前面板保留原因', asyn
wrapper = mount(PluginsPanel, { global })
await flushPromises()
vi.mocked(plugins.refreshPlugins).mockClear().mockRejectedValueOnce(new Error('插件文件读取失败'))
await wrapper.findAll('button').find(item => item.text() === '刷新列表')!.trigger('click')
await wrapper.findAll('button').find(item => item.text() === 'Refresh list')!.trigger('click')
await flushPromises()
expect(plugins.refreshPlugins).toHaveBeenCalledOnce()
expect(wrapper.findAll('.rm-auth-error')).toHaveLength(1)
@@ -64,7 +64,7 @@ it('重置已成功但插件同步失败时,说明已完成的操作,不显
await wrapper.get('#rm-setting-settings-reset button').trigger('click')
await flushPromises()
expect(reset).toHaveBeenCalledOnce()
expect(wrapper.get('[role="alert"]').text()).toContain('设置已重置,原设置已备份,但同步失败:插件服务暂时不可用')
expect(wrapper.get('[role="alert"]').text()).toContain('Settings reset. Previous settings backed up, but synchronization failed: 插件服务暂时不可用')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
@@ -72,7 +72,7 @@ it('设置重置后的同步被会话切换取消时不提示失败', async () =
vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
await flushPromises()
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new CanceledError('会话已变化'))
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new CanceledError('Session changed'))
await wrapper.get('#rm-setting-settings-reset button').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
@@ -108,7 +108,7 @@ it('设置导入已提交但同步失败时保留完成信息,不取消已提
await input.trigger('change')
await flushPromises()
expect(imported).toHaveBeenCalledExactlyOnceWith('settings-upload')
expect(wrapper.get('[role="alert"]').text()).toContain('设置与资源已导入,原设置已备份,但同步失败:插件列表读取失败')
expect(wrapper.get('[role="alert"]').text()).toContain('Settings and resources imported. Previous settings backed up, but synchronization failed: 插件列表读取失败')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(cancel).not.toHaveBeenCalled()
})
+19 -17
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { SettingSearchResult } from './settings-catalog'
import type { SettingsSection } from '@/pinia/stores/settings'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { runCommand } from '@@/utils/commands'
import { installApp, installPrompt } from '@@/utils/pwa'
import { computed, nextTick, ref } from 'vue'
@@ -19,8 +21,8 @@ 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 buildVersion = import.meta.env.DEV ? translate('settings.development_0d1c11') : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error(translate('settings.application_build_information_is_missing_6b04f9'))
const model = defineModel<SettingsSection | 'index' | null>({ required: true })
const section = computed(() => model.value === 'index' || model.value === null ? 'appearance' : model.value)
const showList = ref(model.value === 'index')
@@ -31,7 +33,7 @@ const results = computed(() => searchSettings(query.value))
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
const saveLabel = computed(() => settingsSave.pending ? translate('settings.saving_ff509c') : settingsSave.result === 'error' ? translate('settings.save_failed_6309a3') : settingsSave.result === 'saved' ? translate('settings.saved_1bd91a') : translate('settings.changes_save_automatically_b88990'))
async function open(id: SettingsSection) {
query.value = ''; model.value = id; showList.value = false
await nextTick()
@@ -58,16 +60,16 @@ function close() {
</script>
<template>
<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">
<el-dialog :model-value="model !== null" class="rm-settings-modal" :title="translate('common.settings_df3d58')" :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="设置分类">
<nav class="rm-settings-nav" :aria-label="translate('settings.settings_categories_dfb699')">
<div class="rm-dialog-head">
<h2>设置</h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close">
<h2>{{ translate('common.settings_df3d58') }}</h2>
<button class="rm-icon-btn rm-settings-close-list" :title="translate('settings.close_settings_77b17f')" @click="close">
<Icon name="close" />
</button>
</div>
<el-input v-model="query" class="rm-settings-search" type="search" clearable aria-label="搜索设置" placeholder="搜索设置">
<el-input v-model="query" class="rm-settings-search" type="search" clearable :aria-label="translate('settings.search_settings_287415')" :placeholder="translate('settings.search_settings_287415')">
<template #prefix>
<Icon name="search" />
</template>
@@ -83,21 +85,21 @@ function close() {
</nav>
<section class="rm-settings-body">
<div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back">
<button class="rm-icon-btn rm-settings-back" :title="translate('settings.back_to_settings_categories_2b3f9b')" @click="back">
<Icon name="chevron" class="flip" />
</button>
<h2 ref="heading" tabindex="-1">
{{ searching ? '搜索结果' : current.title }}
{{ searching ? translate('common.search_results_88d72e') : current.title }}
</h2>
<span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || '偏好设置保存在服务器上,所有设备共用'" role="status" aria-live="polite">{{ saveLabel }}</span>
<button class="rm-icon-btn" title="关闭设置" @click="close">
<span v-if="preferenceSection && !searching" class="rm-settings-save" :class="{ error: settingsSave.result === 'error' && !settingsSave.pending }" :title="settingsSave.error || translate('settings.preferences_are_saved_on_the_adf9bd')" role="status" aria-live="polite">{{ saveLabel }}</span>
<button class="rm-icon-btn" :title="translate('settings.close_settings_77b17f')" @click="close">
<Icon name="close" />
</button>
</div>
<div ref="content" class="rm-settings-content">
<div v-if="searching" class="rm-settings-results">
<p class="rm-settings-result-count" role="status">
{{ results.length ? `找到 ${results.length} 项设置` : '没有匹配的设置,试试其他关键词。' }}
{{ results.length ? translate('settings.found_settings_24ca43', { count: results.length }) : translate('settings.no_matching_settings_try_different_3747bc') }}
</p>
<button v-for="result in results" :key="result.id || result.section" class="rm-settings-result" @click="locate(result)">
<span><strong>{{ result.title }}</strong><small>{{ result.description }}</small></span><small>{{ SETTINGS_SECTIONS.find(s => s.id === result.section)!.title }}</small><Icon name="chevron" />
@@ -115,18 +117,18 @@ function close() {
<StoragePanel v-else-if="section === 'storage'" :prepare-maintenance="props.prepareMaintenance" @import-notes="emit('importNotes')" />
<template v-else-if="section === 'about'">
<p class="rm-share-note">
RawMark · 纯 Markdown 笔记编辑器,文件即真源。
{{ translate('settings.rawmark_a_markdown_note_editor_66b52a') }}
</p>
<SettingRow id="version">
<code>{{ buildVersion }}</code>
</SettingRow>
<SettingRow id="install">
<el-button v-if="installPrompt" class="rm-btn" @click="installApp">
安装 RawMark
</el-button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span>
{{ translate('settings.install_rawmark_8ccabe') }}
</el-button><span v-else class="rm-settings-unavailable">{{ translate('settings.this_browser_does_not_currently_d6d77a') }}</span>
</SettingRow>
<SettingRow id="openapi">
<a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a>
<a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">{{ translate('settings.view_openapi_documentation_4f11fd') }}</a>
</SettingRow>
</template>
</div>
+291
View File
@@ -0,0 +1,291 @@
{
"enter_your_current_password_f1790d": "Enter your current password",
"new_password_must_contain_at_2f1f54": "New password must contain at least 8 characters",
"new_passwords_do_not_match_c5575a": "New passwords do not match",
"password_changed_other_devices_must_04f0de": "Password changed. Other devices must sign in again",
"change_password_81ecab": "Change password",
"other_devices_must_sign_in_2c516e": "Other devices must sign in again after a password change.",
"current_password_a114cf": "Current password",
"new_password_515e9c": "New password",
"confirm_new_password_6fde05": "Confirm new password",
"change_password_08d008": "Change password",
"could_not_load_themes_check_8e9224": "Could not load themes. Check your connection",
"could_not_save_appearance_70bfd0": "Could not save appearance: {p0}",
"could_not_reload_appearance_acc776": "Could not reload appearance: {p0}",
"delete_css_snippet_bb7d52": "Delete CSS snippet “{p0}”?",
"default_appearance_and_typography_restored_85037c": "Default appearance and typography restored. CSS snippets are disabled and retained",
"themes_and_css_are_temporarily_0c0bf0": "Themes and CSS are temporarily disabled. Restore the default appearance, then reopen the page.",
"unavailable_6145a5": "{p0} (unavailable)",
"refresh_list_e28be7": "Refresh list",
"light_aa0819": "Light",
"dark_a6b75d": "Dark",
"system_217cfe": "System",
"color_mode_65260b": "Color mode",
"the_light_and_dark_themes_b3b0c9": "The light and dark themes use their respective color modes. Switch to the default or a custom theme to change this setting.",
"custom_css_and_recovery_cc220b": "Custom CSS and recovery",
"new_snippet_b2808d": "New snippet",
"snippet_name_5679e2": "Snippet name",
"save_and_apply_a7ec01": "Save and apply",
"reset_appearance_and_typography_c73c09": "Reset appearance and typography",
"if_custom_styles_make_the_aed659": "If custom styles make the interface unusable, append",
"to_the_url_to_temporarily_b75ce2": "to the URL to temporarily disable them, then restore defaults.",
"body_text_and_code_095337": "Body text and code",
"content_width_e92272": "Content width",
"typography_preview_31ac73": "Typography preview",
"give_your_words_room_to_f3720b": "Give your words room to breathe",
"connect_your_ideas_with_simple_d14ce8": "Connect your ideas with simple notes. Your Markdown files always belong to you.",
"real_content_natural_typography_and_9322bc": "Real content, natural typography, and space to focus.",
"editing_behavior_870d78": "Editing behavior",
"columns_dc30c8": "{p0} columns",
"could_not_load_maintenance_status_51d391": "Could not load maintenance status: {p0}",
"export_file_generated_9d8f6c": "Export file generated",
"exporting_62731d": "Exporting…",
"exporting_notes_015ed1": "Exporting notes…",
"exporting_logs_2a89f7": "Exporting logs…",
"but_synchronization_failed_54aebd": "{p0}, but synchronization failed: {p1}",
"reset_all_rawmark_preferences_shortcuts_9ab27e": "Reset all RawMark preferences? Shortcuts will reset, trash retention will return to 30 days, and plugins will be disabled. Notes, account, installed resources, plugin data, and local tabs/layout will be retained. Settings will be backed up first.",
"resetting_settings_39808b": "Resetting settings…",
"settings_reset_previous_settings_backed_f4a4fa": "Settings reset. Previous settings backed up",
"uploading_6148b3": "Uploading…",
"the_compressed_backup_must_be_cdce88": "The compressed backup must be larger than 0 and no larger than 2 GiB",
"upload_canceled_fb2c92": "Upload canceled",
"the_server_returned_inconsistent_upload_dd61f8": "The server returned inconsistent upload progress",
"uploading_38466c": "Uploading… {p0}%",
"validating_backup_7a1f32": "Validating backup…",
"import_canceled_08d7a9": "Import canceled",
"all_instance_data_will_be_4b1eb6": "All instance data will be replaced, including the password and API tokens from the backup. You must sign in again after a brief maintenance period.",
"all_preferences_and_plugin_configuration_11b0e4": "All preferences and plugin configuration will be replaced. Fonts, themes, and plugins will be merged; imported resources replace matching IDs.",
"backup_date_entries_expanded_size_b6755e": "Backup date: {p0}\nEntries: {p1}, expanded size: {p2} MiB\n{p3}\nCurrent content will be backed up first. Continue?",
"restoring_instance_keep_this_page_405c69": "Restoring instance. Keep this page open…",
"importing_settings_078500": "Importing settings…",
"settings_and_resources_imported_previous_f96d82": "Settings and resources imported. Previous settings backed up",
"restore_request_submitted_the_service_b1d2c2": "Restore request submitted. The service is still under maintenance. Reopen the page later to check the result",
"could_not_clean_up_the_ca0ac3": "Could not clean up the staged upload: {p0}",
"optimizing_index_0aabbf": "Optimizing index…",
"rebuilding_index_ae8fea": "Rebuilding index…",
"index_optimized_dfe862": "Index optimized",
"index_rebuilt_d1763b": "Index rebuilt",
"cleaning_temporary_files_a62be6": "Cleaning temporary files…",
"removed_files_freed_mib_027d71": "Removed {p0} files, freed {p1} MiB",
"some_files_could_not_be_628a24": "Some files could not be removed:\n{p0}",
"backup_and_migration_97dce0": "Backup and migration",
"export_764204": "Export",
"maintenance_tools_051093": "Maintenance tools",
"optimize_e1d83f": "Optimize",
"rebuild_4b8d9d": "Rebuild",
"clean_up_9d2998": "Clean up",
"reset_cb5d68": "Reset",
"last_maintenance_failed_283226": "Last maintenance failed:",
"pre_operation_backup_backups_b966a1": "Pre-operation backup: backups/",
"loading_d04fcb": "Loading",
"running_1f0eb9": "Running",
"disabled_after_an_error_e8b7b5": "Disabled after an error",
"disabled_a8c369": "Disabled",
"temporarily_skipped_9f45ce": "Temporarily skipped",
"plugins_have_full_access_to_7743b6": "Plugins have full access to this page and can read or modify all notes and account data. Only install plugins you trust. Install this plugin?",
"uninstall_plugin_the_plugin_will_6aa869": "Uninstall plugin “{p0}”? The plugin will be disabled and its files removed. Plugin data will be retained.",
"external_plugins_are_temporarily_skipped_f85ce0": "External plugins are temporarily skipped. Disable the faulty plugin, then remove disable-plugins from the URL and reopen the page.",
"installed_plugins_e476c3": "Installed plugins",
"install_zip_4f1723": "Install ZIP",
"plugins_have_full_access_to_0cee40": "Plugins have full access to this page. Only install trusted plugins. New plugins are disabled by default; enabled state is shared across devices.",
"no_plugins_installed_eaaa06": "No plugins installed.",
"no_plugins_installed_9d17e5": "No plugins installed",
"plugins_e806fb": "Plugins",
"waiting_to_load_e1cfcb": "Waiting to load",
"enable_f4f0ea": "Enable",
"enable_2a670f": "Enable {p0}",
"actions_ed31fb": "Actions",
"uninstall_06bc14": "Uninstall",
"if_a_plugin_makes_the_46626f": "If a plugin makes the interface unusable, append",
"to_the_app_url_to_db1658": "to the app URL to skip plugins and open this page. Uninstalling retains plugin data.",
"plugin_settings_b1bc55": "Plugin settings",
"choose_a_plugin_settings_page_7171ad": "Choose a plugin settings page",
"reset_6b6fa6": "Reset {p0}",
"could_not_save_shortcuts_d58683": "Could not save shortcuts: {p0}",
"could_not_reload_settings_744b6a": "Could not reload settings: {p0}",
"conflicts_with_choose_a_different_b5848b": "Conflicts with “{p0}”. Choose a different shortcut",
"the_default_shortcut_conflicts_with_a89537": "The default shortcut conflicts with “{p0}”. Change that command first",
"search_shortcuts_49504f": "Search shortcuts",
"search_commands_or_shortcuts_890754": "Search commands or shortcuts",
"reset_all_shortcuts_1853c7": "Reset all shortcuts",
"click_add_then_press_a_cf4a60": "Click Add, then press a key combination. Esc cancels, × removes a binding, and Reset restores defaults. Defaults follow Typora; unmatched commands (including plugins) have no default. Custom shortcuts are shared across devices.",
"browsers_may_reserve_ctrl_cmd_8d4bc8": "Browsers may reserve Ctrl/Cmd+N, W, number keys, and other combinations. Web apps cannot always intercept them; you can choose different bindings.",
"commands_6aa9ce": "commands",
"no_matching_shortcuts_7e3955": "; no matching shortcuts.",
"remove_from_6909d6": "Remove {p1} from {p0}",
"press_a_shortcut_662b79": "Press a shortcut",
"press_a_shortcut_652436": "Press a shortcut…",
"add_7a8a11": "Add",
"vault_7e16a8": "Vault",
"databases_263200": "Databases",
"themes_788db1": "Themes",
"other_d2909f": "Other",
"could_not_calculate_storage_usage_6db82c": "Could not calculate storage usage: {p0}",
"storage_usage_37ac17": "Storage usage",
"calculating_884dbb": "Calculating…",
"refresh_aee887": "Refresh",
"includes_application_data_and_optional_95f18a": "Includes application data and optional built-in fonts. Excludes default Maple and the app itself. Attachments and trash are included in the vault. Internal symlinks and external operational backups are excluded.",
"storage_usage_by_category_614032": "Storage usage by category",
"total_usage_d3d98e": "Total usage",
"bytes_8180a4": "{p0} bytes",
"calculated_239bdc": "Calculated:",
"optional_built_in_fonts_05b309": "Optional built-in fonts",
"attachments_c7225d": "Attachments",
"enter_a_token_name_3d7107": "Enter a token name",
"could_not_load_api_tokens_98ef9a": "Could not load API tokens: {p0}",
"could_not_create_token_c6c409": "Could not create token: {p0}",
"token_copied_6cca3e": "Token copied",
"copy_failed_select_and_copy_cbc861": "Copy failed. Select and copy the token manually",
"revoke_scripts_using_this_token_f32cc1": "Revoke “{p0}”? Scripts using this token will lose access immediately.",
"could_not_revoke_token_8976da": "Could not revoke token: {p0}",
"e_g_backup_script_da8956": "e.g. Backup script",
"permissions_978cbc": "Permissions",
"read_only_3b5ec3": "Read only",
"read_and_write_d5c332": "Read and write",
"create_cde2cd": "Create",
"this_token_is_shown_only_88a1ba": "This token is shown only once. Save it now:",
"send_the_request_header_02d3d2": "Send the request header",
"authorization_bearer_token_3abd9b": "Authorization: Bearer <token>",
"read_only_tokens_can_only_38418a": "; read-only tokens can only read. See the",
"loading_346ff6": "Loading…",
"no_api_tokens_yet_6eae29": "No API tokens yet",
"created_3567a7": "Created ·",
"last_used_b78fad": "{p0} last used",
"never_used_458529": "Never used",
"revoke_d8c68b": "Revoke",
"could_not_load_trash_0d559f": "Could not load trash: {p0}",
"enter_a_non_negative_number_aff4a0": "Enter a non-negative number of days. 0 means never delete automatically",
"could_not_save_trash_retention_db584b": "Could not save trash retention: {p0}",
"permanently_delete_this_cannot_be_8a3d3f": "Permanently delete “{p0}”? This cannot be undone.",
"empty_all_trash_this_cannot_cf818b": "Empty all trash? This cannot be undone.",
"permanently_deleted_items_4712cc": "Permanently deleted {count} item | Permanently deleted {count} items",
"permanently_deleted_with_failures": "Permanently deleted {count} item; {failed} failed | Permanently deleted {count} items; {failed} failed",
"trash_retention_days_f4ee2a": "Trash retention days",
"days_49da61": "days",
"empty_trash_1b5909": "Empty trash",
"trash_is_empty_9c6705": "Trash is empty",
"item_79f326": "Item",
"delete_permanently_7aa6d0": "Delete permanently",
"could_not_save_settings_17b49d": "Could not save settings",
"development_0d1c11": "Development",
"application_build_information_is_missing_6b04f9": "Application build information is missing",
"saving_ff509c": "Saving…",
"save_failed_6309a3": "Save failed",
"saved_1bd91a": "Saved",
"changes_save_automatically_b88990": "Changes save automatically",
"settings_categories_dfb699": "Settings categories",
"close_settings_77b17f": "Close settings",
"search_settings_287415": "Search settings",
"back_to_settings_categories_2b3f9b": "Back to settings categories",
"preferences_are_saved_on_the_adf9bd": "Preferences are saved on the server and shared across devices",
"found_settings_24ca43": "Found {count} setting | Found {count} settings",
"no_matching_settings_try_different_3747bc": "No matching settings. Try different keywords.",
"rawmark_a_markdown_note_editor_66b52a": "RawMark · A Markdown note editor with files as the source of truth.",
"install_rawmark_8ccabe": "Install RawMark",
"this_browser_does_not_currently_d6d77a": "This browser does not currently offer installation",
"view_openapi_documentation_4f11fd": "View OpenAPI documentation",
"interface_text_size_themes_color_5acf4b": "Interface text size, themes, color mode, and CSS snippets",
"editor_3b7f59": "Editor",
"body_typography_code_font_and_f38913": "Body typography, code font, and editing behavior",
"shortcuts_ee2638": "Shortcuts",
"find_commands_and_add_remove_05d2bd": "Find commands and add, remove, or reset shortcuts",
"install_enable_disable_and_manage_50b0ae": "Install, enable, disable, and manage plugins and their settings",
"storage_26de3d": "Storage",
"storage_usage_backups_migration_and_a2f3e7": "Storage usage, backups, migration, and maintenance tools",
"restore_or_permanently_delete_files_3db466": "Restore or permanently delete files from trash",
"account_and_security_ed3b4b": "Account and security",
"password_api_tokens_and_signing_9c84b0": "Password, API tokens, and signing out",
"about_52d25a": "About",
"app_version_installation_and_api_1217bd": "App version, installation, and API documentation",
"automatically_counts_vault_databases_fonts_0fcb92": "Automatically counts vault, databases, fonts, themes, plugins, and other files. Default Maple is excluded.",
"capacity_usage_size_attachments_images_43526f": "capacity usage size attachments images trash",
"full_instance_backup_093926": "Full instance backup",
"includes_notes_trash_authentication_settings_c1fad0": "Includes notes, trash, authentication, settings, and resources. Current data is backed up before restoring, and you must sign in again afterward. Keep exported backups safe.",
"data_import_export_backup_restore_edece0": "data import export backup restore migration",
"note_data_7d34ee": "Note data",
"export_markdown_and_attachments_as_362805": "Export Markdown and attachments as a ZIP, preserving folders and relative links. Missing attachments are listed separately. Import into a new folder without overwriting notes.",
"data_md_markdown_import_export_0fe592": "data MD Markdown import export migration",
"settings_and_resources_7408ce": "Settings and resources",
"back_up_preferences_shortcuts_plugin_88d8f8": "Back up preferences, shortcuts, plugin data, fonts, themes, and plugins. Excludes notes and authentication. Current settings are backed up before import; matching resource IDs are replaced.",
"settings_import_export_fonts_themes_6ca5d2": "settings import export fonts themes plugins",
"optimize_index_57f600": "Optimize index",
"optimize_full_text_search_and_c95d3a": "Optimize full-text search and reclaim unused index database space without changing notes.",
"search_database_optimize_vacuum_378345": "search database optimize VACUUM",
"rebuild_index_9b178a": "Rebuild index",
"rebuild_full_text_search_and_af7c7e": "Rebuild full-text search and metadata indexes from current Markdown files.",
"search_database_rebuild_repair_fa9c2a": "search database rebuild repair",
"clean_temporary_files_af91ae": "Clean temporary files",
"remove_temporary_files_no_longer_d96efe": "Remove temporary files no longer used by RawMark. Active uploads, restore backups, and user files are retained.",
"temporary_cache_space_cleanup_c6ff13": "temporary cache space cleanup",
"reset_settings_9a70ef": "Reset settings",
"reset_all_rawmark_preferences_and_9b1af9": "Reset all RawMark preferences and disable plugins. Retains notes, account, installed resources, plugin data, and local tabs/layout.",
"reset_default_restore_3d517b": "reset default restore",
"export_system_logs_d05855": "Export system logs",
"export_rotated_application_logs_and_b1f880": "Export rotated application logs and an environment summary, excluding note contents and credentials. Logs not previously saved cannot be recovered.",
"logs_system_errors_diagnostics_f13574": "logs system errors diagnostics",
"automatic_deletion_2fb018": "Automatic deletion",
"measured_from_the_original_deletion_0b80f9": "Measured from the original deletion time. Default: 30 days; 0 disables automatic deletion. Changes affect existing items. Cleanup runs at startup and hourly.",
"trash_days_automatic_cleanup_retention_f6669a": "trash days automatic cleanup retention",
"permanently_delete_all_items_in_cf673d": "Permanently delete all items in trash. This cannot be undone.",
"trash_cleanup_all_delete_bf12ce": "trash cleanup all delete",
"interface_font_52a039": "Interface font",
"used_for_menus_sidebars_and_4ac475": "Used for menus, sidebars, and buttons. Import or delete fonts; the default Maple font cannot be deleted.",
"ui_fonts_import_delete_ttf_767e88": "UI fonts import delete TTF OTF WOFF",
"interface_font_size_608972": "Interface font size",
"adjust_text_in_menus_sidebars_2eb51a": "Adjust text in menus, sidebars, and buttons without changing body or code size.",
"ui_size_8d1a96": "UI size",
"use_a_built_in_theme_c2d94c": "Use a built-in theme or add themes to the themes folder in the server data directory.",
"light_dark_custom_2035c5": "light dark custom",
"choose_light_dark_or_follow_614653": "Choose light, dark, or follow the system.",
"night_light_dark_system_49c06d": "night light dark system",
"glass_opacity_c60f04": "Glass opacity",
"adjust_glass_panel_transparency_while_69b576": "Adjust glass panel transparency while keeping the current theme colors.",
"opacity_background_33f07e": "opacity background",
"css_snippets_d0a2c3": "CSS snippets",
"manage_custom_themes_and_css_5e5a1a": "Manage custom themes and CSS snippets.",
"styles_custom_advanced_88fcf7": "styles custom advanced",
"reset_themes_interface_and_body_b13efd": "Reset themes, interface, and body typography. CSS snippets are disabled and retained.",
"body_font_dd63f6": "Body font",
"used_for_note_content_without_30ab7a": "Used for note content without changing the interface font.",
"sans_serif_system_13027c": "sans serif system",
"code_font_e42107": "Code font",
"used_for_code_blocks_and_a5b950": "Used for code blocks and inline code.",
"monospace_jetbrains_mono_324be7": "monospace JetBrains Mono",
"editor_font_size_7c825e": "Editor font size",
"adjust_the_base_font_size_12669f": "Adjust the base font size for body text and code without affecting the interface.",
"body_size_daa3aa": "body size",
"limit_the_maximum_content_width_5de587": "Limit the maximum content width, or use the full available width.",
"narrow_standard_wide_full_width_d586a8": "narrow standard wide full width",
"body_line_height_088ef4": "Body line height",
"adjust_the_distance_between_lines_d6a35c": "Adjust the distance between lines of body text.",
"line_spacing_typography_703508": "line spacing typography",
"selection_toolbar_1fe31f": "Selection toolbar",
"show_common_formatting_actions_when_b8ed19": "Show common formatting actions when text is selected.",
"format_editing_8105d6": "format editing",
"spell_check_c664a4": "Spell check",
"use_the_browser_s_spell_be99c8": "Use the browser's spell checker. Its language depends on browser settings.",
"language_spelling_d5cb13": "language spelling",
"tab_width_c8881a": "Tab width",
"change_only_the_display_width_3af0c9": "Change only the display width of Tab characters, without changing content. The Tab key still indents by two spaces.",
"tab_indentation_spaces_8a3c02": "Tab indentation spaces",
"preview_the_current_body_and_aadacb": "Preview the current body and code fonts.",
"font_size_line_height_spacing_862f84": "font size line height spacing preview",
"create_or_revoke_rest_api_64d1ec": "Create or revoke REST API access tokens.",
"api_rest_automation_security_ca1d07": "API REST automation security",
"account_security_authentication_e06655": "account security authentication",
"end_this_device_s_login_c5bf28": "End this device's login session.",
"account_session_4b227c": "account session",
"install_app_a298ba": "Install app",
"add_rawmark_to_your_desktop_fb06d4": "Add RawMark to your desktop or home screen when supported by your browser.",
"pwa_install_57a0e5": "PWA install",
"build_version_736f65": "Build version",
"the_application_version_loaded_by_3242d8": "The application version loaded by this page.",
"rawmark_about_64ca9b": "RawMark about",
"api_documentation_c7042f": "API documentation",
"create_a_token_under_account_8d40ff": "Create a token under Account and security → API tokens, then read or write notes through the REST API.",
"openapi_automation_9f53ae": "OpenAPI automation",
"language": "Language",
"language_description": "Choose the language for your account. Shared across devices.",
"theme_white": "Light",
"theme_black": "Dark"
}
+291
View File
@@ -0,0 +1,291 @@
{
"enter_your_current_password_f1790d": "请输入当前密码",
"new_password_must_contain_at_2f1f54": "新密码至少 8 位",
"new_passwords_do_not_match_c5575a": "两次输入的新密码不一致",
"password_changed_other_devices_must_04f0de": "登录密码已修改,其他设备需要重新登录",
"change_password_81ecab": "修改登录密码",
"other_devices_must_sign_in_2c516e": "修改后其他设备需要重新登录。",
"current_password_a114cf": "当前密码",
"new_password_515e9c": "新密码",
"confirm_new_password_6fde05": "确认新密码",
"change_password_08d008": "修改密码",
"could_not_load_themes_check_8e9224": "读取主题失败,请检查网络",
"could_not_save_appearance_70bfd0": "保存外观失败:{p0}",
"could_not_reload_appearance_acc776": "重新读取外观失败:{p0}",
"delete_css_snippet_bb7d52": "删除 CSS 片段“{p0}”?",
"default_appearance_and_typography_restored_85037c": "已恢复默认外观与排版,CSS 片段已停用并保留",
"themes_and_css_are_temporarily_0c0bf0": "已临时停用主题和 CSS。可恢复默认外观后重新打开页面。",
"unavailable_6145a5": "{p0}(不可用)",
"refresh_list_e28be7": "刷新列表",
"light_aa0819": "浅色",
"dark_a6b75d": "深色",
"system_217cfe": "跟随系统",
"color_mode_65260b": "明暗模式",
"the_light_and_dark_themes_b3b0c9": "白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。",
"custom_css_and_recovery_cc220b": "自定义 CSS 与恢复",
"new_snippet_b2808d": "新建片段",
"snippet_name_5679e2": "片段名称",
"save_and_apply_a7ec01": "保存并应用",
"reset_appearance_and_typography_c73c09": "恢复默认外观与排版",
"if_custom_styles_make_the_aed659": "若自定义样式导致界面无法操作,可在地址末尾加",
"to_the_url_to_temporarily_b75ce2": ",临时停用后再恢复默认。",
"body_text_and_code_095337": "正文与代码",
"content_width_e92272": "正文宽度",
"typography_preview_31ac73": "排版预览",
"give_your_words_room_to_f3720b": "让文字自由呼吸",
"connect_your_ideas_with_simple_d14ce8": "用简洁的记录,连接每一个想法。Markdown 文件始终属于你。",
"real_content_natural_typography_and_9322bc": "真实内容,自然排版,专注当下的灵感。",
"editing_behavior_870d78": "编辑行为",
"columns_dc30c8": "{p0} 列",
"could_not_load_maintenance_status_51d391": "读取维护状态失败:{p0}",
"export_file_generated_9d8f6c": "导出文件已生成",
"exporting_62731d": "正在导出…",
"exporting_notes_015ed1": "正在导出笔记…",
"exporting_logs_2a89f7": "正在导出日志…",
"but_synchronization_failed_54aebd": "{p0},但同步失败:{p1}",
"reset_all_rawmark_preferences_shortcuts_9ab27e": "恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。",
"resetting_settings_39808b": "正在重置设置…",
"settings_reset_previous_settings_backed_f4a4fa": "设置已重置,原设置已备份",
"uploading_6148b3": "正在上传…",
"the_compressed_backup_must_be_cdce88": "备份压缩大小须在 0–2 GiB 之间",
"upload_canceled_fb2c92": "已取消上传",
"the_server_returned_inconsistent_upload_dd61f8": "服务端返回的上传进度不一致",
"uploading_38466c": "正在上传… {p0}%",
"validating_backup_7a1f32": "正在校验备份…",
"import_canceled_08d7a9": "已取消导入",
"all_instance_data_will_be_4b1eb6": "将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。",
"all_preferences_and_plugin_configuration_11b0e4": "将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。",
"backup_date_entries_expanded_size_b6755e": "备份时间:{p0}\n条目:{p1},展开大小:{p2} MiB\n{p3}\n操作前会备份当前内容。确认继续?",
"restoring_instance_keep_this_page_405c69": "正在恢复实例,请勿关闭页面…",
"importing_settings_078500": "正在导入设置…",
"settings_and_resources_imported_previous_f96d82": "设置与资源已导入,原设置已备份",
"restore_request_submitted_the_service_b1d2c2": "恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果",
"could_not_clean_up_the_ca0ac3": "清理上传暂存失败:{p0}",
"optimizing_index_0aabbf": "正在优化索引…",
"rebuilding_index_ae8fea": "正在重建索引…",
"index_optimized_dfe862": "索引已优化",
"index_rebuilt_d1763b": "索引已重建",
"cleaning_temporary_files_a62be6": "正在清理临时文件…",
"removed_files_freed_mib_027d71": "已清理 {p0} 个文件,释放 {p1} MiB",
"some_files_could_not_be_628a24": "部分清理失败:\n{p0}",
"backup_and_migration_97dce0": "备份与迁移",
"export_764204": "导出",
"maintenance_tools_051093": "维护工具",
"optimize_e1d83f": "优化",
"rebuild_4b8d9d": "重建",
"clean_up_9d2998": "清理",
"reset_cb5d68": "重置",
"last_maintenance_failed_283226": "上次维护失败:",
"pre_operation_backup_backups_b966a1": "操作前备份:backups/",
"loading_d04fcb": "加载中",
"running_1f0eb9": "运行中",
"disabled_after_an_error_e8b7b5": "故障停用",
"disabled_a8c369": "已停用",
"temporarily_skipped_9f45ce": "临时跳过",
"plugins_have_full_access_to_7743b6": "插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?",
"uninstall_plugin_the_plugin_will_6aa869": "卸载插件“{p0}”?将停用并删除插件文件,保留插件数据。",
"external_plugins_are_temporarily_skipped_f85ce0": "已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。",
"installed_plugins_e476c3": "已安装插件",
"install_zip_4f1723": "安装 ZIP",
"plugins_have_full_access_to_0cee40": "插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。",
"no_plugins_installed_eaaa06": "尚未安装插件。",
"no_plugins_installed_9d17e5": "尚未安装插件",
"plugins_e806fb": "插件",
"waiting_to_load_e1cfcb": "等待加载",
"enable_f4f0ea": "启用",
"enable_2a670f": "启用 {p0}",
"actions_ed31fb": "操作",
"uninstall_06bc14": "卸载",
"if_a_plugin_makes_the_46626f": "若插件导致界面无法操作,在应用地址末尾加",
"to_the_app_url_to_db1658": "可跳过加载并打开此页。卸载保留插件数据。",
"plugin_settings_b1bc55": "插件设置",
"choose_a_plugin_settings_page_7171ad": "选择插件设置页",
"reset_6b6fa6": "重置{p0}",
"could_not_save_shortcuts_d58683": "保存快捷键失败:{p0}",
"could_not_reload_settings_744b6a": "重新读取设置失败:{p0}",
"conflicts_with_choose_a_different_b5848b": "与“{p0}”冲突,请换一个快捷键",
"the_default_shortcut_conflicts_with_a89537": "默认快捷键与“{p0}”冲突,请先调整该命令",
"search_shortcuts_49504f": "搜索快捷键",
"search_commands_or_shortcuts_890754": "搜索命令或快捷键",
"reset_all_shortcuts_1853c7": "重置全部快捷键",
"click_add_then_press_a_cf4a60": "点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。",
"browsers_may_reserve_ctrl_cmd_8d4bc8": "浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。",
"commands_6aa9ce": "个命令",
"no_matching_shortcuts_7e3955": ",没有匹配的快捷键。",
"remove_from_6909d6": "移除{p0}的{p1}",
"press_a_shortcut_662b79": "按下快捷键",
"press_a_shortcut_652436": "按下快捷键…",
"add_7a8a11": "添加",
"vault_7e16a8": "笔记库",
"databases_263200": "数据库",
"themes_788db1": "主题",
"other_d2909f": "其他",
"could_not_calculate_storage_usage_6db82c": "统计失败:{p0}",
"storage_usage_37ac17": "空间占用",
"calculating_884dbb": "统计中…",
"refresh_aee887": "刷新",
"includes_application_data_and_optional_95f18a": "统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。",
"storage_usage_by_category_614032": "各类存储空间占比",
"total_usage_d3d98e": "总占用",
"bytes_8180a4": "{p0} 字节",
"calculated_239bdc": "统计时间:",
"optional_built_in_fonts_05b309": "内置可选字体",
"attachments_c7225d": "附件",
"enter_a_token_name_3d7107": "请输入令牌名称",
"could_not_load_api_tokens_98ef9a": "读取 API 令牌失败:{p0}",
"could_not_create_token_c6c409": "创建失败:{p0}",
"token_copied_6cca3e": "令牌已复制",
"copy_failed_select_and_copy_cbc861": "复制失败,请手动选中复制",
"revoke_scripts_using_this_token_f32cc1": "吊销“{p0}”?使用它的脚本将立即无法访问。",
"could_not_revoke_token_8976da": "吊销失败:{p0}",
"e_g_backup_script_da8956": "如:备份脚本",
"permissions_978cbc": "权限",
"read_only_3b5ec3": "只读",
"read_and_write_d5c332": "读写",
"create_cde2cd": "创建",
"this_token_is_shown_only_88a1ba": "令牌只显示这一次,请立即保存:",
"send_the_request_header_02d3d2": "调用时带上请求头",
"authorization_bearer_token_3abd9b": "Authorization: Bearer 令牌",
"read_only_tokens_can_only_38418a": ";只读令牌只能读取。接口说明见",
"loading_346ff6": "读取中…",
"no_api_tokens_yet_6eae29": "还没有 API 令牌",
"created_3567a7": "创建 ·",
"last_used_b78fad": "{p0} 最近使用",
"never_used_458529": "从未使用",
"revoke_d8c68b": "吊销",
"could_not_load_trash_0d559f": "读取回收站失败:{p0}",
"enter_a_non_negative_number_aff4a0": "请输入非负整数天数,0 表示永不自动删除",
"could_not_save_trash_retention_db584b": "保存自动删除期限失败:{p0}",
"permanently_delete_this_cannot_be_8a3d3f": "彻底删除“{p0}”?此操作无法撤销。",
"empty_all_trash_this_cannot_cf818b": "清空回收站全部条目?此操作无法撤销。",
"permanently_deleted_items_4712cc": "已永久删除 {count} 项",
"permanently_deleted_with_failures": "已永久删除 {count} 项,{failed} 项失败",
"trash_retention_days_f4ee2a": "自动删除天数",
"days_49da61": "天",
"empty_trash_1b5909": "清空全部",
"trash_is_empty_9c6705": "回收站是空的",
"item_79f326": "项目",
"delete_permanently_7aa6d0": "彻底删除",
"could_not_save_settings_17b49d": "保存设置失败",
"development_0d1c11": "开发版",
"application_build_information_is_missing_6b04f9": "缺少应用构建版本信息",
"saving_ff509c": "保存中…",
"save_failed_6309a3": "保存失败",
"saved_1bd91a": "已保存",
"changes_save_automatically_b88990": "修改自动保存",
"settings_categories_dfb699": "设置分类",
"close_settings_77b17f": "关闭设置",
"search_settings_287415": "搜索设置",
"back_to_settings_categories_2b3f9b": "返回设置分类",
"preferences_are_saved_on_the_adf9bd": "偏好设置保存在服务器上,所有设备共用",
"found_settings_24ca43": "找到 {count} 项设置",
"no_matching_settings_try_different_3747bc": "没有匹配的设置,试试其他关键词。",
"rawmark_a_markdown_note_editor_66b52a": "RawMark · 纯 Markdown 笔记编辑器,文件即真源。",
"install_rawmark_8ccabe": "安装 RawMark",
"this_browser_does_not_currently_d6d77a": "当前浏览器暂未提供安装入口",
"view_openapi_documentation_4f11fd": "查看 OpenAPI 文档",
"interface_text_size_themes_color_5acf4b": "界面字号、主题、明暗模式与 CSS 片段",
"editor_3b7f59": "编辑器",
"body_typography_code_font_and_f38913": "正文排版、代码字体与编辑行为",
"shortcuts_ee2638": "快捷键",
"find_commands_and_add_remove_05d2bd": "查找命令,添加、移除或重置快捷键",
"install_enable_disable_and_manage_50b0ae": "安装、启停和管理插件及其设置页",
"storage_26de3d": "存储空间",
"storage_usage_backups_migration_and_a2f3e7": "空间占用、备份迁移及维护工具",
"restore_or_permanently_delete_files_3db466": "恢复或永久删除已移入回收站的文件",
"account_and_security_ed3b4b": "账户与安全",
"password_api_tokens_and_signing_9c84b0": "登录密码、API 令牌与退出登录",
"about_52d25a": "关于",
"app_version_installation_and_api_1217bd": "应用版本、安装应用与 API 文档",
"automatically_counts_vault_databases_fonts_0fcb92": "自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。",
"capacity_usage_size_attachments_images_43526f": "容量 占用 大小 附件 图片 回收站",
"full_instance_backup_093926": "完整服务备份",
"includes_notes_trash_authentication_settings_c1fad0": "包含笔记、回收站、登录信息、设置和资源,用于恢复实例;恢复前备份当前数据,完成后重新登录。请妥善保管导出包。",
"data_import_export_backup_restore_edece0": "Data 导入 导出 备份 恢复 迁移",
"note_data_7d34ee": "笔记数据",
"export_markdown_and_attachments_as_362805": "导出普通 Markdown 与附件 ZIP,保留目录和相对链接,缺失附件另附清单;导入到新文件夹,不覆盖现有笔记。",
"data_md_markdown_import_export_0fe592": "Data MD Markdown 导入 导出 迁移",
"settings_and_resources_7408ce": "设置与资源",
"back_up_preferences_shortcuts_plugin_88d8f8": "备份偏好、快捷键、插件数据及字体、主题、插件;不包含笔记或登录信息。导入前备份,同 ID 资源替换。",
"settings_import_export_fonts_themes_6ca5d2": "设置 导入 导出 字体 主题 插件",
"optimize_index_57f600": "优化索引",
"optimize_full_text_search_and_c95d3a": "整理全文索引并回收索引数据库空闲空间,不修改笔记。",
"search_database_optimize_vacuum_378345": "搜索 数据库 整理 VACUUM",
"rebuild_index_9b178a": "重建索引",
"rebuild_full_text_search_and_af7c7e": "从当前 Markdown 笔记重新生成全文搜索和元数据索引。",
"search_database_rebuild_repair_fa9c2a": "搜索 数据库 重建 修复",
"clean_temporary_files_af91ae": "清理临时文件",
"remove_temporary_files_no_longer_d96efe": "清理本程序不再使用的临时文件;保留活动上传、恢复备份与用户文件。",
"temporary_cache_space_cleanup_c6ff13": "临时 缓存 空间 清理",
"reset_settings_9a70ef": "重置设置",
"reset_all_rawmark_preferences_and_9b1af9": "恢复全部 RawMark 偏好并停用插件,保留笔记、账户、已安装资源、插件存储和本机标签页/布局。",
"reset_default_restore_3d517b": "重置 默认 恢复",
"export_system_logs_d05855": "导出系统日志",
"export_rotated_application_logs_and_b1f880": "导出有限轮转的应用日志和运行环境摘要,不包含笔记正文及认证信息。历史未保存的日志无法补回。",
"logs_system_errors_diagnostics_f13574": "日志 系统 错误 排查",
"automatic_deletion_2fb018": "自动删除期限",
"measured_from_the_original_deletion_0b80f9": "按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。",
"trash_days_automatic_cleanup_retention_f6669a": "回收站 天数 自动 清理 保留",
"permanently_delete_all_items_in_cf673d": "永久删除回收站中的全部条目,无法撤销。",
"trash_cleanup_all_delete_bf12ce": "回收站 清理 全部 删除",
"interface_font_52a039": "界面字体",
"used_for_menus_sidebars_and_4ac475": "用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。",
"ui_fonts_import_delete_ttf_767e88": "UI 字体 导入 删除 TTF OTF WOFF",
"interface_font_size_608972": "界面字号",
"adjust_text_in_menus_sidebars_2eb51a": "调整菜单、侧栏与按钮文字,不影响正文和代码字号。",
"ui_size_8d1a96": "UI 大小",
"use_a_built_in_theme_c2d94c": "使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。",
"light_dark_custom_2035c5": "白色 黑色 自定义",
"choose_light_dark_or_follow_614653": "选择浅色、深色,或跟随系统切换。",
"night_light_dark_system_49c06d": "黑暗 夜间 light dark system",
"glass_opacity_c60f04": "磨砂玻璃通透度",
"adjust_glass_panel_transparency_while_69b576": "调整玻璃面板的透明程度,保留当前主题配色。",
"opacity_background_33f07e": "透明度 背景",
"css_snippets_d0a2c3": "CSS 片段",
"manage_custom_themes_and_css_5e5a1a": "管理自定义主题和 CSS 片段。",
"styles_custom_advanced_88fcf7": "样式 自定义 高级",
"reset_themes_interface_and_body_b13efd": "重置主题、界面与正文排版;停用并保留 CSS 片段。",
"body_font_dd63f6": "正文主要字体",
"used_for_note_content_without_30ab7a": "用于笔记正文,不改变界面字体。",
"sans_serif_system_13027c": "无衬线 宋体 系统",
"code_font_e42107": "代码字体",
"used_for_code_blocks_and_a5b950": "用于代码块和行内代码。",
"monospace_jetbrains_mono_324be7": "等宽 JetBrains Mono",
"editor_font_size_7c825e": "编辑器字号",
"adjust_the_base_font_size_12669f": "调整正文与代码的基础字号,不影响界面字号。",
"body_size_daa3aa": "正文 大小",
"limit_the_maximum_content_width_5de587": "限制正文最大宽度,或使用可用空间的全宽。",
"narrow_standard_wide_full_width_d586a8": "窄 标准 宽 全宽",
"body_line_height_088ef4": "正文行高",
"adjust_the_distance_between_lines_d6a35c": "调整正文各行之间的距离。",
"line_spacing_typography_703508": "行距 排版",
"selection_toolbar_1fe31f": "选中文本浮动工具栏",
"show_common_formatting_actions_when_b8ed19": "选中文字后显示常用格式操作。",
"format_editing_8105d6": "格式 编辑",
"spell_check_c664a4": "拼写检查",
"use_the_browser_s_spell_be99c8": "使用浏览器的拼写检查,检查语言取决于浏览器设置。",
"language_spelling_d5cb13": "语言 拼写",
"tab_width_c8881a": "制表符宽度",
"change_only_the_display_width_3af0c9": "仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。",
"tab_indentation_spaces_8a3c02": "Tab 缩进 空格",
"preview_the_current_body_and_aadacb": "查看当前正文与代码字体的显示效果。",
"font_size_line_height_spacing_862f84": "字号 行高 段距 预览",
"create_or_revoke_rest_api_64d1ec": "创建或撤销 REST API 访问令牌。",
"api_rest_automation_security_ca1d07": "API REST 接口 自动化 安全",
"account_security_authentication_e06655": "账户 安全 鉴权",
"end_this_device_s_login_c5bf28": "退出当前设备的登录会话。",
"account_session_4b227c": "账户 会话",
"install_app_a298ba": "安装应用",
"add_rawmark_to_your_desktop_fb06d4": "浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。",
"pwa_install_57a0e5": "PWA 安装",
"build_version_736f65": "构建版本",
"the_application_version_loaded_by_3242d8": "当前页面加载的应用版本。",
"rawmark_about_64ca9b": "RawMark 关于",
"api_documentation_c7042f": "API 文档",
"create_a_token_under_account_8d40ff": "在「账户与安全 → API 令牌」创建令牌后,通过 REST API 读取或写入笔记。",
"openapi_automation_9f53ae": "OpenAPI 接口 自动化",
"language": "语言",
"language_description": "选择账户语言,所有设备共用。",
"theme_white": "白色",
"theme_black": "黑色"
}
+277 -41
View File
@@ -1,51 +1,287 @@
import type { IconName } from '@@/components/Icon/index.vue'
import type { SettingsSection } from '@/pinia/stores/settings'
import { t as translate } from '@@/i18n/runtime'
export const SETTINGS_SECTIONS: { id: SettingsSection, title: string, icon: IconName, description: string }[] = [
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
{ id: 'storage', title: '存储空间', icon: 'grid', description: '空间占用、备份迁移及维护工具' },
{ id: 'trash', title: '回收站', icon: 'trash', description: '恢复或永久删除已移入回收站的文件' },
{ id: 'account', title: '账户与安全', icon: 'user', description: '登录密码、API 令牌与退出登录' },
{ id: 'about', title: '关于', icon: 'info', description: '应用版本、安装应用与 API 文档' },
{ id: 'appearance', get title() {
return translate('common.appearance_86a63f')
}, icon: 'palette', get description() {
return translate('settings.interface_text_size_themes_color_5acf4b')
} },
{ id: 'editor', get title() {
return translate('settings.editor_3b7f59')
}, icon: 'edit', get description() {
return translate('settings.body_typography_code_font_and_f38913')
} },
{ id: 'shortcuts', get title() {
return translate('settings.shortcuts_ee2638')
}, icon: 'command', get description() {
return translate('settings.find_commands_and_add_remove_05d2bd')
} },
{ id: 'plugins', get title() {
return translate('settings.plugins_e806fb')
}, icon: 'grid', get description() {
return translate('settings.install_enable_disable_and_manage_50b0ae')
} },
{ id: 'storage', get title() {
return translate('settings.storage_26de3d')
}, icon: 'grid', get description() {
return translate('settings.storage_usage_backups_migration_and_a2f3e7')
} },
{ id: 'trash', get title() {
return translate('common.trash_ba35dc')
}, icon: 'trash', get description() {
return translate('settings.restore_or_permanently_delete_files_3db466')
} },
{ id: 'account', get title() {
return translate('settings.account_and_security_ed3b4b')
}, icon: 'user', get description() {
return translate('settings.password_api_tokens_and_signing_9c84b0')
} },
{ id: 'about', get title() {
return translate('settings.about_52d25a')
}, icon: 'info', get description() {
return translate('settings.app_version_installation_and_api_1217bd')
} },
]
export const SETTING_ITEMS = {
'storage': { section: 'storage', title: '空间占用', description: '自动统计笔记库、数据库、字体、主题、插件及其他文件;字体不包含默认 Maple。', keywords: '容量 占用 大小 附件 图片 回收站' },
'backup-full': { section: 'storage', title: '完整服务备份', description: '包含笔记、回收站、登录信息、设置和资源,用于恢复实例;恢复前备份当前数据,完成后重新登录。请妥善保管导出包。', keywords: 'Data 导入 导出 备份 恢复 迁移' },
'backup-notes': { section: 'storage', title: '笔记数据', description: '导出普通 Markdown 与附件 ZIP,保留目录和相对链接,缺失附件另附清单;导入到新文件夹,不覆盖现有笔记。', keywords: 'Data MD Markdown 导入 导出 迁移' },
'backup-settings': { section: 'storage', title: '设置与资源', description: '备份偏好、快捷键、插件数据及字体、主题、插件;不包含笔记或登录信息。导入前备份,同 ID 资源替换。', keywords: '设置 导入 导出 字体 主题 插件' },
'index-optimize': { section: 'storage', title: '优化索引', description: '整理全文索引并回收索引数据库空闲空间,不修改笔记。', keywords: '搜索 数据库 整理 VACUUM' },
'index-rebuild': { section: 'storage', title: '重建索引', description: '从当前 Markdown 笔记重新生成全文搜索和元数据索引。', keywords: '搜索 数据库 重建 修复' },
'temp-clear': { section: 'storage', title: '清理临时文件', description: '清理本程序不再使用的临时文件;保留活动上传、恢复备份与用户文件。', keywords: '临时 缓存 空间 清理' },
'settings-reset': { section: 'storage', title: '重置设置', description: '恢复全部 RawMark 偏好并停用插件,保留笔记、账户、已安装资源、插件存储和本机标签页/布局。', keywords: '重置 默认 恢复' },
'logs-export': { section: 'storage', title: '导出系统日志', description: '导出有限轮转的应用日志和运行环境摘要,不包含笔记正文及认证信息。历史未保存的日志无法补回。', keywords: '日志 系统 错误 排查' },
'trash-retention': { section: 'trash', title: '自动删除期限', description: '按原删除时间计算,默认 30 天;0 表示永不自动删除。修改期限会影响已有条目,服务启动及每小时清理一次。', keywords: '回收站 天数 自动 清理 保留' },
'trash-clear': { section: 'trash', title: '清空全部', description: '永久删除回收站中的全部条目,无法撤销。', keywords: '回收站 清理 全部 删除' },
'ui-font': { section: 'appearance', title: '界面字体', description: '用于菜单、侧栏与按钮;可导入或删除字体,Maple 为不可删除的默认字体。', keywords: 'UI 字体 导入 删除 TTF OTF WOFF' },
'ui-font-size': { section: 'appearance', title: '界面字号', description: '调整菜单、侧栏与按钮文字,不影响正文和代码字号。', keywords: 'UI 大小' },
'theme-select': { section: 'appearance', title: '主题', description: '使用内置主题,或从服务器数据目录的 themes 文件夹添加主题。', keywords: '白色 黑色 自定义' },
'color-mode': { section: 'appearance', title: '明暗模式', description: '选择浅色、深色,或跟随系统切换。', keywords: '黑暗 夜间 light dark system' },
'glass-opacity': { section: 'appearance', title: '磨砂玻璃通透度', description: '调整玻璃面板的透明程度,保留当前主题配色。', keywords: '透明度 背景' },
'css-snippets': { section: 'appearance', title: 'CSS 片段', description: '管理自定义主题和 CSS 片段。', keywords: '样式 自定义 高级' },
'reset-appearance': { section: 'appearance', title: '恢复默认外观与排版', description: '重置主题、界面与正文排版;停用并保留 CSS 片段。', keywords: '重置 默认 恢复' },
'text-font': { section: 'editor', title: '正文主要字体', description: '用于笔记正文,不改变界面字体。', keywords: '无衬线 宋体 系统' },
'code-font': { section: 'editor', title: '代码字体', description: '用于代码块和行内代码。', keywords: '等宽 JetBrains Mono' },
'font-size': { section: 'editor', title: '编辑器字号', description: '调整正文与代码的基础字号,不影响界面字号。', keywords: '正文 大小' },
'content-width': { section: 'editor', title: '正文宽度', description: '限制正文最大宽度,或使用可用空间的全宽。', keywords: '窄 标准 宽 全宽' },
'line-height': { section: 'editor', title: '正文行高', description: '调整正文各行之间的距离。', keywords: '行距 排版' },
'selection-toolbar': { section: 'editor', title: '选中文本浮动工具栏', description: '选中文字后显示常用格式操作。', keywords: '格式 编辑' },
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
'tokens': { section: 'account', title: 'API 令牌', description: '创建或撤销 REST API 访问令牌。', keywords: 'API REST 接口 自动化 安全' },
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
'version': { section: 'about', title: '构建版本', description: '当前页面加载的应用版本。', keywords: 'RawMark 关于' },
'openapi': { section: 'about', title: 'API 文档', description: '在「账户与安全 → API 令牌」创建令牌后,通过 REST API 读取或写入笔记。', keywords: 'OpenAPI 接口 自动化' },
'locale': { section: 'appearance', get title() {
return translate('settings.language')
}, get description() {
return translate('settings.language_description')
}, keywords: 'language English Chinese 语言 英文 中文' },
'storage': { section: 'storage', get title() {
return translate('settings.storage_usage_37ac17')
}, get description() {
return translate('settings.automatically_counts_vault_databases_fonts_0fcb92')
}, get keywords() {
return translate('settings.capacity_usage_size_attachments_images_43526f')
} },
'backup-full': { section: 'storage', get title() {
return translate('settings.full_instance_backup_093926')
}, get description() {
return translate('settings.includes_notes_trash_authentication_settings_c1fad0')
}, get keywords() {
return translate('settings.data_import_export_backup_restore_edece0')
} },
'backup-notes': { section: 'storage', get title() {
return translate('settings.note_data_7d34ee')
}, get description() {
return translate('settings.export_markdown_and_attachments_as_362805')
}, get keywords() {
return translate('settings.data_md_markdown_import_export_0fe592')
} },
'backup-settings': { section: 'storage', get title() {
return translate('settings.settings_and_resources_7408ce')
}, get description() {
return translate('settings.back_up_preferences_shortcuts_plugin_88d8f8')
}, get keywords() {
return translate('settings.settings_import_export_fonts_themes_6ca5d2')
} },
'index-optimize': { section: 'storage', get title() {
return translate('settings.optimize_index_57f600')
}, get description() {
return translate('settings.optimize_full_text_search_and_c95d3a')
}, get keywords() {
return translate('settings.search_database_optimize_vacuum_378345')
} },
'index-rebuild': { section: 'storage', get title() {
return translate('settings.rebuild_index_9b178a')
}, get description() {
return translate('settings.rebuild_full_text_search_and_af7c7e')
}, get keywords() {
return translate('settings.search_database_rebuild_repair_fa9c2a')
} },
'temp-clear': { section: 'storage', get title() {
return translate('settings.clean_temporary_files_af91ae')
}, get description() {
return translate('settings.remove_temporary_files_no_longer_d96efe')
}, get keywords() {
return translate('settings.temporary_cache_space_cleanup_c6ff13')
} },
'settings-reset': { section: 'storage', get title() {
return translate('settings.reset_settings_9a70ef')
}, get description() {
return translate('settings.reset_all_rawmark_preferences_and_9b1af9')
}, get keywords() {
return translate('settings.reset_default_restore_3d517b')
} },
'logs-export': { section: 'storage', get title() {
return translate('settings.export_system_logs_d05855')
}, get description() {
return translate('settings.export_rotated_application_logs_and_b1f880')
}, get keywords() {
return translate('settings.logs_system_errors_diagnostics_f13574')
} },
'trash-retention': { section: 'trash', get title() {
return translate('settings.automatic_deletion_2fb018')
}, get description() {
return translate('settings.measured_from_the_original_deletion_0b80f9')
}, get keywords() {
return translate('settings.trash_days_automatic_cleanup_retention_f6669a')
} },
'trash-clear': { section: 'trash', get title() {
return translate('settings.empty_trash_1b5909')
}, get description() {
return translate('settings.permanently_delete_all_items_in_cf673d')
}, get keywords() {
return translate('settings.trash_cleanup_all_delete_bf12ce')
} },
'ui-font': { section: 'appearance', get title() {
return translate('settings.interface_font_52a039')
}, get description() {
return translate('settings.used_for_menus_sidebars_and_4ac475')
}, get keywords() {
return translate('settings.ui_fonts_import_delete_ttf_767e88')
} },
'ui-font-size': { section: 'appearance', get title() {
return translate('settings.interface_font_size_608972')
}, get description() {
return translate('settings.adjust_text_in_menus_sidebars_2eb51a')
}, get keywords() {
return translate('settings.ui_size_8d1a96')
} },
'theme-select': { section: 'appearance', get title() {
return translate('settings.themes_788db1')
}, get description() {
return translate('settings.use_a_built_in_theme_c2d94c')
}, get keywords() {
return translate('settings.light_dark_custom_2035c5')
} },
'color-mode': { section: 'appearance', get title() {
return translate('settings.color_mode_65260b')
}, get description() {
return translate('settings.choose_light_dark_or_follow_614653')
}, get keywords() {
return translate('settings.night_light_dark_system_49c06d')
} },
'glass-opacity': { section: 'appearance', get title() {
return translate('settings.glass_opacity_c60f04')
}, get description() {
return translate('settings.adjust_glass_panel_transparency_while_69b576')
}, get keywords() {
return translate('settings.opacity_background_33f07e')
} },
'css-snippets': { section: 'appearance', get title() {
return translate('settings.css_snippets_d0a2c3')
}, get description() {
return translate('settings.manage_custom_themes_and_css_5e5a1a')
}, get keywords() {
return translate('settings.styles_custom_advanced_88fcf7')
} },
'reset-appearance': { section: 'appearance', get title() {
return translate('settings.reset_appearance_and_typography_c73c09')
}, get description() {
return translate('settings.reset_themes_interface_and_body_b13efd')
}, get keywords() {
return translate('settings.reset_default_restore_3d517b')
} },
'text-font': { section: 'editor', get title() {
return translate('settings.body_font_dd63f6')
}, get description() {
return translate('settings.used_for_note_content_without_30ab7a')
}, get keywords() {
return translate('settings.sans_serif_system_13027c')
} },
'code-font': { section: 'editor', get title() {
return translate('settings.code_font_e42107')
}, get description() {
return translate('settings.used_for_code_blocks_and_a5b950')
}, get keywords() {
return translate('settings.monospace_jetbrains_mono_324be7')
} },
'font-size': { section: 'editor', get title() {
return translate('settings.editor_font_size_7c825e')
}, get description() {
return translate('settings.adjust_the_base_font_size_12669f')
}, get keywords() {
return translate('settings.body_size_daa3aa')
} },
'content-width': { section: 'editor', get title() {
return translate('settings.content_width_e92272')
}, get description() {
return translate('settings.limit_the_maximum_content_width_5de587')
}, get keywords() {
return translate('settings.narrow_standard_wide_full_width_d586a8')
} },
'line-height': { section: 'editor', get title() {
return translate('settings.body_line_height_088ef4')
}, get description() {
return translate('settings.adjust_the_distance_between_lines_d6a35c')
}, get keywords() {
return translate('settings.line_spacing_typography_703508')
} },
'selection-toolbar': { section: 'editor', get title() {
return translate('settings.selection_toolbar_1fe31f')
}, get description() {
return translate('settings.show_common_formatting_actions_when_b8ed19')
}, get keywords() {
return translate('settings.format_editing_8105d6')
} },
'spellcheck': { section: 'editor', get title() {
return translate('settings.spell_check_c664a4')
}, get description() {
return translate('settings.use_the_browser_s_spell_be99c8')
}, get keywords() {
return translate('settings.language_spelling_d5cb13')
} },
'tab-size': { section: 'editor', get title() {
return translate('settings.tab_width_c8881a')
}, get description() {
return translate('settings.change_only_the_display_width_3af0c9')
}, get keywords() {
return translate('settings.tab_indentation_spaces_8a3c02')
} },
'typography-preview': { section: 'editor', get title() {
return translate('settings.typography_preview_31ac73')
}, get description() {
return translate('settings.preview_the_current_body_and_aadacb')
}, get keywords() {
return translate('settings.font_size_line_height_spacing_862f84')
} },
'tokens': { section: 'account', get title() {
return translate('common.api_tokens_79db53')
}, get description() {
return translate('settings.create_or_revoke_rest_api_64d1ec')
}, get keywords() {
return translate('settings.api_rest_automation_security_ca1d07')
} },
'password': { section: 'account', get title() {
return translate('settings.change_password_81ecab')
}, get description() {
return translate('settings.other_devices_must_sign_in_2c516e')
}, get keywords() {
return translate('settings.account_security_authentication_e06655')
} },
'logout': { section: 'account', get title() {
return translate('common.sign_out_3ab8cc')
}, get description() {
return translate('settings.end_this_device_s_login_c5bf28')
}, get keywords() {
return translate('settings.account_session_4b227c')
} },
'install': { section: 'about', get title() {
return translate('settings.install_app_a298ba')
}, get description() {
return translate('settings.add_rawmark_to_your_desktop_fb06d4')
}, get keywords() {
return translate('settings.pwa_install_57a0e5')
} },
'version': { section: 'about', get title() {
return translate('settings.build_version_736f65')
}, get description() {
return translate('settings.the_application_version_loaded_by_3242d8')
}, get keywords() {
return translate('settings.rawmark_about_64ca9b')
} },
'openapi': { section: 'about', get title() {
return translate('settings.api_documentation_c7042f')
}, get description() {
return translate('settings.create_a_token_under_account_8d40ff')
}, get keywords() {
return translate('settings.openapi_automation_9f53ae')
} },
} satisfies Record<string, { section: SettingsSection, title: string, description: string, keywords: string }>
export type SettingId = keyof typeof SETTING_ITEMS
+7 -6
View File
@@ -1,4 +1,5 @@
import { exportFilesApi } from '@@/apis/files'
import { t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm'
import { toast } from '@@/utils/notifications'
@@ -14,7 +15,7 @@ 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' && !await confirmAction(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
if (action === 'delete' && !await confirmAction(translate('workspace.move_the_selected_items_to_3d5e79', { p0: subjects.length }))) return
const failures: string[] = []
let succeeded = 0
for (const path of subjects) {
@@ -34,9 +35,9 @@ export async function batchFiles(action: 'move' | 'delete', paths: string[], des
user.assertSession(version)
} catch (cause) {
if (isCancel(cause)) return
toast(`刷新目录失败:${String(cause)}`)
toast(translate('workspace.could_not_refresh_the_file_b5f68b', { p0: String(cause) }))
}
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? `;${failures.join(';')}` : ''}`)
toast(translate('workspace.finished_succeeded_failed_dbc164', { p0: action === 'move' ? translate('common.move') : translate('common.delete_2f9daa'), p1: succeeded, p2: failures.length, p3: failures.length ? `;${failures.join(';')}` : '' }))
return failures.length === 0
}
@@ -48,14 +49,14 @@ export async function exportFiles(paths: string[]) {
try {
await Promise.all(tabs.map(tab => flush(tab.path)))
user.assertSession(version)
if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
if (tabs.some(tab => tab.dirty || tab.status !== 'ready')) throw new Error(translate('workspace.save_your_notes_and_resolve_377e42'))
const response = await exportFilesApi({ paths: subjects })
const blob = response.data
user.assertSession(version)
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'
const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? translate('workspace.export_zip_9cdf3e')
const url = URL.createObjectURL(blob)
const link = Object.assign(document.createElement('a'), { href: url, download: filename })
document.body.append(link)
@@ -64,6 +65,6 @@ export async function exportFiles(paths: string[]) {
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (cause) {
if (isCancel(cause)) return
toast(`导出失败:${cause instanceof Error ? cause.message : String(cause)}`)
toast(translate('workspace.export_failed_a53238', { p0: cause instanceof Error ? cause.message : String(cause) }))
}
}
@@ -1,21 +1,23 @@
<script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { runCommand } from '@@/utils/commands'
import { toggleSourceMode, ws } from '../workspace'
</script>
<template>
<section v-if="ws.active" class="rm-editor-chrome" aria-label="笔记操作">
<button class="rm-icon-btn" title="即时预览" aria-label="即时预览" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()">
<section v-if="ws.active" class="rm-editor-chrome" :aria-label="translate('workspace.note_actions_afcc30')">
<button class="rm-icon-btn" :title="translate('workspace.live_preview_195a67')" :aria-label="translate('workspace.live_preview_195a67')" :aria-pressed="!ws.sourceMode" @mousedown.prevent @click="ws.sourceMode && toggleSourceMode()">
<Icon name="eye" />
</button>
<button class="rm-icon-btn" title="源码模式" aria-label="源码模式" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()">
<button class="rm-icon-btn" :title="translate('workspace.source_mode_11b7fd')" :aria-label="translate('workspace.source_mode_11b7fd')" :aria-pressed="ws.sourceMode" @mousedown.prevent @click="!ws.sourceMode && toggleSourceMode()">
<Icon name="source" />
</button>
<button class="rm-icon-btn" title="显示 / 隐藏大纲" aria-label="显示 / 隐藏大纲" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen">
<button class="rm-icon-btn" :title="translate('workspace.show_hide_outline_c41996')" :aria-label="translate('workspace.show_hide_outline_c41996')" :aria-pressed="ws.outlineOpen" @click="ws.outlineOpen = !ws.outlineOpen">
<Icon name="outline" />
</button>
<button class="rm-icon-btn" title="专注写作" aria-label="专注写作" :aria-pressed="ws.focusMode" @click="runCommand('view.focus')">
<button class="rm-icon-btn" :title="translate('workspace.focus_mode_b35534')" :aria-label="translate('workspace.focus_mode_b35534')" :aria-pressed="ws.focusMode" @click="runCommand('view.focus')">
<Icon name="expand" />
</button>
</section>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { StateEffect, Text } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { t as translate } from '@@/i18n/runtime'
import { EditorState } from '@codemirror/state'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createEditorView } from '../editor'
@@ -70,10 +72,10 @@ watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => {
<div class="rm-editor-pane">
<div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
<div v-if="!tab" class="rm-empty">
<p>选择或新建一篇笔记</p>
<p>{{ translate('workspace.select_or_create_a_note_669f82') }}</p>
</div>
<div v-else-if="tab.status === 'error'" class="rm-empty">
<p>笔记打开失败</p>
<p>{{ translate('workspace.could_not_open_this_note_634236') }}</p>
</div>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { locale, t as translate } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm'
import { basename, dirname, isImage, joinPath } from '@@/utils/paths'
import { computed, ref, watch } from 'vue'
@@ -18,7 +20,7 @@ const extension = computed(() => basename(path.value).slice(basename(path.value)
const src = computed(() => imageUrl(path.value))
const siblings = computed(() => ws.entries.filter(e => !e.dir && isImage(e.path) && dirname(e.path) === dirname(path.value)))
const index = computed(() => siblings.value.findIndex(e => e.path === path.value))
const size = computed(() => entry.value ? `${(entry.value.size / 1024).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} KB` : '')
const size = computed(() => entry.value ? `${(entry.value.size / 1024).toLocaleString(locale.value, { maximumFractionDigits: 1 })} KB` : '')
watch(path, (p) => {
name.value = basename(p).slice(0, -extension.value.length); error.value = ''
@@ -44,7 +46,7 @@ function loaded(event: Event) {
async function rename() {
const base = name.value.trim()
if (!base || base.startsWith('.') || /[/\\]/.test(base)) {
error.value = '图片名称不能为空、以点开头或包含斜杠'; return
error.value = translate('workspace.image_names_cannot_be_empty_9499af'); return
}
const to = joinPath(dirname(path.value), base + extension.value)
if (to === path.value || busy.value) return
@@ -54,7 +56,7 @@ async function rename() {
const result = await move(path.value, to, 'caller')
if (!result.ok) error.value = result.error
} catch (err) {
error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录'
error.value = err instanceof Error ? err.message : translate('workspace.change_applied_but_synchronization_failed_8a50cd')
} finally {
busy.value = false
}
@@ -66,18 +68,18 @@ async function replace(event: Event) {
input.value = ''
if (!file || busy.value) return
if (file.size > 50 * 1024 * 1024) {
error.value = '图片不能超过 50 MB'; return
error.value = translate('workspace.images_must_not_exceed_mb_61d721'); return
}
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) {
error.value = '请选择与原图片扩展名相同的文件'; return
error.value = translate('workspace.choose_a_file_with_the_97e882'); return
}
busy.value = true
error.value = ''
try {
if (!await confirmAction('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
if (!await confirmAction(translate('workspace.replacing_this_image_affects_all_107587'))) return
await replaceImage(path.value, file)
} catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试'
error.value = err instanceof ApiError ? err.message : translate('workspace.network_error_replacement_status_is_8b54f6')
} finally {
busy.value = false
}
@@ -87,7 +89,7 @@ async function trash() {
if (busy.value) return
busy.value = true
try {
if (!await confirmAction('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
if (!await confirmAction(translate('workspace.move_this_image_to_trash_c979ba'))) return
await remove(path.value)
} finally {
busy.value = false
@@ -96,41 +98,41 @@ async function trash() {
</script>
<template>
<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">
<el-dialog :model-value="true" :title="translate('workspace.image_manager_07c502')" 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">
图片已不存在,请刷新目录
{{ translate('workspace.this_image_no_longer_exists_50dc1d') }}
</p>
<p v-else-if="loadError" role="alert">
图片无法预览,文件可能损坏或浏览器不支持此格式。
{{ translate('workspace.cannot_preview_this_image_it_046111') }}
</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)">
上一张
{{ translate('workspace.previous_image_bcf200') }}
</el-button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<el-button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">
下一张
{{ translate('workspace.next_image_dcd4eb') }}
</el-button>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<label for="rm-image-name">{{ translate('workspace.image_name_edb98e') }}</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)">
重命名
{{ translate('workspace.rename_0d0cba') }}
</el-button>
</div>
</form>
<p class="rm-image-hint">
改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。
{{ translate('workspace.renaming_updates_local_references_in_bc3d12') }}
</p>
<p v-if="error" class="rm-auth-error" role="alert">
{{ error }}
@@ -138,13 +140,13 @@ async function trash() {
<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">
移动到…
{{ translate('workspace.move_to_0bee16') }}
</el-button>
<el-button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">
移入回收站
{{ translate('workspace.move_to_trash_361bdf') }}
</el-button>
<el-button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">
{{ busy ? '处理中…' : '替换图片' }}
{{ busy ? translate('workspace.processing_d3d211') : translate('workspace.replace_image_dbc39d') }}
</el-button>
</div>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { Entry } from '@@/apis/tree/type'
import Icon from '@@/components/Icon/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import { toast } from '@@/utils/notifications'
import { basename, hiddenAssetPath, imageInFolder, isImage } from '@@/utils/paths'
import { computed, onMounted, ref } from 'vue'
@@ -40,7 +42,7 @@ const certain = computed(() => metadata.ready && !metadata.error && !metadata.no
const images = computed(() => scopeImages.value.filter((entry) => {
const count = references.value.get(entry.path)?.length ?? 0
return entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()) && (!format.value || entry.path.toLowerCase().endsWith(`.${format.value}`)) && (usage.value === 'all' || (certain.value && (usage.value === 'used' ? count > 0 : count === 0)))
}).sort((a, b) => sort.value === 'name' ? a.path.localeCompare(b.path, 'zh-CN', { numeric: true }) : sort.value === 'size' ? b.size - a.size : b.mtime - a.mtime))
}).sort((a, b) => sort.value === 'name' ? a.path.localeCompare(b.path, locale.value, { numeric: true }) : sort.value === 'size' ? b.size - a.size : b.mtime - a.mtime))
const types = computed(() => [...new Set(scopeImages.value.map(entry => entry.path.slice(entry.path.lastIndexOf('.') + 1).toLowerCase()))].sort())
const totalSize = computed(() => scopeImages.value.reduce((sum, entry) => sum + entry.size, 0))
const current = computed(() => scopeImages.value.find(entry => entry.path === selected.value))
@@ -65,13 +67,13 @@ async function upload(event: Event) {
busy.value = true; report.value = []
const valid = files.filter((file) => {
if (!isImage(file.name) || file.size === 0 || file.size > 50 * 1024 * 1024) {
report.value.push(`${file.name}:请选择非空图片,单张最大 50 MB`); return false
report.value.push(translate('workspace.choose_a_non_empty_image_c3cdc3', { p0: file.name })); return false
}; return true
})
try {
const result = await uploadFolderImages(uploadDir.value, valid); report.value.push(`已处理 ${result.uploaded} 张图片,相同内容自动去重`, ...result.failures)
const result = await uploadFolderImages(uploadDir.value, valid); report.value.push(translate('workspace.processed_images_identical_content_is_9c3f9a', { p0: result.uploaded }), ...result.failures)
} catch (error) {
report.value.push(`上传后刷新失败,请重新检查目录:${error instanceof Error ? error.message : '网络错误'}`)
report.value.push(translate('workspace.upload_complete_but_refresh_failed_a8fdfb', { p0: error instanceof Error ? error.message : translate('common.network_error') }))
} finally {
busy.value = false
}
@@ -79,7 +81,7 @@ async function upload(event: Event) {
async function proposeCleanup() {
await refreshMetadata()
if (!certain.value) {
toast('引用状态尚未确认,不能列出清理候选'); return
toast(translate('workspace.references_are_not_confirmed_cleanup_76caea')); return
}
cleanupCandidates.value = [...orphans.value]
cleanupSelected.value = orphans.value.map(entry => entry.path)
@@ -88,10 +90,10 @@ async function clean() {
busy.value = true; report.value = []
try {
const result = await cleanupImages(cleanupSelected.value)
report.value.push(`已将 ${result.deleted.length} 张图片移入回收站`, ...result.skipped.map(item => `${item.path}:${item.reason}`))
report.value.push(translate('workspace.moved_images_to_trash_c5b457', { count: result.deleted.length }), ...result.skipped.map(item => `${item.path}:${item.reason}`))
cleanupCandidates.value = null
} catch (error) {
report.value.push(`清理失败:${error instanceof Error ? error.message : '网络错误'}`)
report.value.push(translate('workspace.cleanup_failed_86823d', { p0: error instanceof Error ? error.message : translate('common.network_error') }))
} finally {
busy.value = false
}
@@ -99,55 +101,55 @@ async function clean() {
</script>
<template>
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)">
<section class="rm-media-library" :aria-label="translate('workspace.image_library_92329a')" @keydown.esc.stop="!busy && (ws.images = null)">
<header class="rm-media-header">
<div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : `${dir || '根目录'} · 当前目录及直属附件` }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><el-button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">
返回笔记
<div><small>ASSET LIBRARY</small><h1>{{ translate('workspace.images_and_visual_resources_794f97') }}</h1><p>{{ ws.imagesAll ? translate('workspace.entire_vault_a8e1f0') : translate('workspace.current_folder_and_direct_attachments_c2a863', { p0: dir || translate('workspace.root_folder_42ee28') }) }} · {{ scopeImages.length }} {{ translate('workspace.images_b62646') }} {{ size(totalSize) }}</p></div><el-button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">
{{ translate('workspace.back_to_notes_38461a') }}
</el-button>
</header>
<div class="rm-media-mobile-filters">
<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 v-model="ws.imagesAll" :aria-label="translate('workspace.resource_scope_9c55b5')">
<el-option :value="false" :label="translate('workspace.current_folder_257f44')" /><el-option :value="true" :label="translate('workspace.entire_vault_a8e1f0')" />
</el-select><el-select v-model="usage" :aria-label="translate('workspace.references_92fb1e')">
<el-option value="all" :label="translate('workspace.all_images_048d65')" /><el-option value="used" :disabled="!certain" :label="translate('workspace.referenced_981f27')" /><el-option value="unused" :disabled="!certain" :label="translate('workspace.unreferenced_01e3bd')" />
</el-select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
清理孤立图片
{{ translate('workspace.clean_unused_images_f4eb11') }}
</el-button>
</div>
<div class="rm-media-workspace">
<aside class="rm-media-filters">
<span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false">
<Icon name="folder" />{{ dir || '根目录' }}
<span class="rm-media-section-label">{{ translate('workspace.resource_scope_9c55b5') }}</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false">
<Icon name="folder" />{{ dir || translate('workspace.root_folder_42ee28') }}
</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true">
<Icon name="image" />整个知识库
</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">
<Icon name="image" />{{ translate('workspace.entire_vault_a8e1f0') }}
</button><span class="rm-media-section-label">{{ translate('workspace.references_92fb1e') }}</span><button v-for="item in [{ id: 'all', get label() { return translate('workspace.all_images_048d65') } }, { id: 'used', get label() { return translate('workspace.referenced_981f27') } }, { id: 'unused', get label() { return translate('workspace.unreferenced_01e3bd') } }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">
{{ item.label }}
</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain" @click="proposeCleanup">
<Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small>
</button><p>清理前核对全库引用,图片移入回收站,可恢复。</p>
</button><span class="rm-media-section-label">{{ translate('workspace.organize_resources_ecf902') }}</span><button :disabled="busy || !certain" @click="proposeCleanup">
<Icon name="trash" />{{ translate('workspace.clean_unused_images_f4eb11') }} <small>{{ orphans.length }}</small>
</button><p>{{ translate('workspace.checks_references_across_the_vault_371581') }}</p>
</aside>
<div class="rm-media-content">
<div class="rm-media-toolbar">
<el-input v-model="query" class="rm-media-search" type="search" clearable placeholder="搜索图片名称或路径…" aria-label="搜索图片">
<el-input v-model="query" class="rm-media-search" type="search" clearable :placeholder="translate('workspace.search_image_names_or_paths_944196')" :aria-label="translate('workspace.search_images_8f938f')">
<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">
</el-input><el-select v-model="format" :aria-label="translate('workspace.image_format_f2ed45')">
<el-option value="" :label="translate('workspace.all_formats_c8b740')" /><el-option v-for="type in types" :key="type" :value="type" :label="type.toUpperCase()" />
</el-select><el-select v-model="sort" :aria-label="translate('workspace.image_sort_order_34877a')">
<el-option value="modified" :label="translate('workspace.last_modified_ad3bf1')" /><el-option value="name" :label="translate('common.name_d44e9b')" /><el-option value="size" :label="translate('workspace.file_size_b9bf69')" />
</el-select><button class="rm-icon-btn" :title="list ? translate('workspace.grid_view_709ce2') : translate('workspace.list_view_383611')" @click="list = !list">
<Icon :name="list ? 'grid' : 'outline'" />
</button>
</div>
<div class="rm-media-upload">
<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 ? '处理中…' : '上传图片' }}
<label>{{ translate('workspace.upload_to_46873e') }} <el-select v-model="uploadDir" :aria-label="translate('workspace.upload_folder_462e85')"><el-option value="" :label="translate('workspace.root_assets_c9d759')" /><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 ? translate('workspace.processing_d3d211') : translate('workspace.upload_images_9fbb47') }}
</el-button>
</div>
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">
{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <el-button class="rm-btn" @click="refreshMetadata()">
刷新状态
{{ metadata.error || (metadata.ready ? translate('workspace.some_note_metadata_is_invalid_26b8ec') : translate('workspace.reading_reference_index_8392a7')) }} <el-button class="rm-btn" @click="refreshMetadata()">
{{ translate('workspace.refresh_status_802a40') }}
</el-button>
</p>
<div v-if="report.length" class="rm-media-report" role="status">
@@ -157,34 +159,34 @@ async function clean() {
</div>
<div class="rm-media-grid" :class="{ list }">
<button v-for="image in images" :key="image.path" class="rm-media-card" :class="{ selected: selected === image.path }" :title="image.path" @click="select(image.path)" @dblclick="ws.image = image.path" @contextmenu.prevent="ws.image = image.path">
<span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy"></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? `${references.get(image.path)?.length ?? 0} 篇引用` : '引用未确认' }}</small>
<span class="rm-media-thumbnail"><img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy"></span><strong>{{ basename(image.path) }}</strong><small>{{ size(image.size) }} · {{ certain ? translate('workspace.references_9aa726', { count: references.get(image.path)?.length ?? 0 }) : translate('workspace.references_not_confirmed_5abb9b') }}</small>
</button>
</div>
<p v-if="!images.length" class="rm-tree-empty">
没有符合条件的图片
{{ translate('workspace.no_matching_images_129380') }}
</p>
</div>
<aside v-if="current" class="rm-media-inspector">
<button class="rm-icon-btn rm-media-deselect" title="关闭详情" @click="selected = ''">
<button class="rm-icon-btn rm-media-deselect" :title="translate('workspace.close_details_9071cf')" @click="selected = ''">
<Icon name="close" />
</button><div class="rm-media-detail-image">
<img v-if="!broken" :src="imageUrl(current.path)" :alt="basename(current.path)" @load="loaded" @error="broken = true"><p v-else>
无法预览此图片
{{ translate('workspace.cannot_preview_this_image_99dab9') }}
</p>
</div><h2>{{ basename(current.path) }}</h2><p class="rm-media-path">
{{ current.path }}
</p><dl><dt>尺寸</dt><dd>{{ dimensions || (broken ? '无法读取' : '加载中…') }}</dd><dt>大小</dt><dd>{{ size(current.size) }}</dd><dt>修改时间</dt><dd>{{ new Date(current.mtime).toLocaleDateString('zh-CN') }}</dd><dt>引用</dt><dd>{{ certain ? `${currentRefs.length} 篇笔记` : '尚未确认' }}</dd></dl><el-button class="rm-btn rm-btn-primary" @click="ws.image = current.path">
<Icon name="edit" />预览与编辑
</el-button><h3>引用此图片的笔记</h3><button v-for="reference in currentRefs" :key="reference.path" class="rm-media-reference" @click="ws.images = null; openReference(reference.path, reference.line)">
</p><dl><dt>{{ translate('workspace.dimensions_246df7') }}</dt><dd>{{ dimensions || (broken ? translate('workspace.cannot_read_c27041') : translate('workspace.loading_4927a5')) }}</dd><dt>{{ translate('workspace.size_50db74') }}</dt><dd>{{ size(current.size) }}</dd><dt>{{ translate('workspace.modified_257bbc') }}</dt><dd>{{ new Date(current.mtime).toLocaleDateString(locale) }}</dd><dt>{{ translate('workspace.references_e0ec65') }}</dt><dd>{{ certain ? translate('workspace.notes_fd9460', { count: currentRefs.length }) : translate('workspace.not_confirmed_10e9fc') }}</dd></dl><el-button class="rm-btn rm-btn-primary" @click="ws.image = current.path">
<Icon name="edit" />{{ translate('workspace.preview_and_edit_d797af') }}
</el-button><h3>{{ translate('workspace.notes_referencing_this_image_b2a9ac') }}</h3><button v-for="reference in currentRefs" :key="reference.path" class="rm-media-reference" @click="ws.images = null; openReference(reference.path, reference.line)">
<Icon name="note" /><span>{{ reference.title }}<small>{{ reference.path }}</small></span>
</button><p v-if="certain && !currentRefs.length">
没有笔记引用
{{ translate('workspace.no_notes_reference_this_image_aab134') }}
</p>
</aside>
</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">
<el-dialog :model-value="cleanupCandidates !== null" :title="translate('workspace.cleanup_candidates_images_9cd82b', { count: 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">
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
{{ translate('workspace.the_server_will_check_the_cce22a') }}
</p><ul>
<li v-for="image in cleanupCandidates ?? []" :key="image.path">
<el-checkbox-group v-model="cleanupSelected">
@@ -194,12 +196,12 @@ async function clean() {
</el-checkbox-group>
</li>
</ul><p v-if="!cleanupCandidates?.length" class="rm-share-note">
当前范围没有可清理的孤立图片。
{{ translate('workspace.no_unused_images_to_clean_ad136a') }}
</p><div class="rm-cleanup-actions">
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
取消
{{ translate('common.cancel_2cd0f3') }}
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
移入回收站({{ cleanupSelected.length }})
{{ translate('workspace.move_to_trash_911c22') }}{{ cleanupSelected.length }})
</el-button>
</div>
</el-dialog>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { ImportResult } from '@@/apis/files/type'
import { t as translate } from '@@/i18n/runtime'
import { dirname } from '@@/utils/paths'
import { computed, ref } from 'vue'
import { ApiError } from '@/http/axios'
@@ -17,8 +19,8 @@ const result = ref<ImportResult | null>(null)
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
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'
if (!zipped.value && files.value.some(f => !f.name.endsWith('.md'))) return translate('workspace.choose_markdown_files_or_a_a06bed')
if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return translate('workspace.total_import_size_must_not_23ac37')
return ''
})
@@ -39,7 +41,7 @@ function close() {
async function submit() {
if (busy.value || !files.value.length || validation.value || result.value) return
if (zipped.value && !folder.value.trim()) {
error.value = '请输入导入文件夹名称'; return
error.value = translate('workspace.enter_an_import_folder_name_6be2a3'); return
}
busy.value = true
error.value = ''
@@ -53,7 +55,7 @@ async function submit() {
// 导入文件成功后释放浏览器持有的文件,结果仍保留在弹窗中。
files.value = []
} catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,导入结果尚未确认,请检查目标目录后再重试'
error.value = err instanceof ApiError ? err.message : translate('workspace.network_error_import_status_is_fb8726')
} finally {
busy.value = false
}
@@ -61,31 +63,31 @@ async function submit() {
</script>
<template>
<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-dialog :model-value="true" :title="translate('workspace.import_markdown_fb4d82')" 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 || '根目录' }}
{{ translate('workspace.import_to_020113') }}{{ dir || translate('workspace.root_folder_42ee28') }}
</p>
<template v-if="!result">
<label>选择 Markdown 文件或一个 ZIP
<label>{{ translate('workspace.choose_markdown_files_or_a_88d508') }}
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose">
</label>
<label v-if="zipped">新建文件夹名称
<label v-if="zipped">{{ translate('workspace.new_folder_name_31b655') }}
<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 = ''" />
<el-input v-model="names[i]" required :aria-label="translate('workspace.import_folder_name_fba3f9')" @input="error = ''" />
</label>
</div>
<p class="rm-import-hint">
总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。
{{ translate('workspace.total_upload_limit_mb_zip_fa43d1') }}
</p>
</template>
<div v-if="result" role="status">
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
<p>{{ translate('workspace.import_complete_96bfaa') }}{{ result.notes }} {{ translate('workspace.notes_56c9da') }}{{ result.assets }} {{ translate('workspace.attachments_including_independent_copies_162f1e') }}</p>
<details v-if="result.warnings.length" class="rm-import-warnings">
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
<summary>{{ result.warnings.length }} {{ translate('workspace.notes_have_link_warnings_1462a8') }}</summary>
<ul>
<li v-for="warning in result.warnings" :key="warning">
{{ warning }}
@@ -97,14 +99,14 @@ async function submit() {
{{ error || validation }}
</p>
<p v-if="busy" role="status">
正在上传并整理,请勿关闭页面…
{{ translate('workspace.uploading_and_organizing_keep_this_2be889') }}
</p>
<div class="rm-import-actions">
<el-button native-type="button" class="rm-btn" :disabled="busy" @click="close">
{{ result ? '完成' : '取消' }}
{{ result ? translate('workspace.done_c0b3fb') : translate('common.cancel_2cd0f3') }}
</el-button>
<el-button v-if="!result" native-type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">
{{ busy ? '导入中…' : '导入' }}
{{ busy ? translate('workspace.importing_2f58b6') : translate('common.import_576d81') }}
</el-button>
</div>
</el-form>
@@ -1,5 +1,7 @@
<script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import { computed, ref, watch } from 'vue'
import { metadata, refreshMetadata } from '../metadata'
import { openNote, openReference, ws } from '../workspace'
@@ -13,7 +15,7 @@ const tags = computed(() => {
for (const note of metadata.notes) {
for (const name of note.tags) counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-CN'))
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], locale.value))
})
const visibleNotes = computed(() => {
const nearby = new Set([ws.active])
@@ -69,52 +71,52 @@ watch(() => ws.view, () => {
<template>
<section class="rm-knowledge-panel">
<header class="rm-knowledge-heading">
<div><span class="rm-eyebrow">KNOWLEDGE CONNECTIONS</span><h1>{{ ws.view === 'graph' ? '让知识自然连接' : '标签索引' }}</h1><p>{{ metadata.notes.length }} 篇笔记,以标准 Markdown 链接与标签连接你的想法。</p></div><el-button class="rm-btn" @click="ws.view = 'notes'">
返回笔记
<div><span class="rm-eyebrow">KNOWLEDGE CONNECTIONS</span><h1>{{ ws.view === 'graph' ? translate('workspace.let_knowledge_connect_22f6c5') : translate('workspace.tags_02b344') }}</h1><p>{{ metadata.notes.length }} {{ translate('workspace.notes_connect_your_ideas_with_9f4998') }}</p></div><el-button class="rm-btn" @click="ws.view = 'notes'">
{{ translate('workspace.back_to_notes_38461a') }}
</el-button>
</header>
<div class="rm-knowledge-tools">
<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>
<label><Icon name="search" /><input v-model="query" type="search" :placeholder="translate('workspace.search_notes_21919c')" :aria-label="translate('workspace.search_notes_d8912a')"></label><el-select v-model="tag" :aria-label="translate('workspace.filter_tags_2d8b5c')">
<el-option value="" :label="translate('workspace.all_tags_b709cb')" /><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">{{ translate('workspace.only_related_to_this_note_d6f457') }}</label>
</div>
<p v-if="metadata.error" role="alert">
{{ metadata.error }} <el-button class="rm-btn" @click="refreshMetadata">
重试
{{ translate('common.retry_b8784c') }}
</el-button>
</p>
<p v-else-if="!metadata.ready" class="rm-tree-empty">
关系索引准备中…
{{ translate('workspace.preparing_relationship_index_1d8bb5') }}
</p>
<template v-else-if="ws.view === 'graph'">
<div class="rm-graph-canvas">
<svg :viewBox="`${camera.x} ${camera.y} ${camera.width} ${camera.width * .69}`" role="img" aria-label="笔记关系图谱,可点击节点打开笔记" @pointerdown="start" @pointermove="pan" @pointerup="drag = undefined" @pointercancel="drag = undefined" @wheel.prevent="zoom($event.deltaY > 0 ? 1.12 : .89)">
<svg :viewBox="`${camera.x} ${camera.y} ${camera.width} ${camera.width * .69}`" role="img" :aria-label="translate('workspace.note_relationship_graph_click_a_5a4781')" @pointerdown="start" @pointermove="pan" @pointerup="drag = undefined" @pointercancel="drag = undefined" @wheel.prevent="zoom($event.deltaY > 0 ? 1.12 : .89)">
<line v-for="edge in graph.edges" :key="`${edge.from.path}\0${edge.to.path}`" :x1="edge.from.x" :y1="edge.from.y" :x2="edge.to.x" :y2="edge.to.y" />
<g v-for="node in graph.nodes" :key="node.path" :transform="`translate(${node.x} ${node.y})`" data-note tabindex="0" role="button" :aria-label="node.title" :class="{ current: node.path === ws.active }" @click="openNote(node.path)" @keydown.enter.prevent="openNote(node.path)" @keydown.space.prevent="openNote(node.path)"><title>{{ node.path }}</title><circle :r="Math.min(13, 5 + node.degree * 1.5)" /><text y="27" text-anchor="middle">{{ node.title.length > 16 ? `${node.title.slice(0, 16)}…` : node.title }}</text></g>
</svg>
<p v-if="!graph.nodes.length" class="rm-graph-empty">
没有符合条件的笔记
{{ translate('workspace.no_matching_notes_215bb5') }}
</p>
<div class="rm-graph-controls">
<span>{{ graph.nodes.length }} 节点 · {{ graph.edges.length }} 关系</span><button class="rm-icon-btn" title="放大" @click="zoom(.8)">
<span>{{ graph.nodes.length }} {{ translate('workspace.nodes_09c858') }} {{ graph.edges.length }} {{ translate('workspace.links_253aaf') }}</span><button class="rm-icon-btn" :title="translate('workspace.zoom_in_80f8fb')" @click="zoom(.8)">
+
</button><button class="rm-icon-btn" title="缩小" @click="zoom(1.25)">
</button><button class="rm-icon-btn" :title="translate('workspace.zoom_out_290f68')" @click="zoom(1.25)">
−
</button><button class="rm-icon-btn" title="重置视图" @click="camera = { x: -420, y: -290, width: 840 }">
</button><button class="rm-icon-btn" :title="translate('workspace.reset_view_764aba')" @click="camera = { x: -420, y: -290, width: 840 }">
<Icon name="expand" />
</button>
</div>
</div>
<details v-if="missing.length" class="rm-missing-links">
<summary>{{ missing.length }} 个链接目标尚不存在</summary><button v-for="link in missing" :key="link.path + link.line + link.target" @click="openReference(link.path, link.line)">
{{ link.path }} → {{ link.target }} · 第 {{ link.line }} 行
<summary>{{ missing.length }} {{ translate('workspace.link_targets_do_not_exist_074109') }}</summary><button v-for="link in missing" :key="link.path + link.line + link.target" @click="openReference(link.path, link.line)">
{{ link.path }} → {{ link.target }} {{ translate('workspace.line_ca0b53') }} {{ link.line }} {{ translate('workspace.line_4cc7b0') }}
</button>
</details>
</template>
<template v-else>
<div class="rm-tags-cloud">
<button :class="{ on: !tag }" @click="tag = ''">
全部
{{ translate('workspace.all_5c55a6') }}
</button><button v-for="[name, count] in tags" :key="name" :class="{ on: tag === name }" @click="tag = name">
#{{ name }} <small>{{ count }}</small>
</button>
@@ -123,7 +125,7 @@ watch(() => ws.view, () => {
<button v-for="note in visibleNotes" :key="note.path" @click="openNote(note.path)">
<Icon name="note" /><div><strong>{{ note.title }}</strong><small>{{ note.path }}</small></div><span>{{ note.tags.map(tag => `#${tag}`).join(' ') }}</span>
</button><p v-if="!visibleNotes.length">
没有符合条件的笔记
{{ translate('workspace.no_matching_notes_215bb5') }}
</p>
</div>
</template>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { InputInstance } from 'element-plus'
import Icon from '@@/components/Icon/index.vue'
import { locale, t as translate } from '@@/i18n/runtime'
import { basename, dirname, inAssetDir, joinPath, noteTitle } from '@@/utils/paths'
import { isCancel } from 'axios'
import { computed, ref, watch } from 'vue'
@@ -20,7 +22,7 @@ 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]))
const title = computed(() => paths.value.length > 1 ? translate('workspace.items_980fda', { count: paths.value.length }) : noteTitle(paths.value[0]))
const targets = computed(() => {
const inside = (dir: string) => paths.value.some(path => ws.entries.some(entry => entry.path === path && entry.dir) && (dir === path || dir.startsWith(`${path}/`)))
@@ -30,7 +32,7 @@ const targets = computed(() => {
const items = computed(() => {
const q = query.value.trim()
if (!q) return [...targets.value].sort((a, b) => a.localeCompare(b, 'zh-CN')).map(path => ({ path, hits: new Set<number>() }))
if (!q) return [...targets.value].sort((a, b) => a.localeCompare(b, locale.value)).map(path => ({ path, hits: new Set<number>() }))
return targets.value
.filter(p => p !== '')
.map(path => ({ path, m: fuzzyMatch(q, path, path.lastIndexOf('/') + 1) }))
@@ -53,7 +55,7 @@ async function choose(i: number) {
error.value = result.error; return
}
} else if (!await batchFiles('move', paths.value, target.path)) {
error.value = '部分项目移动失败,请核对目录后重试'; return
error.value = translate('workspace.some_items_could_not_be_8f48b2'); return
}
emit('close')
} catch (cause) {
@@ -80,8 +82,8 @@ function onKeydown(e: KeyboardEvent) {
</script>
<template>
<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)">
<el-dialog :model-value="true" :title="translate('workspace.move_e3e7e7', { p0: 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="translate('workspace.move_to_d652dc', { p0: title })" :aria-label="translate('workspace.search_destination_folders_95ab69')" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<template #prefix>
<Icon name="folder" />
</template>
@@ -100,13 +102,13 @@ function onKeydown(e: KeyboardEvent) {
>
<Icon name="folder" />
<span class="rm-switcher-title">
<template v-if="item.path === ''">根目录</template>
<template v-if="item.path === ''">{{ translate('workspace.root_folder_42ee28') }}</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">
没有可移动到的文件夹
{{ translate('workspace.no_available_destination_folders_d4d0c2') }}
</p>
</el-dialog>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { t as translate } from '@@/i18n/runtime'
import { computed } from 'vue'
import { activeTab, keepLocalVersion, retrySave, useServerVersion } from '../workspace'
@@ -7,24 +9,24 @@ const tab = computed(activeTab)
<template>
<div v-if="tab?.status === 'conflict'" class="rm-banner warn">
<span>这篇笔记已在其他地方被修改,与你的修改冲突。</span>
<span>{{ translate('workspace.this_note_was_changed_elsewhere_641d37') }}</span>
<el-button class="rm-btn" @click="useServerVersion(tab.path)">
载入服务器版本
{{ translate('workspace.load_server_version_b24209') }}
</el-button>
<el-button class="rm-btn" @click="keepLocalVersion(tab.path)">
保留我的修改
{{ translate('workspace.keep_my_changes_cf78a9') }}
</el-button>
</div>
<div v-else-if="tab?.status === 'deleted'" class="rm-banner warn">
<span>这篇笔记已在其他地方被删除或移动。</span>
<span>{{ translate('workspace.this_note_was_deleted_or_07470b') }}</span>
<el-button class="rm-btn" @click="keepLocalVersion(tab.path)">
重新保存
{{ translate('workspace.save_again_e18806') }}
</el-button>
</div>
<div v-else-if="tab?.saveError" class="rm-banner error">
<span>{{ tab.saveError }}</span>
<el-button class="rm-btn" @click="retrySave(tab.path)">
立即重试
{{ translate('workspace.retry_now_77c30e') }}
</el-button>
</div>
</template>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { EditorState } from '@codemirror/state'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -26,7 +28,7 @@ function collect(state: EditorState): Heading[] {
if (!m) return
const line = state.doc.lineAt(n.from)
const text = m[1] === 'ATX' ? line.text.replace(/^\s*#{1,6}\s*/, '').replace(/\s+#+\s*$/, '') : line.text.trim()
out.push({ level: Number(m[2]), text: text || '(无标题)', from: n.from })
out.push({ level: Number(m[2]), text, from: n.from })
return false
},
})
@@ -84,10 +86,10 @@ const minLevel = () => Math.min(...headings.value.map(h => h.level))
<template>
<div class="rm-outline-backdrop" @click.self="ws.outlineOpen = false">
<aside class="rm-outline" aria-label="大纲">
<aside class="rm-outline" :aria-label="translate('workspace.outline_79fadb')">
<div class="rm-outline-head">
<span>大纲</span>
<button class="rm-icon-btn" title="关闭大纲" @click="ws.outlineOpen = false">
<span>{{ translate('workspace.outline_79fadb') }}</span>
<button class="rm-icon-btn" :title="translate('workspace.close_outline_1b401a')" @click="ws.outlineOpen = false">
<Icon name="close" />
</button>
</div>
@@ -100,11 +102,11 @@ const minLevel = () => Math.min(...headings.value.map(h => h.level))
@mousedown.prevent
@click="jump(h)"
>
{{ h.text }}
{{ h.text || translate('workspace.untitled_7633ed') }}
</li>
</ul>
<p v-else class="rm-tree-empty">
没有标题
{{ translate('workspace.no_headings_c76538') }}
</p>
</aside>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { InputInstance } from 'element-plus'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { availableCommands, commandKeys, formatKeys } from '@@/utils/commands'
import { dirname, NOTE_EXT, noteTitle } from '@@/utils/paths'
import { computed, ref, watch } from 'vue'
@@ -87,7 +89,9 @@ function noteItems(q: string): Item[] {
items.push({
key: '\0create',
icon: 'noteAdd',
title: `新建笔记“${name}”`,
get title() {
return translate('workspace.create_note_8148f2', { p0: name })
},
detail: '',
titleHits: new Set(),
detailHits: new Set(),
@@ -155,8 +159,8 @@ function opened() {
</script>
<template>
<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)">
<el-dialog :model-value="true" :title="commandMode ? translate('workspace.command_palette_4b495d') : translate('workspace.quick_open_13e215')" width="560px" class="rm-picker-dialog" @close="cancel" @opened="opened">
<el-input ref="input" v-model="query" :placeholder="commandMode ? translate('workspace.enter_a_command_name_305f5a') : translate('workspace.enter_a_note_name_to_90941b')" :aria-label="commandMode ? translate('workspace.search_commands_a72832') : translate('workspace.search_notes_d8912a')" enterkeyhint="go" @keydown="onKeydown($event as KeyboardEvent)">
<template #prefix>
<Icon :name="commandMode ? 'command' : 'search'" />
</template>
@@ -180,7 +184,7 @@ function opened() {
</li>
</ul>
<p v-else class="rm-tree-empty">
{{ commandMode ? '没有匹配的命令' : '还没有笔记' }}
{{ commandMode ? translate('workspace.no_matching_commands_556513') : translate('workspace.no_notes_yet_871745') }}
</p>
</el-dialog>
</template>
@@ -2,7 +2,9 @@
import type { SearchResult } from '@@/apis/search/type'
import { searchApi } from '@@/apis/search'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { reportError } from '@@/utils/notifications'
import { dirname, noteTitle } from '@@/utils/paths'
@@ -95,11 +97,11 @@ onMounted(() => input.value?.focus())
v-model="query"
class="rm-search-input"
type="search"
placeholder="搜索笔记"
:placeholder="translate('workspace.search_notes_d8912a')"
enterkeyhint="search"
@keydown="onKeydown"
>
<button class="rm-icon-btn" title="关闭搜索" @click="ws.searchOpen = false">
<button class="rm-icon-btn" :title="translate('workspace.close_search_ab008e')" @click="ws.searchOpen = false">
<Icon name="close" />
</button>
</div>
@@ -122,10 +124,10 @@ onMounted(() => input.value?.focus())
</li>
</ul>
<p v-else-if="results" class="rm-tree-empty">
没有找到匹配的笔记
{{ translate('workspace.no_matching_notes_found_d87b01') }}
</p>
<p v-else class="rm-tree-empty">
输入关键词,多个词用空格分隔
{{ translate('workspace.enter_keywords_separated_by_spaces_a25285') }}
</p>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { InlineStyles } from '@@/markdown/inline-styles'
import type { EditorSelection } from '@codemirror/state'
import type { FormatCommand } from '../editor/format'
import Icon from '@@/components/Icon/index.vue'
import InlineAppearance from '@@/components/InlineAppearance/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { pinia } from '@/pinia'
import { useSettingsStore } from '@/pinia/stores/settings'
@@ -22,11 +24,21 @@ let unsubscribe: (() => void) | undefined
let frame = 0
let dismissed: EditorSelection | undefined
const buttons = [
{ title: '加粗', label: 'B', cls: 'bold', key: 'bold', run: toggleSelectionMark('StrongEmphasis') },
{ title: '斜体', label: 'I', cls: 'italic', key: 'italic', run: toggleSelectionMark('Emphasis') },
{ title: '下划线', label: 'U', cls: 'underline', key: 'underline', run: toggleUnderline },
{ title: '删除线', label: 'S', cls: 'strike', key: 'strike', run: toggleSelectionMark('Strikethrough') },
{ title: '高亮', label: 'M', cls: 'highlight', key: 'highlight', run: toggleSelectionMark('Highlight') },
{ get title() {
return translate('workspace.bold_5ceac6')
}, label: 'B', cls: 'bold', key: 'bold', run: toggleSelectionMark('StrongEmphasis') },
{ get title() {
return translate('workspace.italic_217901')
}, label: 'I', cls: 'italic', key: 'italic', run: toggleSelectionMark('Emphasis') },
{ get title() {
return translate('workspace.underline_d3b4a2')
}, label: 'U', cls: 'underline', key: 'underline', run: toggleUnderline },
{ get title() {
return translate('workspace.strikethrough_5b0508')
}, label: 'S', cls: 'strike', key: 'strike', run: toggleSelectionMark('Strikethrough') },
{ get title() {
return translate('workspace.highlight_05f954')
}, label: 'M', cls: 'highlight', key: 'highlight', run: toggleSelectionMark('Highlight') },
] as const
function close(restoreFocus = false) {
@@ -134,14 +146,14 @@ watch(() => ws.active, () => {
<template>
<Teleport to="body">
<div v-if="floating" ref="root" class="rm-selection-popover" :style="{ left: `${floating.left}px`, top: `${floating.top}px`, maxHeight: `${floating.maxHeight}px`, maxWidth: `${floating.maxWidth}px` }">
<div class="rm-selection-toolbar" role="toolbar" aria-label="选中文本格式">
<button type="button" title="外观" aria-label="外观" :aria-expanded="appearanceOpen" aria-controls="rm-inline-appearance" @mousedown.prevent @click="appearanceOpen = !appearanceOpen">
<div class="rm-selection-toolbar" role="toolbar" :aria-label="translate('workspace.format_selected_text_bd0a7d')">
<button type="button" :title="translate('common.appearance_86a63f')" :aria-label="translate('common.appearance_86a63f')" :aria-expanded="appearanceOpen" aria-controls="rm-inline-appearance" @mousedown.prevent @click="appearanceOpen = !appearanceOpen">
<Icon name="palette" />
</button>
<button v-for="button in buttons" :key="button.key" type="button" :title="button.title" :aria-label="button.title" :aria-pressed="appearance[button.key]" :class="button.cls" @mousedown.prevent @click="run(button.run)">
{{ button.label }}
</button>
<button type="button" title="链接" aria-label="链接" :aria-pressed="appearance.link" @mousedown.prevent @click="run(insertLink)">
<button type="button" :title="translate('workspace.link_305378')" :aria-label="translate('workspace.link_305378')" :aria-pressed="appearance.link" @mousedown.prevent @click="run(insertLink)">
<Icon name="link" />
</button>
</div>
+16 -14
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import Icon from '@@/components/Icon/index.vue'
import PluginMount from '@@/components/PluginMount/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { sidebarViews } from '@@/plugins/registry'
import { commandTitle } from '@@/utils/commands'
import { basename } from '@@/utils/paths'
@@ -112,16 +114,16 @@ onUnmounted(() => {
<template>
<div class="rm-sidebar-inner">
<div class="rm-vault-heading">
<button class="rm-icon-btn" title="折叠目录" aria-label="折叠目录" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false">
<button class="rm-icon-btn" :title="translate('workspace.collapse_sidebar_7b4c4c')" :aria-label="translate('workspace.collapse_sidebar_7b4c4c')" @click="ws.sidebarCollapsed = true; ws.sidebarOpen = false">
<Icon name="menu" />
</button>
<button class="rm-tree-label" title="文件目录操作" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">
文件目录
<button class="rm-tree-label" :title="translate('workspace.file_tree_actions_ac6020')" aria-haspopup="true" :aria-expanded="rootMenu !== null" @click="rootContext" @contextmenu="rootContext">
{{ translate('workspace.files_ef81f8') }}
</button>
</div>
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" :aria-label="translate('workspace.sidebar_view_3d4d03')">
<el-button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">
文件
{{ translate('workspace.files_39932f') }}
</el-button>
<el-button v-for="[id, pluginView] in sidebarViews" :key="id" class="rm-btn" :class="{ on: selectedView === id }" @click="selectedView = id">
{{ pluginView.title }}
@@ -134,39 +136,39 @@ onUnmounted(() => {
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
</ul>
<p v-if="!tree.length" class="rm-tree-empty">
还没有笔记
{{ translate('workspace.no_notes_yet_871745') }}
</p>
</div>
<Teleport v-if="rootMenu" to="body">
<div class="rm-menu-backdrop rm-context-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
<div ref="menuElement" class="rm-menu rm-context-menu" :style="{ left: `${rootMenu.x}px`, top: `${rootMenu.y}px` }">
<button :title="commandTitle('app.search')" @click="runRootMenu('search')">
<Icon name="search" />搜索
<Icon name="search" />{{ translate('workspace.search_44ce7a') }}
</button>
<button @click="runRootMenu('tags')">
<Icon name="tag" />标签索引
<Icon name="tag" />{{ translate('workspace.tags_02b344') }}
</button>
<button @click="runRootMenu('note')">
<Icon name="noteAdd" />新建笔记
<Icon name="noteAdd" />{{ translate('workspace.new_note_d3d2d9') }}
</button>
<button @click="runRootMenu('folder')">
<Icon name="folderAdd" />新建目录
<Icon name="folderAdd" />{{ translate('workspace.new_folder_d5f6f6') }}
</button>
<button @click="runRootMenu('import')">
<Icon name="import" />导入文档
<Icon name="import" />{{ translate('workspace.import_documents_8b1183') }}
</button>
<button @click="runRootMenu('images')">
<Icon name="image" />图片管理
<Icon name="image" />{{ translate('workspace.image_manager_07c502') }}
</button>
</div>
</Teleport>
<TreeIconPicker v-if="treeIconTarget" :key="treeIconTarget.path" :target="treeIconTarget" />
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'">
<Icon name="settings" />设置
<Icon name="settings" />{{ translate('common.settings_df3d58') }}
</button>
<button class="rm-foot-btn" @click="emit('logout')">
<Icon name="logout" />退出
<Icon name="logout" />{{ translate('common.sign_out_498e1d') }}
</button>
</div>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { Tab } from '../workspace'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { commandTitle } from '@@/utils/commands'
import { noteTitle } from '@@/utils/paths'
import { nextTick, ref } from 'vue'
@@ -98,30 +100,30 @@ function closeRight(path: string) {
@drop="onDrop"
@dragend="onDragEnd"
>
<span v-if="tab.pinned" class="rm-tab-pin" title="已固定"><Icon name="pin" :size="16" /></span>
<span v-if="tab.pinned" class="rm-tab-pin" :title="translate('workspace.pinned_317c1a')"><Icon name="pin" :size="16" /></span>
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" :title="translate('workspace.close_3fd47e')" @click.stop="closeTab(tab.path)">
<span class="rm-dirty-dot" />
<Icon name="close" :size="16" />
</button>
</div>
<button class="rm-icon-btn rm-tab-add" title="新建笔记" @click="createNote('')">
<button class="rm-icon-btn rm-tab-add" :title="translate('workspace.new_note_d3d2d9')" @click="createNote('')">
<Icon name="plus" />
</button>
<template v-if="menu">
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: `${menu.x}px`, top: `${menu.y}px` }">
<button @click="menu.tab.pinned = !menu.tab.pinned; menu = null">
{{ menu.tab.pinned ? '取消固定' : '固定' }}
{{ menu.tab.pinned ? translate('workspace.unpin_20590a') : translate('workspace.pin_d4b3f4') }}
</button>
<button @click="closeTab(menu.tab.path); menu = null">
关闭
{{ translate('workspace.close_3fd47e') }}
</button>
<button @click="closeOthers(menu.tab.path)">
关闭其他标签页
{{ translate('workspace.close_other_tabs_701003') }}
</button>
<button @click="closeRight(menu.tab.path)">
关闭右侧标签页
{{ translate('workspace.close_tabs_to_the_right_5fcad9') }}
</button>
</div>
</template>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { TreeIcon } from '@@/apis/tree/type'
import type { IconName } from '@@/utils/icons'
import type { Gemoji } from 'gemoji'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { iconPaths } from '@@/utils/icons'
import { basename } from '@@/utils/paths'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
@@ -73,7 +75,7 @@ async function loadEmojis() {
try {
emojis.value = (await import('gemoji')).gemoji
} catch (err) {
loadError.value = `表情加载失败:${err instanceof Error ? err.message : String(err)}`
loadError.value = translate('workspace.could_not_load_emoji_ec4192', { p0: err instanceof Error ? err.message : String(err) })
} finally {
loading.value = false
}
@@ -96,7 +98,7 @@ async function save(icon: TreeIcon | null) {
busy.value = false
close()
} catch (err) {
error.value = `图标保存失败:${err instanceof Error ? err.message : String(err)}`
error.value = translate('workspace.could_not_save_icon_110c4c', { p0: err instanceof Error ? err.message : String(err) })
} finally {
busy.value = false
}
@@ -127,31 +129,31 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="rm-menu-backdrop rm-tree-icon-backdrop" @click="close" @contextmenu.prevent="close" />
<section ref="panel" class="rm-tree-icon-picker" role="dialog" aria-modal="true" aria-label="修改图标" :aria-busy="busy" :style="{ left: `${position.left}px`, top: `${position.top}px` }">
<section ref="panel" class="rm-tree-icon-picker" role="dialog" aria-modal="true" :aria-label="translate('workspace.change_icon_252d1f')" :aria-busy="busy" :style="{ left: `${position.left}px`, top: `${position.top}px` }">
<header>
<span class="rm-tree-icon-title" :title="target.path">{{ basename(target.path) }}</span><button class="rm-icon-btn" title="关闭图标选择" aria-label="关闭图标选择" :disabled="busy" @click="close">
<span class="rm-tree-icon-title" :title="target.path">{{ basename(target.path) }}</span><button class="rm-icon-btn" :title="translate('workspace.close_icon_picker_a76f13')" :aria-label="translate('workspace.close_icon_picker_a76f13')" :disabled="busy" @click="close">
<Icon name="close" />
</button>
</header>
<div class="rm-tree-icon-tabs" role="tablist" aria-label="图标类型">
<div class="rm-tree-icon-tabs" role="tablist" :aria-label="translate('workspace.icon_type_5e85b1')">
<button role="tab" :aria-selected="tab === 'emoji'" :disabled="busy" @click="tab = 'emoji'">
表情
{{ translate('workspace.emoji_c762ed') }}
</button>
<button role="tab" :aria-selected="tab === 'builtin'" :disabled="busy" @click="tab = 'builtin'">
线性图标
{{ translate('workspace.line_icons_5948d6') }}
</button>
</div>
<input ref="search" v-model="query" class="rm-input" placeholder="搜索图标(中文 / English)" aria-label="搜索图标" :disabled="busy">
<nav v-if="tab === 'emoji'" class="rm-tree-icon-categories" aria-label="表情分类">
<input ref="search" v-model="query" class="rm-input" :placeholder="translate('workspace.search_icons_chinese_english_653965')" :aria-label="translate('workspace.search_icons_ebb240')" :disabled="busy">
<nav v-if="tab === 'emoji'" class="rm-tree-icon-categories" :aria-label="translate('workspace.emoji_category_8c37bd')">
<button v-for="item in emojiCategories" :key="item.id" class="rm-emoji" :title="item.label" :aria-label="item.label" :aria-pressed="category === item.id && !query" :disabled="busy" @click="category = item.id; query = ''">
{{ item.icon }}
</button>
</nav>
<div class="rm-tree-icon-results">
<template v-if="!query && visibleRecent.length">
<p>最近使用</p>
<p>{{ translate('workspace.recent_39a904') }}</p>
<div class="rm-tree-icon-grid">
<button v-for="icon in visibleRecent" :key="chosen(icon)" :class="{ 'rm-emoji': icon.kind === 'emoji' }" :title="icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value" :aria-label="`最近:${icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value}`" :disabled="busy" @click="save(icon)">
<button v-for="icon in visibleRecent" :key="chosen(icon)" :class="{ 'rm-emoji': icon.kind === 'emoji' }" :title="icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value" :aria-label="translate('workspace.recent_9793c5', { p0: icon.kind === 'builtin' ? iconLabels[icon.value] : icon.value })" :disabled="busy" @click="save(icon)">
<Icon v-if="icon.kind === 'builtin'" :name="icon.value" :size="20" /><template v-else>
{{ icon.value }}
</template>
@@ -160,34 +162,34 @@ onBeforeUnmount(() => {
</template>
<template v-if="tab === 'emoji'">
<p v-if="loading">
正在加载表情…
{{ translate('workspace.loading_emoji_f7826c') }}
</p>
<div v-else-if="loadError" role="alert">
{{ loadError }} <el-button class="rm-btn" @click="loadEmojis">
重试
{{ translate('common.retry_b8784c') }}
</el-button>
</div>
<template v-else>
<p>{{ query ? '搜索结果' : emojiCategories.find(c => c.id === category)?.label }}</p>
<p>{{ query ? translate('common.search_results_88d72e') : emojiCategories.find(c => c.id === category)?.label }}</p>
<div class="rm-tree-icon-grid">
<button v-for="emoji in filtered" :key="emoji.emoji" class="rm-emoji" :title="emojiLabel(emoji)" :aria-label="emojiLabel(emoji)" :disabled="busy" @click="save({ kind: 'emoji', value: emoji.emoji })">
{{ emoji.emoji }}
</button>
</div>
<p v-if="!filtered.length">
没有找到图标,试试其他关键词
{{ translate('workspace.no_icons_found_try_different_14e89f') }}
</p>
</template>
</template>
<template v-else>
<p>线性图标</p>
<p>{{ translate('workspace.line_icons_5948d6') }}</p>
<div class="rm-tree-icon-grid">
<button v-for="name in linear" :key="name" :title="iconLabels[name]" :aria-label="iconLabels[name]" :disabled="busy" @click="save({ kind: 'builtin', value: name })">
<Icon :name="name" :size="20" />
</button>
</div>
<p v-if="!linear.length">
没有找到图标,试试其他关键词
{{ translate('workspace.no_icons_found_try_different_14e89f') }}
</p>
</template>
</div>
@@ -196,7 +198,7 @@ onBeforeUnmount(() => {
</p>
<footer>
<el-button class="rm-btn" :disabled="busy" @click="save(null)">
{{ busy ? '正在保存…' : '恢复默认图标' }}
{{ busy ? translate('common.saving_6bdb44') : translate('workspace.reset_icon_0ed903') }}
</el-button>
</footer>
</section>
+14 -12
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { TreeNode } from '../tree'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { basename, dirname, inAssetDir, isImage, joinPath } from '@@/utils/paths'
import { computed, nextTick, ref, watch } from 'vue'
import { batchFiles, exportFiles } from '../batch'
@@ -184,7 +186,7 @@ function onDrop(e: DragEvent) {
@drop="onDrop"
>
<span class="rm-tree-chevron" :class="{ open: isOpen, hidden: !node.dir }"><Icon name="chevron" :size="16" /></span>
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="`修改图标:${node.name}`" title="修改图标" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
<button v-if="!isRenaming" type="button" class="rm-tree-icon" :class="{ 'rm-emoji': node.icon?.kind === 'emoji' || (!node.icon && !image) }" :aria-label="translate('workspace.change_icon_2fad4d', { p0: node.name })" :title="translate('workspace.change_icon_252d1f')" @click.stop="editIcon" @touchstart.stop @contextmenu.prevent.stop="editIcon">
<Icon v-if="node.icon?.kind === 'builtin'" :name="node.icon.value" :size="18" />
<template v-else-if="node.icon?.kind === 'emoji'">
{{ node.icon.value }}
@@ -205,7 +207,7 @@ function onDrop(e: DragEvent) {
@blur="commitRename"
>
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<button v-if="!isRenaming" class="rm-icon-btn rm-tree-more" :title="translate('workspace.more_actions_9f07d2')" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
<Icon name="more" />
</button>
</div>
@@ -214,35 +216,35 @@ function onDrop(e: DragEvent) {
<div v-if="menuOpen" ref="menuElement" class="rm-menu rm-tree-menu" :style="{ left: `${menuPosition.left}px`, top: `${menuPosition.top}px` }" @click.stop>
<template v-if="node.dir && !assetDir && selectedPaths.size === 1">
<button @click="runMenu('note')">
<Icon name="noteAdd" />新建笔记
<Icon name="noteAdd" />{{ translate('workspace.new_note_d3d2d9') }}
</button>
<button @click="runMenu('folder')">
<Icon name="folderAdd" />新建文件夹
<Icon name="folderAdd" />{{ translate('workspace.new_folder_84244a') }}
</button>
<button @click="runMenu('images')">
<Icon name="image" />图片管理
<Icon name="image" />{{ translate('workspace.image_manager_07c502') }}
</button>
<button @click="runMenu('import')">
<Icon name="import" />导入
<Icon name="import" />{{ translate('common.import_576d81') }}
</button>
</template>
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path">
<Icon name="image" />预览 / 替换图片
<Icon name="image" />{{ translate('workspace.preview_replace_image_bb9187') }}
</button>
<button v-if="selectedPaths.size === 1" @click="runMenu('icon')">
<Icon name="palette" />修改图标
<Icon name="palette" />{{ translate('workspace.change_icon_252d1f') }}
</button>
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')">
<Icon name="edit" />重命名
<Icon name="edit" />{{ translate('workspace.rename_0d0cba') }}
</button>
<button v-if="!assetDir" @click="runMenu('move')">
<Icon name="folder" />移动到…
<Icon name="folder" />{{ translate('workspace.move_to_0bee16') }}
</button>
<button @click="runMenu('export')">
<Icon name="note" />导出 Markdown
<Icon name="note" />{{ translate('workspace.export_markdown_ac8b02') }}
</button>
<button v-if="!assetDir" class="danger" @click="runMenu('delete')">
<Icon name="trash" />删除
<Icon name="trash" />{{ translate('common.delete_2f9daa') }}
</button>
</div>
</Teleport>
+1
View File
@@ -2,6 +2,7 @@ import { watchFontLoading } from '@@/appearance/font-loading'
// 编辑器演示页(无需登录),用于性能与交互的自动化测试
import { createEditorState, createEditorView } from '../editor'
import sample from './sample.md?raw'
import '@/i18n'
import '@@/appearance/base.css'
// ?repeat=N 将示例重复 N 次,用于大文档性能测试
+3 -2
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
export interface Draft {
@@ -25,7 +26,7 @@ async function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => ID
return new Promise((resolve, reject) => {
const req = fn(transaction.objectStore('drafts'))
transaction.oncomplete = () => resolve(req.result)
transaction.onabort = () => reject(transaction.error ?? new Error('本地草稿事务已中止'))
transaction.onabort = () => reject(transaction.error ?? new Error(translate('workspace.local_draft_transaction_aborted_67d8fb')))
req.onerror = () => reject(req.error)
})
}
@@ -46,7 +47,7 @@ export const drafts = {
for (const draft of moving) store.add({ ...draft, path: to + draft.path.slice(from.length) })
}
transaction.oncomplete = () => resolve()
transaction.onabort = () => reject(new Error('迁移本地草稿事务失败', { cause: transaction.error }))
transaction.onabort = () => reject(new Error(translate('workspace.could_not_migrate_the_local_3ac936'), { cause: transaction.error }))
})
},
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { StateField } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view'
import { codeContentRange, fenceLanguage } from './codeblocks'
import { editorFocused, setEditorFocus } from './focus'
import { editorLocale } from './locale'
import { CodeRendererWidget, MathWidget } from './widgets'
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
@@ -50,7 +51,8 @@ export const renderedBlocks = StateField.define<DecorationSet>({
create: buildBlocks,
update(value, tr) {
if (
tr.docChanged
tr.startState.facet(editorLocale) !== tr.state.facet(editorLocale)
|| tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus) || e.is(pluginRefresh))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
+2 -1
View File
@@ -1,3 +1,4 @@
import { t as translate } from '@@/i18n/runtime'
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
import TurndownService from 'turndown'
@@ -14,7 +15,7 @@ export async function htmlToMarkdown(html: string, importImage: (url: string) =>
try {
node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href)
} catch (error) {
onError(new Error('来源基址无效,已保留原链接', { cause: error }))
onError(new Error(translate('workspace.invalid_source_base_url_original_f0ec05'), { cause: error }))
}
}
}
+3
View File
@@ -1,6 +1,7 @@
import type { Extension } from '@codemirror/state'
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown'
import { locale } from '@@/i18n/runtime'
import { typoraExtensions } from '@@/markdown/extensions'
import { editorExtensions, markdownSyntax, reportEditorFailure } from '@@/plugins/registry'
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
@@ -12,6 +13,7 @@ import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
import { livePreview, openLink, resolveImageUrl } from './livepreview'
import { editorLocale, localeCompartment } from './locale'
import { defaultEditorKeys } from './shortcuts'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
@@ -56,6 +58,7 @@ export interface EditorStateOptions {
export function createEditorState(opts: EditorStateOptions): EditorState {
const extensions: Extension[] = [
history(),
localeCompartment.of(editorLocale.of(locale.value)),
drawSelection(),
EditorView.lineWrapping,
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
@@ -7,6 +7,7 @@ import { syntaxTree } from '@codemirror/language'
import { Facet, StateEffect } from '@codemirror/state'
import { Decoration, ViewPlugin } from '@codemirror/view'
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
import { editorLocale } from './locale'
import {
BulletWidget,
CheckboxWidget,
@@ -363,6 +364,7 @@ const livePreviewPlugin = ViewPlugin.fromClass(
if (
this.stale
|| u.transactions.some(tr => tr.effects.some(effect => effect.is(refreshImages)))
|| u.startState.facet(editorLocale) !== u.state.facet(editorLocale)
|| u.docChanged
|| u.viewportChanged
|| u.selectionSet
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { applyLocale } from '@@/i18n/runtime'
import { undo, undoDepth } from '@codemirror/commands'
import { afterEach, expect, it, vi } from 'vitest'
import { createEditorState, createEditorView } from './index'
import { editorLocale, localeCompartment } from './locale'
const views: ReturnType<typeof createEditorView>[] = []
afterEach(() => {
views.splice(0).forEach(view => view.destroy())
document.body.replaceChildren()
applyLocale('en')
})
it('切换语言更新表格和代码控件,保留正文、选区、撤销历史及缓存状态', () => {
applyLocale('en')
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nlet x = 1\n```\n\nEnd'
const host = document.createElement('div')
document.body.append(host)
const view = createEditorView(host, createEditorState({ doc }))
views.push(view)
view.dispatch({ changes: { from: doc.length, insert: '!' }, selection: { anchor: doc.length + 1 } })
const content = view.state.doc.toString()
const selection = view.state.selection
const history = undoDepth(view.state)
expect(view.dom.querySelector('.cm-rm-code-copy')?.getAttribute('title')).toBe('Copy code')
expect(view.dom.querySelector('.cm-rm-table-toolbar')?.textContent).toContain('Source')
let cached = createEditorState({ doc: 'Background note' })
applyLocale('zh-CN')
const effects = localeCompartment.reconfigure(editorLocale.of('zh-CN'))
view.dispatch({ effects })
cached = cached.update({ effects }).state
expect(view.state.doc.toString()).toBe(content)
expect(view.state.selection.eq(selection)).toBe(true)
expect(undoDepth(view.state)).toBe(history)
expect(cached.doc.toString()).toBe('Background note')
expect(cached.facet(editorLocale)).toBe('zh-CN')
expect(view.dom.querySelector('.cm-rm-code-copy')?.getAttribute('title')).toBe('复制代码')
expect(view.dom.querySelector('.cm-rm-table-toolbar')?.textContent).toContain('源码')
expect(undo(view)).toBe(true)
expect(view.state.doc.toString()).toBe(doc)
})
it('空 Mermaid 提示随语言双向更新,不改正文或撤销历史', async () => {
applyLocale('en')
const doc = '```mermaid\n\n```\n\nEnd'
const host = document.createElement('div')
document.body.append(host)
const view = createEditorView(host, createEditorState({ doc }))
views.push(view)
view.dispatch({ changes: { from: doc.length, insert: '!' } })
const content = view.state.doc.toString()
const history = undoDepth(view.state)
await vi.waitFor(() => expect(view.dom.querySelector('.cm-rm-mermaid')?.textContent).toBe('Empty diagram'))
for (const [language, label] of [['zh-CN', '空图表'], ['en', 'Empty diagram']] as const) {
applyLocale(language)
view.dispatch({ effects: localeCompartment.reconfigure(editorLocale.of(language)) })
await vi.waitFor(() => expect(view.dom.querySelector('.cm-rm-mermaid')?.textContent).toBe(label))
expect(view.state.doc.toString()).toBe(content)
expect(undoDepth(view.state)).toBe(history)
}
})
+5
View File
@@ -0,0 +1,5 @@
import { locale } from '@@/i18n/runtime'
import { Compartment, Facet } from '@codemirror/state'
export const editorLocale = Facet.define<string, string>({ combine: values => values[0] ?? locale.value })
export const localeCompartment = new Compartment()
+2 -1
View File
@@ -1,4 +1,5 @@
import type { EditorState, Extension, TransactionSpec } from '@codemirror/state'
import { t as translate } from '@@/i18n/runtime'
import { isolateHistory } from '@codemirror/commands'
import { Facet, StateEffect, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
@@ -78,7 +79,7 @@ function insertHTML(view: EditorView, html: string, text: string, opts: PasteIma
})
}
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
opts.onError(new Error(translate('workspace.could_not_convert_the_web_4f0e6a'), { cause: error }))
return text
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
}
+51 -14
View File
@@ -1,4 +1,5 @@
import type { Command, KeyBinding } from '@codemirror/view'
import { t as translate } from '@@/i18n/runtime'
import { typoraKeys } from '@@/utils/typora-keys'
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import { selectCodeBlock } from './codeblocks'
@@ -6,20 +7,56 @@ import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleI
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
export const editorCommands: { id: string, title: string, keys: string[], run: Command }[] = [
{ id: 'editor.bold', title: '粗体', keys: typoraKeys('editor.bold'), run: toggleBold },
{ id: 'editor.italic', title: '斜体', keys: typoraKeys('editor.italic'), run: toggleItalic },
{ id: 'editor.underline', title: '下划线', keys: typoraKeys('editor.underline'), run: toggleUnderline },
{ id: 'editor.strike', title: '删除线', keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
{ id: 'editor.highlight', title: '高亮', keys: [], run: toggleSelectionMark('Highlight') },
{ id: 'editor.code', title: '行内代码', keys: typoraKeys('editor.code'), run: toggleCode },
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, title: { task: '任务列表', bullet: '无序列表', ordered: '有序列表', quote: '引用' }[kind], keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
{ id: 'editor.link', title: '插入链接', keys: typoraKeys('editor.link'), run: insertLink },
{ id: 'editor.undo', title: '撤销', keys: typoraKeys('editor.undo'), run: undo },
{ id: 'editor.redo', title: '重做', keys: typoraKeys('editor.redo'), run: redo },
{ id: 'editor.indent', title: '增加缩进', keys: typoraKeys('editor.indent'), run: indentMore },
{ id: 'editor.outdent', title: '减少缩进', keys: typoraKeys('editor.outdent'), run: indentLess },
{ id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
{ id: 'editor.bold', get title() {
return translate('workspace.bold_f6da93')
}, keys: typoraKeys('editor.bold'), run: toggleBold },
{ id: 'editor.italic', get title() {
return translate('workspace.italic_217901')
}, keys: typoraKeys('editor.italic'), run: toggleItalic },
{ id: 'editor.underline', get title() {
return translate('workspace.underline_d3b4a2')
}, keys: typoraKeys('editor.underline'), run: toggleUnderline },
{ id: 'editor.strike', get title() {
return translate('workspace.strikethrough_5b0508')
}, keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
{ id: 'editor.highlight', get title() {
return translate('workspace.highlight_05f954')
}, keys: [], run: toggleSelectionMark('Highlight') },
{ id: 'editor.code', get title() {
return translate('workspace.inline_code_6711d9')
}, keys: typoraKeys('editor.code'), run: toggleCode },
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, get title() {
return { get task() {
return translate('workspace.task_list_c5ced4')
}, get bullet() {
return translate('workspace.bulleted_list_9ee577')
}, get ordered() {
return translate('workspace.numbered_list_5d0bb2')
}, get quote() {
return translate('workspace.block_quote')
} }[kind]
}, keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
{ id: 'editor.link', get title() {
return translate('workspace.insert_link_1ad3ca')
}, keys: typoraKeys('editor.link'), run: insertLink },
{ id: 'editor.undo', get title() {
return translate('workspace.undo_926a50')
}, keys: typoraKeys('editor.undo'), run: undo },
{ id: 'editor.redo', get title() {
return translate('workspace.redo_03717b')
}, keys: typoraKeys('editor.redo'), run: redo },
{ id: 'editor.indent', get title() {
return translate('workspace.increase_indent_cfd42c')
}, keys: typoraKeys('editor.indent'), run: indentMore },
{ id: 'editor.outdent', get title() {
return translate('workspace.decrease_indent_efd955')
}, keys: typoraKeys('editor.outdent'), run: indentLess },
{ id: 'editor.selectAll', get title() {
return translate('workspace.select_all_select_code_inside_8dfb27')
}, keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, get title() {
return translate('workspace.heading_a4f510', { p0: i + 1 })
}, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
]
export const defaultEditorKeys: KeyBinding[] = editorCommands.flatMap(command => command.keys.map(key => ({ key: key.replaceAll('+', '-').replace(/-[A-Z]$/, letter => letter.toLowerCase()), run: command.run, preventDefault: true })))
+16 -7
View File
@@ -1,6 +1,7 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { locale, t as translate } from '@@/i18n/runtime'
import { renderInlineMarkdown } from '@@/markdown/html'
import { hydrate } from '@@/markdown/render'
import { isolateHistory, redo, undo } from '@codemirror/commands'
@@ -8,6 +9,7 @@ import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { editorFocused, setEditorFocus } from './focus'
import { editorLocale } from './locale'
type Align = '' | 'left' | 'center' | 'right'
@@ -116,6 +118,7 @@ interface WidgetState {
const widgetStates = new WeakMap<HTMLElement, WidgetState>()
class TableWidget extends WidgetType {
readonly locale = locale.value
readonly key: string
constructor(readonly model: TableModel) {
@@ -124,7 +127,7 @@ class TableWidget extends WidgetType {
}
eq(other: TableWidget) {
return other.key === this.key
return other.locale === this.locale && other.key === this.key
}
get estimatedHeight() {
@@ -134,6 +137,7 @@ class TableWidget extends WidgetType {
toDOM(view: EditorView) {
const wrap = document.createElement('div')
wrap.className = 'cm-rm-table-widget'
wrap.lang = this.locale
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
const scroller = document.createElement('div')
@@ -146,6 +150,10 @@ class TableWidget extends WidgetType {
updateDOM(dom: HTMLElement, view: EditorView) {
const st = widgetStates.get(dom)
if (!st) return false
if (dom.lang !== this.locale) {
dom.querySelector('.cm-rm-table-toolbar')!.replaceWith(this.toolbar(view, dom))
dom.lang = this.locale
}
const old = st.model
st.model = this.model
const sameShape
@@ -230,13 +238,13 @@ class TableWidget extends WidgetType {
sep.className = 'cm-rm-sep'
tools.append(sep)
}
button(ICONS.addRow, '行', '在下方插入行', () => insertRow(view, wrap))
button(ICONS.addColumn, '列', '在右侧插入列', () => insertColumn(view, wrap))
button(ICONS.addRow, translate('workspace.line_4cc7b0'), translate('workspace.insert_row_below_7c131f'), () => insertRow(view, wrap))
button(ICONS.addColumn, translate('workspace.column_c06d21'), translate('workspace.insert_column_to_the_right_8ae98f'), () => insertColumn(view, wrap))
separator()
button(ICONS.deleteRow, '行', '删除当前行', () => deleteRow(view, wrap), true)
button(ICONS.deleteColumn, '列', '删除当前列', () => deleteColumn(view, wrap), true)
button(ICONS.deleteRow, translate('workspace.line_4cc7b0'), translate('workspace.delete_current_row_3bc4de'), () => deleteRow(view, wrap), true)
button(ICONS.deleteColumn, translate('workspace.column_c06d21'), translate('workspace.delete_current_column_435bde'), () => deleteColumn(view, wrap), true)
separator()
button(ICONS.source, '源码', '以 Markdown 源码编辑', () => {
button(ICONS.source, translate('workspace.source_41b50a'), translate('workspace.edit_as_markdown_source_9375e8'), () => {
const model = currentModel(view, wrap)
if (!model) return
view.dispatch({ selection: { anchor: model.from } })
@@ -439,7 +447,8 @@ export const tableField = StateField.define<DecorationSet>({
create: buildTables,
update(value, tr) {
if (
tr.docChanged
tr.startState.facet(editorLocale) !== tr.state.facet(editorLocale)
|| tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
+15 -9
View File
@@ -1,4 +1,5 @@
import type { OwnedRenderer } from '@@/plugins/registry'
import { locale, t as translate } from '@@/i18n/runtime'
import { emojiFor, renderMath } from '@@/markdown/render'
import { mountContent } from '@@/plugins/mount'
import { copyText } from '@@/utils/clipboard'
@@ -66,6 +67,7 @@ const CHECK_ICON
// 代码块右上角:语言标签(未激活时)+ 复制按钮
export class CodeHeaderWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly lang: string,
readonly showLang: boolean,
@@ -76,7 +78,7 @@ export class CodeHeaderWidget extends WidgetType {
}
eq(other: CodeHeaderWidget) {
return other.lang === this.lang && other.showLang === this.showLang
return other.locale === this.locale && other.lang === this.lang && other.showLang === this.showLang
}
toDOM(view: EditorView) {
@@ -91,7 +93,7 @@ export class CodeHeaderWidget extends WidgetType {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'cm-rm-code-copy'
btn.title = '复制代码'
btn.title = translate('workspace.copy_code_420c20')
btn.innerHTML = COPY_ICON
btn.addEventListener('mousedown', e => e.preventDefault())
btn.addEventListener('click', async () => {
@@ -175,6 +177,7 @@ function showRenderError(el: HTMLElement, err: unknown) {
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
*/
export class MathWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly tex: string,
readonly display: boolean,
@@ -184,7 +187,7 @@ export class MathWidget extends WidgetType {
}
eq(other: MathWidget) {
return other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
return other.locale === this.locale && other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
}
toDOM(view: EditorView) {
@@ -205,7 +208,7 @@ export class MathWidget extends WidgetType {
private render(el: HTMLElement, view: EditorView) {
if (!this.tex.trim()) {
el.textContent = '空公式'
el.textContent = translate('workspace.empty_formula_fd803b')
el.classList.add('cm-rm-placeholder')
return
}
@@ -224,6 +227,7 @@ const rendererMounts = new WeakMap<HTMLElement, () => void>()
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
export class CodeRendererWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly code: string,
readonly editOffset: number | null,
@@ -235,7 +239,7 @@ export class CodeRendererWidget extends WidgetType {
}
eq(other: CodeRendererWidget) {
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
return other.locale === this.locale && other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
}
toDOM(view: EditorView) {
@@ -313,6 +317,7 @@ export class EmojiWidget extends WidgetType {
}
export class FootnoteRefWidget extends WidgetType {
readonly locale = locale.value
constructor(
readonly label: string,
readonly definition: string | undefined,
@@ -321,14 +326,14 @@ export class FootnoteRefWidget extends WidgetType {
}
eq(other: FootnoteRefWidget) {
return other.label === this.label && other.definition === this.definition
return other.locale === this.locale && other.label === this.label && other.definition === this.definition
}
toDOM(view: EditorView) {
const el = document.createElement('sup')
el.className = 'cm-rm-footnote-ref'
el.textContent = this.label
el.title = this.definition ?? '未找到脚注定义'
el.title = this.definition ?? translate('workspace.footnote_definition_not_found_1c0993')
editOnMousedown(el, view, 2)
return el
}
@@ -341,19 +346,20 @@ export interface TocEntry {
}
export class TocWidget extends WidgetType {
readonly locale = locale.value
constructor(readonly entries: TocEntry[]) {
super()
}
eq(other: TocWidget) {
return JSON.stringify(other.entries) === JSON.stringify(this.entries)
return other.locale === this.locale && JSON.stringify(other.entries) === JSON.stringify(this.entries)
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = 'cm-rm-toc'
if (!this.entries.length) {
el.textContent = '目录(还没有标题)'
el.textContent = translate('workspace.table_of_contents_no_headings_c24b5d')
el.classList.add('cm-rm-placeholder')
}
const minLevel = Math.min(...this.entries.map(e => e.level))
+14 -10
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import AppStatus from '@@/components/AppStatus/index.vue'
import Icon from '@@/components/Icon/index.vue'
import { t as translate } from '@@/i18n/runtime'
import { registerCommand } from '@@/utils/commands'
import { toast } from '@@/utils/notifications'
import { noteTitle } from '@@/utils/paths'
@@ -37,7 +39,7 @@ async function prepareReload() {
const user = useUserStore()
const router = useRouter()
async function prepareMaintenance() {
await flushAll(); if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
await flushAll(); if (!readyToReload()) throw new Error(translate('workspace.finish_saving_uploading_or_working_352bc6'))
}
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const resizingSidebar = ref(false)
@@ -65,7 +67,7 @@ function startResize(e: PointerEvent) {
if (version !== user.getSessionVersion()) return
if (width !== startWidth) {
updateSettings({ sidebarWidth: width }).catch((error) => {
if (!isCancel(error)) toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败')
if (!isCancel(error)) toast(error instanceof SettingsAppearanceError ? error.message : translate('workspace.could_not_save_sidebar_width_8621fe'))
})
}
}
@@ -80,7 +82,7 @@ async function logout() {
await flushAll()
user.assertSession(version)
if (!readyToReload()) {
toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
toast(translate('workspace.finish_saving_pasting_uploading_or_670f95'))
return
}
await user.logout()
@@ -117,13 +119,15 @@ function onTouchMove(e: TouchEvent) {
}
}
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
const unregisterLogout = registerCommand({ id: 'app.logout', get title() {
return translate('common.sign_out_3ab8cc')
}, run: () => void logout() })
onMounted(async () => {
try {
await startWorkspace()
} catch (err) {
if (isCancel(err)) return; toast(`工作区初始化失败,请重新加载:${err instanceof ApiError ? err.message : '网络或本地存储错误'}`)
if (isCancel(err)) return; toast(translate('workspace.workspace_initialization_failed_reload_the_880958', { p0: err instanceof ApiError ? err.message : translate('common.network_or_local_storage_error') }))
} finally {
initializing.value = false
}
@@ -147,21 +151,21 @@ onBeforeUnmount(() => {
<div class="rm-workspace">
<aside class="rm-sidebar">
<Sidebar @logout="logout" />
<div class="rm-sidebar-resize" title="拖动调整宽度" @pointerdown.prevent="startResize" />
<div class="rm-sidebar-resize" :title="translate('workspace.drag_to_resize_b51545')" @pointerdown.prevent="startResize" />
</aside>
<div class="rm-backdrop" @click="ws.sidebarOpen = false" />
<main class="rm-main">
<header class="rm-mobile-bar">
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true">
<button class="rm-icon-btn" :title="translate('workspace.folders_52daa7')" @click="ws.sidebarOpen = true">
<Icon name="menu" />
</button>
<button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">
<button class="rm-mobile-title" :title="translate('workspace.switch_note_649e31')" @click="ws.switcher = ''">
{{ title }}
</button>
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''">
<button class="rm-icon-btn" :title="translate('workspace.quick_open_13e215')" @click="ws.switcher = ''">
<Icon name="search" />
</button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'">
<button class="rm-icon-btn" :title="translate('common.command_928f87')" @click="ws.switcher = '>'">
<Icon name="command" />
</button>
</header>
+386
View File
@@ -0,0 +1,386 @@
{
"move_the_selected_items_to_3d5e79": "Move the selected {p0} items to trash? Folder contents will also be removed.",
"could_not_refresh_the_file_b5f68b": "Could not refresh the file tree: {p0}",
"finished_succeeded_failed_dbc164": "{p0} finished: {p1} succeeded, {p2} failed{p3}",
"save_your_notes_and_resolve_377e42": "Save your notes and resolve conflicts before exporting",
"export_zip_9cdf3e": "Export.zip",
"export_failed_a53238": "Export failed: {p0}",
"note_actions_afcc30": "Note actions",
"live_preview_195a67": "Live preview",
"source_mode_11b7fd": "Source mode",
"show_hide_outline_c41996": "Show / hide outline",
"focus_mode_b35534": "Focus mode",
"select_or_create_a_note_669f82": "Select or create a note",
"could_not_open_this_note_634236": "Could not open this note",
"image_names_cannot_be_empty_9499af": "Image names cannot be empty, start with a dot, or contain slashes",
"change_applied_but_synchronization_failed_8a50cd": "Change applied, but synchronization failed. Check the file tree",
"images_must_not_exceed_mb_61d721": "Images must not exceed 50 MB",
"choose_a_file_with_the_97e882": "Choose a file with the same extension as the original image",
"replacing_this_image_affects_all_107587": "Replacing this image affects all notes referencing this path. The old image will not go to trash. Replace it?",
"network_error_replacement_status_is_8b54f6": "Network error. Replacement status is unknown. Check the image before trying again",
"move_this_image_to_trash_c979ba": "Move this image to trash? Notes referencing it will temporarily lose the image. Restoring it will restore those references.",
"image_manager_07c502": "Image manager",
"this_image_no_longer_exists_50dc1d": "This image no longer exists. Refresh the file tree",
"cannot_preview_this_image_it_046111": "Cannot preview this image. It may be damaged or use an unsupported format.",
"previous_image_bcf200": "Previous image",
"next_image_dcd4eb": "Next image",
"image_name_edb98e": "Image name",
"rename_0d0cba": "Rename",
"renaming_updates_local_references_in_bc3d12": "Renaming updates local references in notes. Replacing keeps the original path; choose the same image format (up to 50 MB).",
"move_to_0bee16": "Move to…",
"move_to_trash_361bdf": "Move to trash",
"processing_d3d211": "Processing…",
"replace_image_dbc39d": "Replace image",
"choose_a_non_empty_image_c3cdc3": "{p0}: Choose a non-empty image no larger than 50 MB",
"processed_images_identical_content_is_9c3f9a": "Processed {p0} images. Identical content is deduplicated automatically",
"upload_complete_but_refresh_failed_a8fdfb": "Upload complete, but refresh failed. Check the file tree: {p0}",
"references_are_not_confirmed_cleanup_76caea": "References are not confirmed. Cleanup candidates cannot be listed",
"moved_images_to_trash_c5b457": "Moved {count} image to trash | Moved {count} images to trash",
"cleanup_failed_86823d": "Cleanup failed: {p0}",
"image_library_92329a": "Image library",
"images_and_visual_resources_794f97": "Images and visual resources",
"entire_vault_a8e1f0": "Entire vault",
"current_folder_and_direct_attachments_c2a863": "{p0} · Current folder and direct attachments",
"images_b62646": "images ·",
"back_to_notes_38461a": "Back to notes",
"resource_scope_9c55b5": "Resource scope",
"current_folder_257f44": "Current folder",
"references_92fb1e": "References",
"all_images_048d65": "All images",
"referenced_981f27": "Referenced",
"unreferenced_01e3bd": "Unreferenced",
"clean_unused_images_f4eb11": "Clean unused images",
"root_folder_42ee28": "Root folder",
"organize_resources_ecf902": "Organize resources",
"checks_references_across_the_vault_371581": "Checks references across the vault before cleanup. Images are moved to trash and can be restored.",
"search_image_names_or_paths_944196": "Search image names or paths…",
"search_images_8f938f": "Search images",
"image_format_f2ed45": "Image format",
"all_formats_c8b740": "All formats",
"image_sort_order_34877a": "Image sort order",
"last_modified_ad3bf1": "Last modified",
"file_size_b9bf69": "File size",
"grid_view_709ce2": "Grid view",
"list_view_383611": "List view",
"upload_to_46873e": "Upload to",
"upload_folder_462e85": "Upload folder",
"root_assets_c9d759": "Root / assets",
"upload_images_9fbb47": "Upload images",
"some_note_metadata_is_invalid_26b8ec": "Some note metadata is invalid, so reference information is incomplete. Cleanup is disabled.",
"reading_reference_index_8392a7": "Reading reference index…",
"refresh_status_802a40": "Refresh status",
"references_9aa726": "{count} reference | {count} references",
"references_not_confirmed_5abb9b": "References not confirmed",
"no_matching_images_129380": "No matching images",
"close_details_9071cf": "Close details",
"cannot_preview_this_image_99dab9": "Cannot preview this image",
"dimensions_246df7": "Dimensions",
"cannot_read_c27041": "Cannot read",
"loading_4927a5": "Loading…",
"size_50db74": "Size",
"modified_257bbc": "Modified",
"references_e0ec65": "References",
"notes_fd9460": "{count} note | {count} notes",
"not_confirmed_10e9fc": "Not confirmed",
"preview_and_edit_d797af": "Preview and edit",
"notes_referencing_this_image_b2a9ac": "Notes referencing this image",
"no_notes_reference_this_image_aab134": "No notes reference this image",
"cleanup_candidates_images_9cd82b": "Cleanup candidates · {count} image | Cleanup candidates · {count} images",
"the_server_will_check_the_cce22a": "The server will check the latest notes again. Images still in use will be skipped. Confirm to move unused images to trash.",
"no_unused_images_to_clean_ad136a": "No unused images to clean in this scope.",
"move_to_trash_911c22": "Move to trash (",
"choose_markdown_files_or_a_a06bed": "Choose Markdown files, or a single ZIP file",
"total_import_size_must_not_23ac37": "Total import size must not exceed 100 MB",
"enter_an_import_folder_name_6be2a3": "Enter an import folder name",
"network_error_import_status_is_fb8726": "Network error. Import status is unknown. Check the destination folder before trying again",
"import_markdown_fb4d82": "Import Markdown",
"import_to_020113": "Import to:",
"choose_markdown_files_or_a_88d508": "Choose Markdown files or a ZIP",
"new_folder_name_31b655": "New folder name",
"import_folder_name_fba3f9": "Import folder name",
"total_upload_limit_mb_zip_fa43d1": "Total upload limit: 100 MB. ZIP folders are preserved and local images become note attachments. Existing content is not overwritten. Importing Markdown files alone does not include local attachments.",
"import_complete_96bfaa": "Import complete:",
"notes_56c9da": "notes,",
"attachments_including_independent_copies_162f1e": "attachments (including independent copies).",
"notes_have_link_warnings_1462a8": "notes have link warnings",
"uploading_and_organizing_keep_this_2be889": "Uploading and organizing. Keep this page open…",
"done_c0b3fb": "Done",
"importing_2f58b6": "Importing…",
"let_knowledge_connect_22f6c5": "Let knowledge connect",
"tags_02b344": "Tags",
"notes_connect_your_ideas_with_9f4998": "notes. Connect your ideas with standard Markdown links and tags.",
"search_notes_21919c": "Search notes…",
"search_notes_d8912a": "Search notes",
"filter_tags_2d8b5c": "Filter tags",
"all_tags_b709cb": "All tags",
"only_related_to_this_note_d6f457": "Only related to this note",
"preparing_relationship_index_1d8bb5": "Preparing relationship index…",
"note_relationship_graph_click_a_5a4781": "Note relationship graph. Click a node to open its note",
"no_matching_notes_215bb5": "No matching notes",
"nodes_09c858": "nodes ·",
"links_253aaf": "links",
"zoom_in_80f8fb": "Zoom in",
"zoom_out_290f68": "Zoom out",
"reset_view_764aba": "Reset view",
"link_targets_do_not_exist_074109": "link targets do not exist yet",
"line_ca0b53": "· Line",
"line_4cc7b0": "line",
"all_5c55a6": "All",
"items_980fda": "{count} item | {count} items",
"some_items_could_not_be_8f48b2": "Some items could not be moved. Check the file tree and retry",
"move_e3e7e7": "Move “{p0}”",
"move_to_d652dc": "Move “{p0}” to…",
"search_destination_folders_95ab69": "Search destination folders",
"no_available_destination_folders_d4d0c2": "No available destination folders",
"this_note_was_changed_elsewhere_641d37": "This note was changed elsewhere and conflicts with your changes.",
"load_server_version_b24209": "Load server version",
"keep_my_changes_cf78a9": "Keep my changes",
"this_note_was_deleted_or_07470b": "This note was deleted or moved elsewhere.",
"save_again_e18806": "Save again",
"retry_now_77c30e": "Retry now",
"untitled_7633ed": "(Untitled)",
"outline_79fadb": "Outline",
"close_outline_1b401a": "Close outline",
"no_headings_c76538": "No headings",
"create_note_8148f2": "Create note “{p0}”",
"command_palette_4b495d": "Command palette",
"quick_open_13e215": "Quick open",
"enter_a_command_name_305f5a": "Enter a command name",
"enter_a_note_name_to_90941b": "Enter a note name to open or create it; type > for commands",
"search_commands_a72832": "Search commands",
"no_matching_commands_556513": "No matching commands",
"no_notes_yet_871745": "No notes yet",
"close_search_ab008e": "Close search",
"no_matching_notes_found_d87b01": "No matching notes found",
"enter_keywords_separated_by_spaces_a25285": "Enter keywords separated by spaces",
"bold_5ceac6": "Bold",
"italic_217901": "Italic",
"underline_d3b4a2": "Underline",
"strikethrough_5b0508": "Strikethrough",
"highlight_05f954": "Highlight",
"format_selected_text_bd0a7d": "Format selected text",
"link_305378": "Link",
"collapse_sidebar_7b4c4c": "Collapse sidebar",
"file_tree_actions_ac6020": "File tree actions",
"files_ef81f8": "Files",
"sidebar_view_3d4d03": "Sidebar view",
"files_39932f": "Files",
"search_44ce7a": "Search",
"new_note_d3d2d9": "New note",
"new_folder_d5f6f6": "New folder",
"import_documents_8b1183": "Import documents",
"pinned_317c1a": "Pinned",
"close_3fd47e": "Close",
"unpin_20590a": "Unpin",
"pin_d4b3f4": "Pin",
"close_other_tabs_701003": "Close other tabs",
"close_tabs_to_the_right_5fcad9": "Close tabs to the right",
"could_not_load_emoji_ec4192": "Could not load emoji: {p0}",
"could_not_save_icon_110c4c": "Could not save icon: {p0}",
"change_icon_252d1f": "Change icon",
"close_icon_picker_a76f13": "Close icon picker",
"icon_type_5e85b1": "Icon type",
"emoji_c762ed": "Emoji",
"line_icons_5948d6": "Line icons",
"search_icons_chinese_english_653965": "Search icons (Chinese / English)",
"search_icons_ebb240": "Search icons",
"emoji_category_8c37bd": "Emoji category",
"recent_39a904": "Recent",
"recent_9793c5": "Recent: {p0}",
"loading_emoji_f7826c": "Loading emoji…",
"no_icons_found_try_different_14e89f": "No icons found. Try different keywords",
"reset_icon_0ed903": "Reset icon",
"change_icon_2fad4d": "Change icon: {p0}",
"more_actions_9f07d2": "More actions",
"new_folder_84244a": "New folder",
"preview_replace_image_bb9187": "Preview / replace image",
"export_markdown_ac8b02": "Export Markdown",
"local_draft_transaction_aborted_67d8fb": "Local draft transaction aborted",
"could_not_migrate_the_local_3ac936": "Could not migrate the local draft transaction",
"invalid_source_base_url_original_f0ec05": "Invalid source base URL. Original links retained",
"could_not_convert_the_web_4f0e6a": "Could not convert the web page. Plain clipboard text retained",
"bold_f6da93": "Bold",
"inline_code_6711d9": "Inline code",
"task_list_c5ced4": "Task list",
"bulleted_list_9ee577": "Bulleted list",
"numbered_list_5d0bb2": "Numbered list",
"insert_link_1ad3ca": "Insert link",
"undo_926a50": "Undo",
"redo_03717b": "Redo",
"increase_indent_cfd42c": "Increase indent",
"decrease_indent_efd955": "Decrease indent",
"select_all_select_code_inside_8dfb27": "Select all (select code inside a code block first)",
"heading_a4f510": "Heading {p0}",
"insert_row_below_7c131f": "Insert row below",
"column_c06d21": "Column",
"insert_column_to_the_right_8ae98f": "Insert column to the right",
"delete_current_row_3bc4de": "Delete current row",
"delete_current_column_435bde": "Delete current column",
"source_41b50a": "Source",
"edit_as_markdown_source_9375e8": "Edit as Markdown source",
"copy_code_420c20": "Copy code",
"empty_formula_fd803b": "Empty formula",
"footnote_definition_not_found_1c0993": "Footnote definition not found",
"table_of_contents_no_headings_c24b5d": "Table of contents (no headings yet)",
"finish_saving_uploading_or_working_352bc6": "Finish saving, uploading, or working with files before continuing",
"could_not_save_sidebar_width_8621fe": "Could not save sidebar width",
"finish_saving_pasting_uploading_or_670f95": "Finish saving, pasting, uploading, or working with files before signing out",
"workspace_initialization_failed_reload_the_880958": "Workspace initialization failed. Reload the page: {p0}",
"drag_to_resize_b51545": "Drag to resize",
"folders_52daa7": "Folders",
"switch_note_649e31": "Switch note",
"could_not_load_the_relationship_15e3ca": "Could not load the relationship index",
"plus_3d5f3d": "Plus",
"view_db8db0": "View",
"expand_00bd39": "Expand",
"calendar_a00f29": "Calendar",
"tag_1d0fd5": "Tag",
"task_525304": "Task",
"print_d7bfe7": "Print",
"grid_e41ecc": "Grid",
"menu_4ce4ca": "Menu",
"folder_7c7802": "Folder",
"image_d24c10": "Image",
"document_2687cc": "Document",
"arrow_83fa01": "Arrow",
"more_38844b": "More",
"key_6d1327": "Key",
"palette_57d285": "Palette",
"user_0d0e1a": "User",
"code_e6f04f": "Code",
"info_e70286": "Info",
"people_8d7aff": "People",
"nature_4f1240": "Nature",
"food_ab2775": "Food",
"travel_e751bc": "Travel",
"activities_9e9bea": "Activities",
"objects_7c89d6": "Objects",
"symbols_e89265": "Symbols",
"flags_f5aee0": "Flags",
"folder_directory_open_3744fe": "folder directory open",
"folder_directory_796213": "folder directory",
"document_note_article_28eb56": "document note article",
"note_edit_record_87af46": "note edit record",
"document_article_2d7c78": "document article",
"checklist_clipboard_032cfe": "checklist clipboard",
"books_knowledge_learning_ef0c86": "books knowledge learning",
"reading_books_0d07d9": "reading books",
"books_red_book_86db47": "books red book",
"books_green_book_e2ab64": "books green book",
"books_blue_book_3f6b60": "books blue book",
"books_orange_book_1ff6c9": "books orange book",
"notebook_155c2f": "notebook",
"ledger_notebook_b17498": "ledger notebook",
"notebook_journal_f3f1a0": "notebook journal",
"calendar_date_0a71ec": "calendar date",
"calendar_plan_54e7ab": "calendar plan",
"done_task_checkmark_9071c9": "done task checkmark",
"checkbox_task_064c0e": "checkbox task",
"star_favorite_b042d9": "star favorite",
"star_important_8cf705": "star important",
"heart_like_37e5e5": "heart like",
"fire_popular_48643d": "fire popular",
"lightbulb_inspiration_idea_404d08": "lightbulb inspiration idea",
"pin_30251e": "pin",
"lock_security_ca4da0": "lock security",
"key_secret_f6132e": "key secret",
"settings_gear_08cbae": "settings gear",
"tools_development_66775a": "tools development",
"computer_programming_code_e80796": "computer programming code",
"computer_monitor_096473": "computer monitor",
"phone_2e0733": "Phone",
"images_album_e7c28d": "images album",
"art_design_palette_7177c2": "art design palette",
"music_db9514": "Music",
"film_video_78c13a": "film video",
"camera_photography_50a8c0": "camera photography",
"game_847c5f": "Game",
"house_home_6cddfb": "house home",
"home_house_e9cb84": "home house",
"rocket_project_release_85d3b1": "rocket project release",
"work_briefcase_f2fcc3": "work briefcase",
"target_57060c": "Target",
"earth_world_c6983f": "earth world",
"earth_asia_b820a1": "earth Asia",
"seedling_growth_5d0cd3": "seedling growth",
"plant_leaf_nature_d57c83": "plant leaf nature",
"flower_cherry_blossom_c6af13": "flower cherry blossom",
"sun_sunny_c1c7c6": "sun sunny",
"moon_night_8d87a0": "moon night",
"cat_animal_7759cf": "cat animal",
"dog_animal_872611": "dog animal",
"apple_food_d549ae": "apple food",
"coffee_drink_1d3d41": "coffee drink",
"smile_happy_emoji_3b25f0": "smile happy emoji",
"smiling_happy_emoji_5fab64": "smiling happy emoji",
"thinking_idea_fd06c2": "thinking idea",
"thumbs_up_good_7d9981": "thumbs up good",
"warning_attention_a3fe1d": "warning attention",
"exclamation_important_5a619b": "exclamation important",
"question_03c022": "question",
"money_finance_13980e": "money finance",
"graduation_learning_f0d5cf": "graduation learning",
"this_note_is_moving_finish_d105e5": "This note is moving. Finish before pasting or uploading images",
"the_note_receiving_this_paste_a59580": "The note receiving this paste has closed",
"the_editor_state_for_the_028961": "The editor state for the note receiving this paste no longer exists",
"paste_or_image_import_failed_09d4e6": "Paste or image import failed: {p0}",
"processing_paste_or_image_import_023dad": "Processing paste or image import…",
"this_note_has_unsaved_changes_f14427": "This note has unsaved changes or an operation in progress. Retry later",
"a_write_requires_a_non_b12fd4": "A write requires a non-empty revision or an explicit create flag",
"save_failed_retrying_d62793": "Save failed. Retrying…",
"save_failed_check_your_connection_c0913f": "Save failed. Check your connection and retry",
"save_failed_6ba723": "Save failed: {p0}",
"load_failed_396d55": "Load failed: {p0}",
"this_note_is_saving_uploading_c23675": "This note is saving, uploading, or moving. Finish before resolving conflicts",
"the_server_version_changed_again_594f7d": "The server version changed again. Choose how to proceed",
"could_not_open_0ed1e1": "Could not open: {p0}",
"this_note_is_uploading_or_349b3d": "This note is uploading or moving. Finish before closing it",
"the_editor_state_for_this_248447": "The editor state for this unsaved note no longer exists",
"could_not_save_the_local_cc0e42": "Could not save the local draft. The tab has been kept open: {p0}",
"unsaved_changes_retained_as_a_8cb2ab": "Unsaved changes retained as a local draft",
"icon_saved_but_the_file_72a5c2": "Icon saved, but the file tree could not be refreshed: {p0}",
"untitled_67bee6": "Untitled",
"could_not_create_note_2760ce": "Could not create note: {p0}",
"import_complete_but_the_file_52460f": "Import complete, but the file tree could not be refreshed: {p0}",
"could_not_create_folder_bc6447": "Could not create folder: {p0}",
"this_note_has_not_been_77a7a9": "This note has not been created successfully",
"already_exists_6af434": "“{p0}” already exists",
"this_note_is_processing_a_8c2cce": "This note is processing a paste, upload, or move. Finish before moving it",
"save_your_notes_and_resolve_5500d4": "Save your notes and resolve conflicts before moving files",
"could_not_restore_the_draft_e5f464": "Could not restore the draft at its original path: {p0}",
"move_failed_04b0a9": "Move failed: {p0}",
"file_moved_but_note_synchronization_b9ff79": "File moved, but note synchronization failed: {p0}",
"file_no_longer_exists_4eb446": "File no longer exists",
"save_your_notes_and_resolve_5981a5": "Save your notes and resolve conflicts before deleting files",
"could_not_close_the_note_cd2a80": "Could not close the note. No files were deleted",
"deleted_86a46b": "Deleted “{p0}”",
"restore_failed_283a6e": "Restore failed: {p0}",
"image_replaced_but_the_file_bb4848": "Image replaced, but the file tree could not be refreshed: {p0}",
"could_not_synchronize_settings_7e5bb1": "Could not synchronize settings: {p0}",
"could_not_migrate_the_local_4c513a": "Could not migrate the local draft. The original draft was retained: {p0}",
"could_not_load_settings_246415": "Could not load settings: {p0}",
"could_not_restore_the_local_a275e8": "Could not restore the local draft: {p0}",
"synchronization_failed_c6eeea": "Synchronization failed: {p0}",
"quick_open_note_0d376f": "Quick open note",
"full_text_search_a6c47a": "Full-text search",
"save_current_note_5a7e29": "Save current note",
"rename_current_note_de288b": "Rename current note",
"move_current_note_to_f1fc16": "Move current note to…",
"delete_current_note_657a00": "Delete current note",
"insert_image_52c554": "Insert image…",
"close_current_tab_cc8a4e": "Close current tab",
"show_hide_sidebar_96b1a4": "Show / hide sidebar",
"show_hide_outline_1da8d2": "Show / hide outline",
"toggle_source_editing_602b33": "Toggle source editing",
"toggle_focus_mode_b52fb6": "Toggle focus mode",
"open_trash_8a939d": "Open trash",
"manage_root_folder_images_fed368": "Manage root folder images",
"could_not_save_the_local_4b21e1": "Could not save the local draft. Keep this page open",
"workspace_closed_221ea3": "Workspace closed",
"invalid_link_format_651be5": "Invalid link format",
"unsupported_link_protocol_cf426f": "Unsupported link protocol",
"invalid_link_encoding_2c6a7e": "Invalid link encoding",
"link_target_does_not_exist_2503d1": "Link target does not exist: {p0}",
"finish_saving_pasting_uploading_or_e67d3d": "Finish saving, pasting, uploading, or working with files before cleaning up",
"block_quote": "Block quote"
}
+386
View File
@@ -0,0 +1,386 @@
{
"move_the_selected_items_to_3d5e79": "将选中的 {p0} 项移入回收站?文件夹内的内容也会一起删除。",
"could_not_refresh_the_file_b5f68b": "刷新目录失败:{p0}",
"finished_succeeded_failed_dbc164": "{p0}完成:成功 {p1} 项,失败 {p2} 项{p3}",
"save_your_notes_and_resolve_377e42": "请先保存笔记并解决冲突,再导出",
"export_zip_9cdf3e": "导出.zip",
"export_failed_a53238": "导出失败:{p0}",
"note_actions_afcc30": "笔记操作",
"live_preview_195a67": "即时预览",
"source_mode_11b7fd": "源码模式",
"show_hide_outline_c41996": "显示 / 隐藏大纲",
"focus_mode_b35534": "专注写作",
"select_or_create_a_note_669f82": "选择或新建一篇笔记",
"could_not_open_this_note_634236": "笔记打开失败",
"image_names_cannot_be_empty_9499af": "图片名称不能为空、以点开头或包含斜杠",
"change_applied_but_synchronization_failed_8a50cd": "修改后同步失败,请检查目录",
"images_must_not_exceed_mb_61d721": "图片不能超过 50 MB",
"choose_a_file_with_the_97e882": "请选择与原图片扩展名相同的文件",
"replacing_this_image_affects_all_107587": "替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?",
"network_error_replacement_status_is_8b54f6": "网络错误,替换结果尚未确认,请检查图片后再重试",
"move_this_image_to_trash_c979ba": "将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。",
"image_manager_07c502": "图片管理",
"this_image_no_longer_exists_50dc1d": "图片已不存在,请刷新目录",
"cannot_preview_this_image_it_046111": "图片无法预览,文件可能损坏或浏览器不支持此格式。",
"previous_image_bcf200": "上一张",
"next_image_dcd4eb": "下一张",
"image_name_edb98e": "图片名称",
"rename_0d0cba": "重命名",
"renaming_updates_local_references_in_bc3d12": "改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。",
"move_to_0bee16": "移动到…",
"move_to_trash_361bdf": "移入回收站",
"processing_d3d211": "处理中…",
"replace_image_dbc39d": "替换图片",
"choose_a_non_empty_image_c3cdc3": "{p0}:请选择非空图片,单张最大 50 MB",
"processed_images_identical_content_is_9c3f9a": "已处理 {p0} 张图片,相同内容自动去重",
"upload_complete_but_refresh_failed_a8fdfb": "上传后刷新失败,请重新检查目录:{p0}",
"references_are_not_confirmed_cleanup_76caea": "引用状态尚未确认,不能列出清理候选",
"moved_images_to_trash_c5b457": "已将 {count} 张图片移入回收站",
"cleanup_failed_86823d": "清理失败:{p0}",
"image_library_92329a": "图片库",
"images_and_visual_resources_794f97": "图片与灵感资源",
"entire_vault_a8e1f0": "整个知识库",
"current_folder_and_direct_attachments_c2a863": "{p0} · 当前目录及直属附件",
"images_b62646": "张图片 ·",
"back_to_notes_38461a": "返回笔记",
"resource_scope_9c55b5": "资源范围",
"current_folder_257f44": "当前目录",
"references_92fb1e": "引用状态",
"all_images_048d65": "全部图片",
"referenced_981f27": "已被引用",
"unreferenced_01e3bd": "未被引用",
"clean_unused_images_f4eb11": "清理孤立图片",
"root_folder_42ee28": "根目录",
"organize_resources_ecf902": "整理资源",
"checks_references_across_the_vault_371581": "清理前核对全库引用,图片移入回收站,可恢复。",
"search_image_names_or_paths_944196": "搜索图片名称或路径…",
"search_images_8f938f": "搜索图片",
"image_format_f2ed45": "图片格式",
"all_formats_c8b740": "所有格式",
"image_sort_order_34877a": "图片排序",
"last_modified_ad3bf1": "最近修改",
"file_size_b9bf69": "文件大小",
"grid_view_709ce2": "切换网格",
"list_view_383611": "切换列表",
"upload_to_46873e": "上传至",
"upload_folder_462e85": "上传目录",
"root_assets_c9d759": "根目录 / assets",
"upload_images_9fbb47": "上传图片",
"some_note_metadata_is_invalid_26b8ec": "部分笔记元数据异常,引用状态不完整;已禁用清理。",
"reading_reference_index_8392a7": "正在读取引用索引…",
"refresh_status_802a40": "刷新状态",
"references_9aa726": "{count} 篇引用",
"references_not_confirmed_5abb9b": "引用未确认",
"no_matching_images_129380": "没有符合条件的图片",
"close_details_9071cf": "关闭详情",
"cannot_preview_this_image_99dab9": "无法预览此图片",
"dimensions_246df7": "尺寸",
"cannot_read_c27041": "无法读取",
"loading_4927a5": "加载中…",
"size_50db74": "大小",
"modified_257bbc": "修改时间",
"references_e0ec65": "引用",
"notes_fd9460": "{count} 篇笔记",
"not_confirmed_10e9fc": "尚未确认",
"preview_and_edit_d797af": "预览与编辑",
"notes_referencing_this_image_b2a9ac": "引用此图片的笔记",
"no_notes_reference_this_image_aab134": "没有笔记引用",
"cleanup_candidates_images_9cd82b": "清理候选 · {count} 张",
"the_server_will_check_the_cce22a": "服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。",
"no_unused_images_to_clean_ad136a": "当前范围没有可清理的孤立图片。",
"move_to_trash_911c22": "移入回收站(",
"choose_markdown_files_or_a_a06bed": "请选择 .md 文件,或单独选择一个 ZIP",
"total_import_size_must_not_23ac37": "导入文件总量不能超过 100 MB",
"enter_an_import_folder_name_6be2a3": "请输入导入文件夹名称",
"network_error_import_status_is_fb8726": "网络错误,导入结果尚未确认,请检查目标目录后再重试",
"import_markdown_fb4d82": "导入 Markdown",
"import_to_020113": "导入到:",
"choose_markdown_files_or_a_88d508": "选择 Markdown 文件或一个 ZIP",
"new_folder_name_31b655": "新建文件夹名称",
"import_folder_name_fba3f9": "导入文件名称",
"total_upload_limit_mb_zip_fa43d1": "总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。",
"import_complete_96bfaa": "导入完成:",
"notes_56c9da": "篇笔记,",
"attachments_including_independent_copies_162f1e": "个附件(含独立副本)。",
"notes_have_link_warnings_1462a8": "篇笔记有链接提示",
"uploading_and_organizing_keep_this_2be889": "正在上传并整理,请勿关闭页面…",
"done_c0b3fb": "完成",
"importing_2f58b6": "导入中…",
"let_knowledge_connect_22f6c5": "让知识自然连接",
"tags_02b344": "标签索引",
"notes_connect_your_ideas_with_9f4998": "篇笔记,以标准 Markdown 链接与标签连接你的想法。",
"search_notes_21919c": "搜索笔记…",
"search_notes_d8912a": "搜索笔记",
"filter_tags_2d8b5c": "筛选标签",
"all_tags_b709cb": "全部标签",
"only_related_to_this_note_d6f457": "仅当前笔记关联",
"preparing_relationship_index_1d8bb5": "关系索引准备中…",
"note_relationship_graph_click_a_5a4781": "笔记关系图谱,可点击节点打开笔记",
"no_matching_notes_215bb5": "没有符合条件的笔记",
"nodes_09c858": "节点 ·",
"links_253aaf": "关系",
"zoom_in_80f8fb": "放大",
"zoom_out_290f68": "缩小",
"reset_view_764aba": "重置视图",
"link_targets_do_not_exist_074109": "个链接目标尚不存在",
"line_ca0b53": "· 第",
"line_4cc7b0": "行",
"all_5c55a6": "全部",
"items_980fda": "{count} 项",
"some_items_could_not_be_8f48b2": "部分项目移动失败,请核对目录后重试",
"move_e3e7e7": "移动“{p0}”",
"move_to_d652dc": "把“{p0}”移动到…",
"search_destination_folders_95ab69": "搜索目标文件夹",
"no_available_destination_folders_d4d0c2": "没有可移动到的文件夹",
"this_note_was_changed_elsewhere_641d37": "这篇笔记已在其他地方被修改,与你的修改冲突。",
"load_server_version_b24209": "载入服务器版本",
"keep_my_changes_cf78a9": "保留我的修改",
"this_note_was_deleted_or_07470b": "这篇笔记已在其他地方被删除或移动。",
"save_again_e18806": "重新保存",
"retry_now_77c30e": "立即重试",
"untitled_7633ed": "(无标题)",
"outline_79fadb": "大纲",
"close_outline_1b401a": "关闭大纲",
"no_headings_c76538": "没有标题",
"create_note_8148f2": "新建笔记“{p0}”",
"command_palette_4b495d": "命令面板",
"quick_open_13e215": "快速打开",
"enter_a_command_name_305f5a": "输入命令名称",
"enter_a_note_name_to_90941b": "输入笔记名称,回车打开或新建;输入 > 执行命令",
"search_commands_a72832": "搜索命令",
"no_matching_commands_556513": "没有匹配的命令",
"no_notes_yet_871745": "还没有笔记",
"close_search_ab008e": "关闭搜索",
"no_matching_notes_found_d87b01": "没有找到匹配的笔记",
"enter_keywords_separated_by_spaces_a25285": "输入关键词,多个词用空格分隔",
"bold_5ceac6": "加粗",
"italic_217901": "斜体",
"underline_d3b4a2": "下划线",
"strikethrough_5b0508": "删除线",
"highlight_05f954": "高亮",
"format_selected_text_bd0a7d": "选中文本格式",
"link_305378": "链接",
"collapse_sidebar_7b4c4c": "折叠目录",
"file_tree_actions_ac6020": "文件目录操作",
"files_ef81f8": "文件目录",
"sidebar_view_3d4d03": "侧栏视图",
"files_39932f": "文件",
"search_44ce7a": "搜索",
"new_note_d3d2d9": "新建笔记",
"new_folder_d5f6f6": "新建目录",
"import_documents_8b1183": "导入文档",
"pinned_317c1a": "已固定",
"close_3fd47e": "关闭",
"unpin_20590a": "取消固定",
"pin_d4b3f4": "固定",
"close_other_tabs_701003": "关闭其他标签页",
"close_tabs_to_the_right_5fcad9": "关闭右侧标签页",
"could_not_load_emoji_ec4192": "表情加载失败:{p0}",
"could_not_save_icon_110c4c": "图标保存失败:{p0}",
"change_icon_252d1f": "修改图标",
"close_icon_picker_a76f13": "关闭图标选择",
"icon_type_5e85b1": "图标类型",
"emoji_c762ed": "表情",
"line_icons_5948d6": "线性图标",
"search_icons_chinese_english_653965": "搜索图标(中文 / English)",
"search_icons_ebb240": "搜索图标",
"emoji_category_8c37bd": "表情分类",
"recent_39a904": "最近使用",
"recent_9793c5": "最近:{p0}",
"loading_emoji_f7826c": "正在加载表情…",
"no_icons_found_try_different_14e89f": "没有找到图标,试试其他关键词",
"reset_icon_0ed903": "恢复默认图标",
"change_icon_2fad4d": "修改图标:{p0}",
"more_actions_9f07d2": "更多操作",
"new_folder_84244a": "新建文件夹",
"preview_replace_image_bb9187": "预览 / 替换图片",
"export_markdown_ac8b02": "导出 Markdown",
"local_draft_transaction_aborted_67d8fb": "本地草稿事务已中止",
"could_not_migrate_the_local_3ac936": "迁移本地草稿事务失败",
"invalid_source_base_url_original_f0ec05": "来源基址无效,已保留原链接",
"could_not_convert_the_web_4f0e6a": "网页转换失败,已保留剪贴板纯文本",
"bold_f6da93": "粗体",
"inline_code_6711d9": "行内代码",
"task_list_c5ced4": "任务列表",
"bulleted_list_9ee577": "无序列表",
"numbered_list_5d0bb2": "有序列表",
"insert_link_1ad3ca": "插入链接",
"undo_926a50": "撤销",
"redo_03717b": "重做",
"increase_indent_cfd42c": "增加缩进",
"decrease_indent_efd955": "减少缩进",
"select_all_select_code_inside_8dfb27": "全选(代码块内优先选中代码块)",
"heading_a4f510": "设置 {p0} 级标题",
"insert_row_below_7c131f": "在下方插入行",
"column_c06d21": "列",
"insert_column_to_the_right_8ae98f": "在右侧插入列",
"delete_current_row_3bc4de": "删除当前行",
"delete_current_column_435bde": "删除当前列",
"source_41b50a": "源码",
"edit_as_markdown_source_9375e8": "以 Markdown 源码编辑",
"copy_code_420c20": "复制代码",
"empty_formula_fd803b": "空公式",
"footnote_definition_not_found_1c0993": "未找到脚注定义",
"table_of_contents_no_headings_c24b5d": "目录(还没有标题)",
"finish_saving_uploading_or_working_352bc6": "仍有未保存内容或正在上传、操作文件,请完成后再试",
"could_not_save_sidebar_width_8621fe": "保存侧栏宽度失败",
"finish_saving_pasting_uploading_or_670f95": "仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出",
"workspace_initialization_failed_reload_the_880958": "工作区初始化失败,请重新加载:{p0}",
"drag_to_resize_b51545": "拖动调整宽度",
"folders_52daa7": "目录",
"switch_note_649e31": "切换笔记",
"could_not_load_the_relationship_15e3ca": "关系索引读取失败",
"plus_3d5f3d": "加号",
"view_db8db0": "查看",
"expand_00bd39": "展开",
"calendar_a00f29": "日历",
"tag_1d0fd5": "标签",
"task_525304": "任务",
"print_d7bfe7": "打印",
"grid_e41ecc": "网格",
"menu_4ce4ca": "菜单",
"folder_7c7802": "文件夹",
"image_d24c10": "图片",
"document_2687cc": "文档",
"arrow_83fa01": "箭头",
"more_38844b": "更多",
"key_6d1327": "钥匙",
"palette_57d285": "调色板",
"user_0d0e1a": "用户",
"code_e6f04f": "代码",
"info_e70286": "信息",
"people_8d7aff": "人物",
"nature_4f1240": "自然",
"food_ab2775": "食物",
"travel_e751bc": "旅行",
"activities_9e9bea": "活动",
"objects_7c89d6": "物品",
"symbols_e89265": "符号",
"flags_f5aee0": "旗帜",
"folder_directory_open_3744fe": "文件夹 目录 打开",
"folder_directory_796213": "文件夹 目录",
"document_note_article_28eb56": "文档 笔记 文章",
"note_edit_record_87af46": "笔记 编辑 记录",
"document_article_2d7c78": "文档 文章",
"checklist_clipboard_032cfe": "清单 剪贴板",
"books_knowledge_learning_ef0c86": "书籍 知识 学习",
"reading_books_0d07d9": "阅读 书籍",
"books_red_book_86db47": "书籍 红书",
"books_green_book_e2ab64": "书籍 绿书",
"books_blue_book_3f6b60": "书籍 蓝书",
"books_orange_book_1ff6c9": "书籍 橙书",
"notebook_155c2f": "笔记本",
"ledger_notebook_b17498": "账本 笔记本",
"notebook_journal_f3f1a0": "笔记本 日记",
"calendar_date_0a71ec": "日历 日期",
"calendar_plan_54e7ab": "日历 计划",
"done_task_checkmark_9071c9": "完成 任务 对勾",
"checkbox_task_064c0e": "勾选 任务",
"star_favorite_b042d9": "星星 收藏",
"star_important_8cf705": "星星 重要",
"heart_like_37e5e5": "爱心 喜欢",
"fire_popular_48643d": "火 热门",
"lightbulb_inspiration_idea_404d08": "灯泡 灵感 想法",
"pin_30251e": "固定 图钉",
"lock_security_ca4da0": "锁 安全",
"key_secret_f6132e": "钥匙 密钥",
"settings_gear_08cbae": "设置 齿轮",
"tools_development_66775a": "工具 开发",
"computer_programming_code_e80796": "电脑 编程 代码",
"computer_monitor_096473": "电脑 显示器",
"phone_2e0733": "手机",
"images_album_e7c28d": "图片 相册",
"art_design_palette_7177c2": "艺术 设计 调色板",
"music_db9514": "音乐",
"film_video_78c13a": "电影 视频",
"camera_photography_50a8c0": "相机 摄影",
"game_847c5f": "游戏",
"house_home_6cddfb": "房子 主页 家",
"home_house_e9cb84": "家 房子",
"rocket_project_release_85d3b1": "火箭 项目 发布",
"work_briefcase_f2fcc3": "工作 公文包",
"target_57060c": "目标",
"earth_world_c6983f": "地球 世界",
"earth_asia_b820a1": "地球 亚洲",
"seedling_growth_5d0cd3": "幼苗 成长",
"plant_leaf_nature_d57c83": "植物 叶子 自然",
"flower_cherry_blossom_c6af13": "花 樱花",
"sun_sunny_c1c7c6": "太阳 晴天",
"moon_night_8d87a0": "月亮 夜晚",
"cat_animal_7759cf": "猫 动物",
"dog_animal_872611": "狗 动物",
"apple_food_d549ae": "苹果 食物",
"coffee_drink_1d3d41": "咖啡 饮料",
"smile_happy_emoji_3b25f0": "笑脸 开心 表情",
"smiling_happy_emoji_5fab64": "微笑 开心 表情",
"thinking_idea_fd06c2": "思考 想法",
"thumbs_up_good_7d9981": "赞 好",
"warning_attention_a3fe1d": "警告 注意",
"exclamation_important_5a619b": "感叹号 重要",
"question_03c022": "问号 问题",
"money_finance_13980e": "钱 财务",
"graduation_learning_f0d5cf": "毕业 学习",
"this_note_is_moving_finish_d105e5": "笔记正在移动,请完成后再粘贴或上传图片",
"the_note_receiving_this_paste_a59580": "粘贴所属笔记已关闭",
"the_editor_state_for_the_028961": "粘贴所属笔记的编辑状态不存在",
"paste_or_image_import_failed_09d4e6": "粘贴或图片导入失败:{p0}",
"processing_paste_or_image_import_023dad": "正在处理粘贴或图片导入…",
"this_note_has_unsaved_changes_f14427": "笔记有未保存修改或正在操作,请稍后重试",
"a_write_requires_a_non_b12fd4": "写入必须指定非空版本号或明确新建",
"save_failed_retrying_d62793": "保存失败,正在重试…",
"save_failed_check_your_connection_c0913f": "保存失败,请检查网络后重试",
"save_failed_6ba723": "保存失败:{p0}",
"load_failed_396d55": "载入失败:{p0}",
"this_note_is_saving_uploading_c23675": "笔记正在保存、上传或移动,请完成后再处理冲突",
"the_server_version_changed_again_594f7d": "服务器上的版本又变化了,请再次选择",
"could_not_open_0ed1e1": "打开失败:{p0}",
"this_note_is_uploading_or_349b3d": "笔记正在上传或移动,完成后再关闭",
"the_editor_state_for_this_248447": "未保存笔记的编辑状态不存在",
"could_not_save_the_local_cc0e42": "本地草稿保存失败,已保留标签页:{p0}",
"unsaved_changes_retained_as_a_8cb2ab": "未保存的修改已保留为本地草稿",
"icon_saved_but_the_file_72a5c2": "图标已保存,目录刷新失败:{p0}",
"untitled_67bee6": "未命名",
"could_not_create_note_2760ce": "新建笔记失败:{p0}",
"import_complete_but_the_file_52460f": "导入已完成,目录刷新失败:{p0}",
"could_not_create_folder_bc6447": "新建文件夹失败:{p0}",
"this_note_has_not_been_77a7a9": "笔记尚未创建成功",
"already_exists_6af434": "“{p0}”已存在",
"this_note_is_processing_a_8c2cce": "笔记正在处理粘贴、上传或移动,请完成后再移动",
"save_your_notes_and_resolve_5500d4": "请先保存笔记并解决冲突,再移动文件",
"could_not_restore_the_draft_e5f464": "恢复原路径草稿失败:{p0}",
"move_failed_04b0a9": "移动失败:{p0}",
"file_moved_but_note_synchronization_b9ff79": "文件已移动,但同步笔记失败:{p0}",
"file_no_longer_exists_4eb446": "文件已不存在",
"save_your_notes_and_resolve_5981a5": "请先保存笔记并解决冲突,再删除文件",
"could_not_close_the_note_cd2a80": "关闭笔记失败,未删除文件",
"deleted_86a46b": "已删除“{p0}”",
"restore_failed_283a6e": "恢复失败:{p0}",
"image_replaced_but_the_file_bb4848": "图片已替换,但刷新目录失败:{p0}",
"could_not_synchronize_settings_7e5bb1": "同步设置失败:{p0}",
"could_not_migrate_the_local_4c513a": "迁移本地草稿失败,原路径草稿已保留:{p0}",
"could_not_load_settings_246415": "读取设置失败:{p0}",
"could_not_restore_the_local_a275e8": "恢复本地草稿失败:{p0}",
"synchronization_failed_c6eeea": "同步失败:{p0}",
"quick_open_note_0d376f": "快速打开笔记",
"full_text_search_a6c47a": "全文搜索",
"save_current_note_5a7e29": "保存当前笔记",
"rename_current_note_de288b": "重命名当前笔记",
"move_current_note_to_f1fc16": "移动当前笔记到…",
"delete_current_note_657a00": "删除当前笔记",
"insert_image_52c554": "插入图片…",
"close_current_tab_cc8a4e": "关闭当前标签页",
"show_hide_sidebar_96b1a4": "显示/隐藏侧栏",
"show_hide_outline_1da8d2": "显示/隐藏大纲",
"toggle_source_editing_602b33": "开启/关闭源码编辑",
"toggle_focus_mode_b52fb6": "开启/关闭专注写作",
"open_trash_8a939d": "打开回收站",
"manage_root_folder_images_fed368": "管理根目录图片",
"could_not_save_the_local_4b21e1": "本地草稿保存失败,请勿关闭页面",
"workspace_closed_221ea3": "工作区已关闭",
"invalid_link_format_651be5": "链接格式无效",
"unsupported_link_protocol_cf426f": "不支持的链接协议",
"invalid_link_encoding_2c6a7e": "链接编码无效",
"link_target_does_not_exist_2503d1": "链接目标不存在:{p0}",
"finish_saving_pasting_uploading_or_e67d3d": "仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再清理",
"block_quote": "引用"
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { NoteMetadata } from '@@/apis/metadata/type'
import { getMetadataApi } from '@@/apis/metadata'
import { t as translate } from '@@/i18n/runtime'
import { isCancel } from 'axios'
import { reactive } from 'vue'
@@ -15,6 +16,6 @@ export async function refreshMetadata() {
metadata.error = ''
} catch (error) {
if (isCancel(error)) return
if (current === request) metadata.error = error instanceof Error ? error.message : '关系索引读取失败'
if (current === request) metadata.error = error instanceof Error ? error.message : translate('workspace.could_not_load_the_relationship_15e3ca')
}
}
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import { applyLocale } from '@@/i18n/runtime'
import { shallowMount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import OutlinePanel from './components/OutlinePanel.vue'
import { createEditorState, createEditorView } from './editor'
import * as workspace from './workspace'
it('切换语言仅翻译空标题占位,保留真实标题与正文', async () => {
applyLocale('en')
const doc = '# \n\n## 用户标题 User heading\n'
const host = document.createElement('div')
document.body.append(host)
const view = createEditorView(host, createEditorState({ doc }))
vi.spyOn(workspace, 'editorView').mockReturnValue(view)
vi.spyOn(workspace, 'activeTab').mockReturnValue({ path: 'note.md', rev: 'r1', status: 'ready', dirty: false })
const wrapper = shallowMount(OutlinePanel)
try {
await vi.waitFor(() => expect(wrapper.findAll('li').map(item => item.text())).toEqual(['(Untitled)', '用户标题 User heading']))
for (const [language, label] of [['zh-CN', '(无标题)'], ['en', '(Untitled)']] as const) {
applyLocale(language)
await nextTick()
expect(wrapper.findAll('li').map(item => item.text())).toEqual([label, '用户标题 User heading'])
expect(view.state.doc.toString()).toBe(doc)
}
} finally {
wrapper.unmount()
view.destroy()
host.remove()
vi.restoreAllMocks()
applyLocale('en')
}
})
+214 -51
View File
@@ -1,61 +1,223 @@
import type { IconName } from '@@/utils/icons'
import type { Gemoji } from 'gemoji'
import { t as translate } from '@@/i18n/runtime'
export const iconLabels: Record<IconName, string> = {
plus: '加号',
eye: '查看',
expand: '展开',
graph: '关系',
calendar: '日历',
tag: '标签',
copy: '复制',
task: '任务',
quote: '引用',
undo: '撤销',
redo: '重做',
print: '打印',
grid: '网格',
menu: '菜单',
noteAdd: '新建笔记',
import: '导入',
folderAdd: '新建文件夹',
folder: '文件夹',
image: '图片',
note: '文档',
chevron: '箭头',
more: '更多',
close: '关闭',
pin: '固定',
trash: '回收站',
logout: '退出',
restore: '恢复',
edit: '编辑',
search: '搜索',
link: '链接',
key: '钥匙',
settings: '设置',
palette: '调色板',
user: '用户',
outline: '大纲',
source: '代码',
command: '命令',
info: '信息',
get plus() {
return translate('workspace.plus_3d5f3d')
},
get eye() {
return translate('workspace.view_db8db0')
},
get expand() {
return translate('workspace.expand_00bd39')
},
get graph() {
return translate('workspace.links_253aaf')
},
get calendar() {
return translate('workspace.calendar_a00f29')
},
get tag() {
return translate('workspace.tag_1d0fd5')
},
get copy() {
return translate('common.copy_63d90d')
},
get task() {
return translate('workspace.task_525304')
},
get quote() {
return translate('workspace.references_e0ec65')
},
get undo() {
return translate('workspace.undo_926a50')
},
get redo() {
return translate('workspace.redo_03717b')
},
get print() {
return translate('workspace.print_d7bfe7')
},
get grid() {
return translate('workspace.grid_e41ecc')
},
get menu() {
return translate('workspace.menu_4ce4ca')
},
get noteAdd() {
return translate('workspace.new_note_d3d2d9')
},
get import() {
return translate('common.import_576d81')
},
get folderAdd() {
return translate('workspace.new_folder_84244a')
},
get folder() {
return translate('workspace.folder_7c7802')
},
get image() {
return translate('workspace.image_d24c10')
},
get note() {
return translate('workspace.document_2687cc')
},
get chevron() {
return translate('workspace.arrow_83fa01')
},
get more() {
return translate('workspace.more_38844b')
},
get close() {
return translate('workspace.close_3fd47e')
},
get pin() {
return translate('workspace.pin_d4b3f4')
},
get trash() {
return translate('common.trash_ba35dc')
},
get logout() {
return translate('common.sign_out_498e1d')
},
get restore() {
return translate('common.restore_e0534b')
},
get edit() {
return translate('common.edit_051836')
},
get search() {
return translate('workspace.search_44ce7a')
},
get link() {
return translate('workspace.link_305378')
},
get key() {
return translate('workspace.key_6d1327')
},
get settings() {
return translate('common.settings_df3d58')
},
get palette() {
return translate('workspace.palette_57d285')
},
get user() {
return translate('workspace.user_0d0e1a')
},
get outline() {
return translate('workspace.outline_79fadb')
},
get source() {
return translate('workspace.code_e6f04f')
},
get command() {
return translate('common.command_928f87')
},
get info() {
return translate('workspace.info_e70286')
},
}
export const emojiCategories = [
{ id: 'Smileys & Emotion', label: '表情', icon: '😀' },
{ id: 'People & Body', label: '人物', icon: '👋' },
{ id: 'Animals & Nature', label: '自然', icon: '🌿' },
{ id: 'Food & Drink', label: '食物', icon: '🍎' },
{ id: 'Travel & Places', label: '旅行', icon: '🚀' },
{ id: 'Activities', label: '活动', icon: '⚽' },
{ id: 'Objects', label: '物品', icon: '💡' },
{ id: 'Symbols', label: '符号', icon: '❤️' },
{ id: 'Flags', label: '旗帜', icon: '🏳️' },
{ id: 'Smileys & Emotion', get label() {
return translate('workspace.emoji_c762ed')
}, icon: '😀' },
{ id: 'People & Body', get label() {
return translate('workspace.people_8d7aff')
}, icon: '👋' },
{ id: 'Animals & Nature', get label() {
return translate('workspace.nature_4f1240')
}, icon: '🌿' },
{ id: 'Food & Drink', get label() {
return translate('workspace.food_ab2775')
}, icon: '🍎' },
{ id: 'Travel & Places', get label() {
return translate('workspace.travel_e751bc')
}, icon: '🚀' },
{ id: 'Activities', get label() {
return translate('workspace.activities_9e9bea')
}, icon: '⚽' },
{ id: 'Objects', get label() {
return translate('workspace.objects_7c89d6')
}, icon: '💡' },
{ id: 'Symbols', get label() {
return translate('workspace.symbols_e89265')
}, icon: '❤️' },
{ id: 'Flags', get label() {
return translate('workspace.flags_f5aee0')
}, icon: '🏳️' },
]
// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
const keywords: Record<string, string> = {
// 常见图标的显示标签在使用时翻译,避免缓存语言切换前的文案。
const keywordKeys: Record<string, string> = {
'📂': 'workspace.folder_directory_open_3744fe',
'📁': 'workspace.folder_directory_796213',
'📄': 'workspace.document_note_article_28eb56',
'📝': 'workspace.note_edit_record_87af46',
'📃': 'workspace.document_article_2d7c78',
'📋': 'workspace.checklist_clipboard_032cfe',
'📚': 'workspace.books_knowledge_learning_ef0c86',
'📖': 'workspace.reading_books_0d07d9',
'📕': 'workspace.books_red_book_86db47',
'📗': 'workspace.books_green_book_e2ab64',
'📘': 'workspace.books_blue_book_3f6b60',
'📙': 'workspace.books_orange_book_1ff6c9',
'📓': 'workspace.notebook_155c2f',
'📒': 'workspace.ledger_notebook_b17498',
'📔': 'workspace.notebook_journal_f3f1a0',
'📅': 'workspace.calendar_date_0a71ec',
'🗓️': 'workspace.calendar_plan_54e7ab',
'✅': 'workspace.done_task_checkmark_9071c9',
'☑️': 'workspace.checkbox_task_064c0e',
'⭐': 'workspace.star_favorite_b042d9',
'🌟': 'workspace.star_important_8cf705',
'❤️': 'workspace.heart_like_37e5e5',
'🔥': 'workspace.fire_popular_48643d',
'💡': 'workspace.lightbulb_inspiration_idea_404d08',
'📌': 'workspace.pin_30251e',
'🔗': 'workspace.link_305378',
'🔒': 'workspace.lock_security_ca4da0',
'🔑': 'workspace.key_secret_f6132e',
'⚙️': 'workspace.settings_gear_08cbae',
'🛠️': 'workspace.tools_development_66775a',
'💻': 'workspace.computer_programming_code_e80796',
'🖥️': 'workspace.computer_monitor_096473',
'📱': 'workspace.phone_2e0733',
'🖼️': 'workspace.images_album_e7c28d',
'🎨': 'workspace.art_design_palette_7177c2',
'🎵': 'workspace.music_db9514',
'🎬': 'workspace.film_video_78c13a',
'📷': 'workspace.camera_photography_50a8c0',
'🎮': 'workspace.game_847c5f',
'🏠': 'workspace.house_home_6cddfb',
'🏡': 'workspace.home_house_e9cb84',
'🚀': 'workspace.rocket_project_release_85d3b1',
'💼': 'workspace.work_briefcase_f2fcc3',
'🎯': 'workspace.target_57060c',
'🌍': 'workspace.earth_world_c6983f',
'🌏': 'workspace.earth_asia_b820a1',
'🌱': 'workspace.seedling_growth_5d0cd3',
'🌿': 'workspace.plant_leaf_nature_d57c83',
'🌸': 'workspace.flower_cherry_blossom_c6af13',
'☀️': 'workspace.sun_sunny_c1c7c6',
'🌙': 'workspace.moon_night_8d87a0',
'🐱': 'workspace.cat_animal_7759cf',
'🐶': 'workspace.dog_animal_872611',
'🍎': 'workspace.apple_food_d549ae',
'☕': 'workspace.coffee_drink_1d3d41',
'😀': 'workspace.smile_happy_emoji_3b25f0',
'😊': 'workspace.smiling_happy_emoji_5fab64',
'🤔': 'workspace.thinking_idea_fd06c2',
'👍': 'workspace.thumbs_up_good_7d9981',
'⚠️': 'workspace.warning_attention_a3fe1d',
'❗': 'workspace.exclamation_important_5a619b',
'❓': 'workspace.question_03c022',
'💰': 'workspace.money_finance_13980e',
'🎓': 'workspace.graduation_learning_f0d5cf',
}
const chineseKeywords: Record<string, string> = {
'📂': '文件夹 目录 打开',
'📁': '文件夹 目录',
'📄': '文档 笔记 文章',
@@ -123,9 +285,10 @@ const keywords: Record<string, string> = {
}
export function emojiLabel(emoji: Gemoji): string {
return keywords[emoji.emoji] ?? emoji.description
const key = keywordKeys[emoji.emoji]
return key ? translate(key) : emoji.description
}
export function matchesEmoji(emoji: Gemoji, query: string): boolean {
return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, keywords[emoji.emoji] ?? '', emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
return [emoji.emoji, emoji.description, ...emoji.names, ...emoji.tags, chineseKeywords[emoji.emoji] ?? '', emojiLabel(emoji), emojiCategories.find(c => c.id === emoji.category)?.label ?? ''].join(' ').toLowerCase().includes(query)
}
+32 -12
View File
@@ -1,5 +1,6 @@
import type { App } from 'vue'
import * as treeApi from '@@/apis/tree'
import { applyLocale } from '@@/i18n/runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { createApp, nextTick } from 'vue'
@@ -11,6 +12,7 @@ import * as workspace from './workspace'
let app: App
let host: HTMLElement
beforeEach(async () => {
applyLocale('en')
vi.spyOn(workspace, 'saveTreeIcon').mockResolvedValue()
vi.spyOn(workspace, 'openNote').mockResolvedValue()
vi.spyOn(workspace, 'move').mockResolvedValue({ ok: true })
@@ -32,6 +34,7 @@ beforeEach(async () => {
})
afterEach(() => {
app.unmount(); host.remove(); vi.restoreAllMocks()
applyLocale('en')
})
const row = (name: string) => [...host.querySelectorAll<HTMLElement>('.rm-tree-row')].find(e => e.querySelector('.rm-tree-name')?.textContent?.trim() === name)!
async function click(element: Element, init: MouseEventInit = {}) {
@@ -40,12 +43,12 @@ async function click(element: Element, init: MouseEventInit = {}) {
const button = (name: string) => document.querySelector<HTMLElement>(`[aria-label="${name}"]`)!
async function picker() {
await click(row('folder').querySelector('.rm-tree-icon')!)
await vi.waitFor(() => expect(button('搜索图标')).not.toBeNull())
await vi.waitFor(() => expect(button('Search icons')).not.toBeNull())
}
describe('文件树图标操作', () => {
it('保留右侧目录菜单,点击默认图标不折叠目录或改变多选', async () => {
expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent?.trim()).toBe('文件目录')
expect(host.querySelector('.rm-vault-heading .rm-tree-label')?.textContent?.trim()).toBe('Files')
expect(row('folder').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📂')
await click(row('folder'))
expect(row('a').querySelector('.rm-tree-icon')?.textContent?.trim()).toBe('📄')
@@ -63,24 +66,41 @@ describe('文件树图标操作', () => {
it('中文搜索表情并保存到当前节点,最近使用只记成功操作', async () => {
await picker()
const input = button('搜索图标') as HTMLInputElement
const input = button('Search icons') as HTMLInputElement
input.value = '文件夹'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(button('文件夹 目录 打开')).not.toBeNull())
await click(button('文件夹 目录 打开'))
await vi.waitFor(() => expect(button('folder directory open')).not.toBeNull())
await click(button('folder directory open'))
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
expect(workspace.saveTreeIcon).toHaveBeenCalledExactlyOnceWith('folder', { kind: 'emoji', value: '📂' })
expect(JSON.parse(localStorage.getItem('rawmark.recent-tree-icons')!)).toEqual([{ kind: 'emoji', value: '📂' }])
})
it('图标提示与搜索随语言双向切换,保留弹窗和查询', async () => {
await picker()
const input = button('Search icons') as HTMLInputElement
input.value = '文件夹'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.waitFor(() => expect(button('folder directory open')).not.toBeNull())
for (const [language, label] of [['zh-CN', '文件夹 目录 打开'], ['en', 'folder directory open']] as const) {
applyLocale(language)
await nextTick()
const folder = button(label)
expect(folder).not.toBeNull()
expect(folder.title).toBe(label)
expect(document.querySelector('.rm-tree-icon-picker input')).toBe(input)
expect(input.value).toBe('文件夹')
}
})
it('保存失败保留弹窗与明确错误,恢复默认成功后关闭', async () => {
vi.mocked(workspace.saveTreeIcon).mockRejectedValueOnce(new Error('磁盘只读'))
await picker()
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === '恢复默认图标')!)
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === 'Reset icon')!)
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain('磁盘只读'))
expect(treeIconTarget.value?.path).toBe('folder')
expect(localStorage.getItem('rawmark.recent-tree-icons')).toBeNull()
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === '恢复默认图标')!)
await click([...document.querySelectorAll('button')].find(e => e.textContent?.trim() === 'Reset icon')!)
await vi.waitFor(() => expect(treeIconTarget.value).toBeNull())
expect(workspace.saveTreeIcon).toHaveBeenLastCalledWith('folder', null)
})
@@ -88,12 +108,12 @@ describe('文件树图标操作', () => {
it('线性图标可搜索,右键入口选择同一节点', async () => {
row('folder').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
await nextTick()
await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent?.trim() === '修改图标')!)
await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent?.trim() === '线性图标')!)
const input = button('搜索图标') as HTMLInputElement
input.value = '任务'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
await click([...document.querySelectorAll('.rm-tree-menu button')].find(e => e.textContent?.trim() === 'Change icon')!)
await click([...document.querySelectorAll('[role="tab"]')].find(e => e.textContent?.trim() === 'Line icons')!)
const input = button('Search icons') as HTMLInputElement
input.value = 'Task'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick()
expect(document.querySelectorAll('.rm-tree-icon-grid button')).toHaveLength(1)
await click(button('任务'))
await click(button('Task'))
expect(workspace.saveTreeIcon).toHaveBeenCalledWith('folder', { kind: 'builtin', value: 'task' })
})
+3 -2
View File
@@ -1,4 +1,5 @@
import type { Entry, TreeIcon } from '@@/apis/tree/type'
import { locale } from '@@/i18n/runtime'
import { basename, dirname, hiddenAssetPath } from '@@/utils/paths'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { rangePaths } from './selection'
@@ -12,7 +13,7 @@ export interface TreeNode {
children: TreeNode[]
}
const collator = new Intl.Collator('zh-CN', { numeric: true, sensitivity: 'base' })
const collator = computed(() => new Intl.Collator(locale.value, { numeric: true, sensitivity: 'base' }))
function build(entries: Entry[]): TreeNode[] {
const root: TreeNode[] = []
@@ -24,7 +25,7 @@ function build(entries: Entry[]): TreeNode[] {
;(dirs.get(dirname(e.path))?.children ?? root).push(node)
}
const sort = (nodes: TreeNode[]) => {
nodes.sort((a, b) => (a.dir !== b.dir ? (a.dir ? -1 : 1) : collator.compare(a.name, b.name)))
nodes.sort((a, b) => (a.dir !== b.dir ? (a.dir ? -1 : 1) : collator.value.compare(a.name, b.name)))
nodes.forEach(n => sort(n.children))
}
sort(root)
+4 -4
View File
@@ -356,7 +356,7 @@ describe('导入', () => {
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)
expect(toasts.some(t => t.text.includes('Import complete, but the file tree could not be refreshed'))).toBe(true)
})
it('失败保留错误并释放操作占用', async () => {
@@ -384,7 +384,7 @@ describe('图片管理', () => {
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)
expect(toasts.some(t => t.text.includes('Image replaced, but the file tree could not be refreshed'))).toBe(true)
})
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
@@ -572,8 +572,8 @@ it('弹窗接管移动错误时只返回失败,由调用方展示;目录操
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(result).toEqual({ ok: false, error: '“already.md” already exists' })
expect(toasts).toHaveLength(0)
await move('source.md', 'already.md')
expect(toasts.map(item => item.text)).toEqual(['“already.md”已存在'])
expect(toasts.map(item => item.text)).toEqual(['“already.md” already exists'])
})
+137 -80
View File
@@ -8,17 +8,18 @@ import { createFolderApi, deleteFileApi, getRawFileApi, importNotesApi, moveFile
import { restoreTrashApi } from '@@/apis/trash'
import { getTreeApi, setTreeIconApi } from '@@/apis/tree'
import { appearancePaused } from '@@/appearance/appearance'
import { locale, t as translate } from '@@/i18n/runtime'
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
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'
@@ -33,6 +34,7 @@ import { useUserStore } from '@/pinia/stores/user'
import { drafts } from './drafts'
import { createEditorState, insertImageFiles, languageCompartment, pasteImages, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment, previewCompartment, previewExtensions } from './editor'
import { refreshImages } from './editor/livepreview'
import { editorLocale, localeCompartment } from './editor/locale'
import { editorCommands } from './editor/shortcuts'
import { refreshMetadata } from './metadata'
@@ -140,7 +142,7 @@ export function activeTab(): Tab | undefined {
}
function errorText(err: unknown): string {
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
return err instanceof ApiError ? err.message : translate('common.network_error_check_your_connection_566064')
}
// ---- 编辑器状态 ----
@@ -228,22 +230,22 @@ function buildState(path: string, doc: string): EditorState {
canPaste: () => {
if (!isCurrentNote()) return false
if (!slot(ref.path).paused) return true
toast('笔记正在移动,请完成后再粘贴或上传图片')
toast(translate('workspace.this_note_is_moving_finish_d105e5'))
return false
},
upload: (file) => {
if (!isCurrentNote()) throw new CanceledError('粘贴所属笔记已关闭')
if (!isCurrentNote()) throw new CanceledError(translate('workspace.the_note_receiving_this_paste_a59580'))
return uploadImageApi({ note: ref.path, file }).then(({ data }) => data.link)
},
importImage: (url) => {
if (!isCurrentNote()) throw new CanceledError('粘贴所属笔记已关闭')
if (!isCurrentNote()) throw new CanceledError(translate('workspace.the_note_receiving_this_paste_a59580'))
return importImageApi({ note: ref.path, url }).then(({ data }) => data.link)
},
update: (change) => {
if (!isCurrentNote()) return
const shown = view?.state.facet(notePath) === ref
const state = shown ? view!.state : editorStates.get(ref.path)
if (!state) throw new Error('粘贴所属笔记的编辑状态不存在')
if (!state) throw new Error(translate('workspace.the_editor_state_for_the_028961'))
const spec = change(state)
if (!spec) return
if (shown) {
@@ -255,11 +257,11 @@ function buildState(path: string, doc: string): EditorState {
}
},
onError: (err) => {
if (isCurrentNote() && !isCancel(err)) toast(`粘贴或图片导入失败:${err instanceof Error ? err.message : errorText(err)}`)
if (isCurrentNote() && !isCancel(err)) toast(translate('workspace.paste_or_image_import_failed_09d4e6', { p0: err instanceof Error ? err.message : errorText(err) }))
},
onPending: (d) => {
if (!isCurrentNote()) return
if (d === 1 && slot(ref.path).uploads === 0) toast('正在处理粘贴或图片导入…')
if (d === 1 && slot(ref.path).uploads === 0) toast(translate('workspace.processing_paste_or_image_import_023dad'))
slot(ref.path).uploads += d
},
}),
@@ -283,8 +285,8 @@ function reconfigurePluginStates() {
const pluginWrite: PluginAPI['vault']['write'] = async (path, content, options) => {
const tab = tabOf(path)
const saved = slots.get(path)
if ((tab && (tab.dirty || tab.status !== 'ready')) || saved?.inflight || saved?.uploads || saved?.paused || opsInFlight) throw new ApiError(409, '笔记有未保存修改或正在操作,请稍后重试')
if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, '写入必须指定非空版本号或明确新建')
if ((tab && (tab.dirty || tab.status !== 'ready')) || saved?.inflight || saved?.uploads || saved?.paused || opsInFlight) throw new ApiError(409, translate('workspace.this_note_has_unsaved_changes_f14427'))
if (!options || (!('create' in options && options.create === true) && !('ifMatch' in options && typeof options.ifMatch === 'string' && options.ifMatch.length > 0))) throw new ApiError(400, translate('workspace.a_write_requires_a_non_b12fd4'))
const initialRevision = tab?.rev
const initialSequence = saved?.seq ?? 0
const rev = (await writeFileApi({ path, content, ifMatch: 'create' in options ? '' : options.ifMatch })).data.rev
@@ -383,10 +385,10 @@ export async function flush(path: string, keepalive = false): Promise<void> {
tab.conflictRev = serverRev
} else if (!(err instanceof ApiError) || err.status >= 500) {
// 网络错误与服务器错误可重试,草稿已在本地,不会丢
tab.saveError = s.retries < MAX_RETRIES ? '保存失败,正在重试…' : '保存失败,请检查网络后重试'
tab.saveError = s.retries < MAX_RETRIES ? translate('workspace.save_failed_retrying_d62793') : translate('workspace.save_failed_check_your_connection_c0913f')
if (s.retries++ < MAX_RETRIES) s.timer = setTimeout(() => void flush(path), RETRY_DELAY)
} else {
tab.saveError = `保存失败:${err.message}`
tab.saveError = translate('workspace.save_failed_6ba723', { p0: err.message })
}
}
})()
@@ -426,7 +428,7 @@ export async function useServerVersion(path: string) {
await drafts.delete(path)
} catch (err) {
if (isCancel(err)) return
toast(`载入失败:${errorText(err)}`)
toast(translate('workspace.load_failed_396d55', { p0: errorText(err) }))
}
}
@@ -438,7 +440,7 @@ export async function keepLocalVersion(path: string) {
if (!tab || content === undefined) return
const s = slot(path)
if (s.inflight || s.uploads || s.paused) {
toast('笔记正在保存、上传或移动,请完成后再处理冲突')
toast(translate('workspace.this_note_is_saving_uploading_c23675'))
return
}
const seq = s.seq
@@ -450,16 +452,16 @@ export async function keepLocalVersion(path: string) {
if (s.seq === seq) await drafts.delete(path)
if (!ws.entries.some(e => e.path === path)) {
void refreshTree().catch((err) => {
if (!isCancel(err)) toast(`刷新目录失败:${errorText(err)}`)
if (!isCancel(err)) toast(translate('workspace.could_not_refresh_the_file_b5f68b', { p0: errorText(err) }))
})
}
} catch (err) {
if (isCancel(err)) return
if (err instanceof ApiError && err.status === 412) {
tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : ''
toast('服务器上的版本又变化了,请再次选择')
toast(translate('workspace.the_server_version_changed_again_594f7d'))
} else {
toast(`保存失败:${errorText(err)}`)
toast(translate('workspace.save_failed_6ba723', { p0: errorText(err) }))
}
}
})()
@@ -538,7 +540,7 @@ export async function openNote(path: string) {
if (isCancel(err)) return
const t = tabOf(path)
if (t) t.status = 'error'
toast(`打开失败:${errorText(err)}`)
toast(translate('workspace.could_not_open_0ed1e1', { p0: errorText(err) }))
}
}
@@ -546,7 +548,7 @@ export async function closeTab(path: string): Promise<boolean> {
const version = user.getSessionVersion()
const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused)
if (busy()) {
toast('笔记正在上传或移动,完成后再关闭')
toast(translate('workspace.this_note_is_uploading_or_349b3d'))
return false
}
await flush(path)
@@ -555,23 +557,23 @@ export async function closeTab(path: string): Promise<boolean> {
if (i < 0) return true
const tab = ws.tabs[i]
if (busy()) {
toast('笔记正在上传或移动,完成后再关闭')
toast(translate('workspace.this_note_is_uploading_or_349b3d'))
return false
}
if (tab.dirty) {
const seq = slot(path).seq
const content = docOf(path)
if (content === undefined) throw new Error('未保存笔记的编辑状态不存在')
if (content === undefined) throw new Error(translate('workspace.the_editor_state_for_this_248447'))
try {
await drafts.put({ path, content, baseRev: tab.rev, updatedAt: Date.now() })
user.assertSession(version)
} catch (err) {
if (isCancel(err)) return false
toast(`本地草稿保存失败,已保留标签页:${errorText(err)}`)
toast(translate('workspace.could_not_save_the_local_cc0e42', { p0: errorText(err) }))
return false
}
if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false
toast('未保存的修改已保留为本地草稿')
toast(translate('workspace.unsaved_changes_retained_as_a_8cb2ab'))
}
const index = ws.tabs.indexOf(tab)
if (index < 0) return true
@@ -718,12 +720,12 @@ export async function saveTreeIcon(path: string, icon: TreeIcon | null) {
await refreshTree()
} catch (err) {
if (isCancel(err)) return
toast(`图标已保存,目录刷新失败:${errorText(err)}`)
toast(translate('workspace.icon_saved_but_the_file_72a5c2', { p0: errorText(err) }))
}
}
export async function createNote(dir: string) {
const path = uniqueName(dir, '未命名', NOTE_EXT)
const path = uniqueName(dir, translate('workspace.untitled_67bee6'), NOTE_EXT)
ws.renaming = path
await createNoteAt(path)
}
@@ -760,7 +762,7 @@ async function createNoteFile(path: string, content: string): Promise<boolean> {
if (isCancel(err)) return false
ws.entries = ws.entries.filter(e => !added.includes(e.path))
if (ws.renaming === path) ws.renaming = null
toast(`新建笔记失败:${errorText(err)}`)
toast(translate('workspace.could_not_create_note_2760ce', { p0: errorText(err) }))
return false
}
})
@@ -774,13 +776,13 @@ export async function importNotes(dir: string, folder: string, files: File[]) {
await refreshTree()
} catch (err) {
if (isCancel(err)) throw err
toast(`导入已完成,目录刷新失败:${errorText(err)}`)
toast(translate('workspace.import_complete_but_the_file_52460f', { p0: errorText(err) }))
}
return result
}
export async function createFolder(dir: string) {
const path = uniqueName(dir, '新建文件夹', '')
const path = uniqueName(dir, translate('workspace.new_folder_84244a'), '')
ws.entries.push({ path, dir: true, size: 0, mtime: Date.now() })
ws.renaming = path
await withOp(async () => {
@@ -790,7 +792,7 @@ export async function createFolder(dir: string) {
if (isCancel(err)) return
ws.entries = ws.entries.filter(e => e.path !== path)
if (ws.renaming === path) ws.renaming = null
toast(`新建文件夹失败:${errorText(err)}`)
toast(translate('workspace.could_not_create_folder_bc6447', { p0: errorText(err) }))
}
})
}
@@ -812,29 +814,29 @@ function operationFailed(error: string, owner: 'operation' | 'caller'): FileResu
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('笔记尚未创建成功', errorOwner)
if (creating.has(from) && !(await creating.get(from))) return operationFailed(translate('workspace.this_note_has_not_been_77a7a9'), errorOwner)
user.assertSession(version)
if (ws.entries.some(e => e.path === to)) {
return operationFailed(`“${basename(to)}”已存在`, errorOwner)
return operationFailed(translate('workspace.already_exists_6af434', { p0: 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('笔记正在处理粘贴、上传或移动,请完成后再移动', errorOwner)
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
}
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(impacted().map(p => flush(p)))
user.assertSession(version)
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
if (busy()) {
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动', errorOwner)
return operationFailed(translate('workspace.this_note_is_processing_a_8c2cce'), errorOwner)
}
if (impacted().some((p) => {
const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready')
})) {
return operationFailed('请先保存笔记并解决冲突,再移动文件', errorOwner)
return operationFailed(translate('workspace.save_your_notes_and_resolve_5500d4'), errorOwner)
}
const paused = impacted().map(slot)
paused.forEach(s => s.paused++)
@@ -866,10 +868,10 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
try {
await drafts.move(to, from)
} catch (cause) {
toast(`恢复原路径草稿失败:${cause instanceof Error ? cause.message : errorText(cause)}`)
toast(translate('workspace.could_not_restore_the_draft_e5f464', { p0: cause instanceof Error ? cause.message : errorText(cause) }))
}
}
return operationFailed(`移动失败:${err instanceof Error ? err.message : errorText(err)}`, errorOwner)
return operationFailed(translate('workspace.move_failed_04b0a9', { p0: err instanceof Error ? err.message : errorText(err) }), errorOwner)
} finally {
paused.forEach(s => s.paused--)
}
@@ -886,12 +888,12 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
}
})).catch((err) => {
if (isCancel(err)) throw err
toast(`文件已移动,但同步笔记失败:${errorText(err)}`)
toast(translate('workspace.file_moved_but_note_synchronization_b9ff79', { p0: errorText(err) }))
})
return { ok: true }
})
void refreshTree().catch((err) => {
if (!isCancel(err)) toast(`刷新目录失败:${errorText(err)}`)
if (!isCancel(err)) toast(translate('workspace.could_not_refresh_the_file_b5f68b', { p0: errorText(err) }))
})
return result
}
@@ -899,7 +901,7 @@ export async function move(from: string, to: string, errorOwner: 'operation' | '
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('文件已不存在', errorOwner)
if (!entry) return operationFailed(translate('workspace.file_no_longer_exists_4eb446'), errorOwner)
return withOp<FileResult>(async () => {
const paths = affected(path)
await Promise.all(paths.map(p => flush(p)))
@@ -907,28 +909,30 @@ export async function remove(path: string, errorOwner: 'operation' | 'caller' =
if (paths.some((p) => {
const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready')
})) {
return operationFailed('请先保存笔记并解决冲突,再删除文件', errorOwner)
return operationFailed(translate('workspace.save_your_notes_and_resolve_5981a5'), 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('关闭笔记失败,未删除文件', errorOwner)
ws.entries = snapshot; return operationFailed(translate('workspace.could_not_close_the_note_cd2a80'), errorOwner)
}
}
user.assertSession(version)
try {
const item = (await deleteFileApi(path)).data
if (ws.image && remapPath(ws.image, path, path) !== null) ws.image = null
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
label: '撤销',
toast(translate('workspace.deleted_86a46b', { p0: basename(path).replace(/\.md$/, '') }), {
get label() {
return translate('workspace.undo_926a50')
},
run: () => void restore(item.id),
})
return { ok: true }
} catch (err) {
if (isCancel(err)) throw err
ws.entries = snapshot
return operationFailed(`删除失败:${errorText(err)}`, errorOwner)
return operationFailed(translate('common.could_not_delete_9619d7', { p0: errorText(err) }), errorOwner)
}
})
}
@@ -941,7 +945,7 @@ export async function restore(id: string) {
else if (!item.dir && isImage(item.path)) ws.image = item.path
} catch (err) {
if (isCancel(err)) return
toast(`恢复失败:${errorText(err)}`)
toast(translate('workspace.restore_failed_283a6e', { p0: errorText(err) }))
}
}
@@ -957,7 +961,7 @@ export async function replaceImage(path: string, file: File) {
await refreshTree()
} catch (err) {
if (isCancel(err)) return
toast(`图片已替换,但刷新目录失败:${errorText(err)}`)
toast(translate('workspace.image_replaced_but_the_file_bb4848', { p0: errorText(err) }))
}
}
@@ -991,7 +995,7 @@ async function handleEvents(evs: VaultEvent[]) {
void refreshMetadata()
} else if (ev.type === 'settings') {
void loadSettings().catch((err) => {
if (!isCancel(err)) toast(`同步设置失败:${errorText(err)}`)
if (!isCancel(err)) toast(translate('workspace.could_not_synchronize_settings_7e5bb1', { p0: errorText(err) }))
})
await refreshPlugins()
} else if (ev.type === 'plugins') {
@@ -1014,7 +1018,7 @@ async function handleEvents(evs: VaultEvent[]) {
await drafts.move(ev.from!, ev.path!)
} catch (cause) {
// 外部文件已移动,草稿失败不能阻止编辑器跟随;保留原记录并明确提示。
toast(`迁移本地草稿失败,原路径草稿已保留:${cause instanceof Error ? cause.message : errorText(cause)}`)
toast(translate('workspace.could_not_migrate_the_local_4c513a', { p0: cause instanceof Error ? cause.message : errorText(cause) }))
}
user.assertSession(version)
if (epoch !== workspaceEpoch) return
@@ -1098,7 +1102,7 @@ export async function startWorkspace() {
user.assertSession(version)
// 设置读取失败不影响使用,沿用本地缓存的值
const settingsLoaded = loadSettings().catch((err) => {
if (!isCancel(err)) toast(`读取设置失败:${errorText(err)}`)
if (!isCancel(err)) toast(translate('workspace.could_not_load_settings_246415', { p0: errorText(err) }))
})
await refreshTree()
await settingsLoaded
@@ -1110,7 +1114,7 @@ export async function startWorkspace() {
await recoverDrafts()
} catch (err) {
if (isCancel(err)) throw err
toast(`恢复本地草稿失败:${errorText(err)}`)
toast(translate('workspace.could_not_restore_the_local_a275e8', { p0: errorText(err) }))
}
user.assertSession(version)
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
@@ -1123,7 +1127,7 @@ export async function startWorkspace() {
eventQueue = eventQueue.then(() => {
if (!stopped) return work()
}).catch((err) => {
if (!stopped && version === user.getSessionVersion() && !isCancel(err)) toast(`同步失败:${errorText(err)}`)
if (!stopped && version === user.getSessionVersion() && !isCancel(err)) toast(translate('workspace.synchronization_failed_c6eeea', { p0: errorText(err) }))
})
}
const stopEvents = connectEventsApi({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
@@ -1173,6 +1177,17 @@ export async function startWorkspace() {
requestAnimationFrame(() => view?.requestMeasure())
},
)
watch(locale, (value) => {
const effects = localeCompartment.reconfigure(editorLocale.of(value))
for (const [path, state] of editorStates) {
if (path === ws.active && view) {
view.dispatch({ effects })
editorStates.set(path, view.state)
} else {
editorStates.set(path, state.update({ effects }).state)
}
}
})
// 字号、正文宽度变化后行高随之改变,编辑器需要重新测量
watch(
() => [settings.uiFont, settings.uiFontSize, settings.fontSize, settings.contentWidth, settings.sidebarWidth, settings.textFont, settings.codeFont, settings.lineHeight],
@@ -1219,7 +1234,9 @@ registerBuiltinCommands()
function registerBuiltinCommands() {
for (const command of editorCommands) {
registerCommand({ ...command, when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && ws.active !== null && !!view, run: () => {
registerCommand({ ...command, get title() {
return command.title
}, when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && ws.active !== null && !!view, run: () => {
if (view) {
command.run(view); view.focus()
}
@@ -1230,29 +1247,69 @@ function registerBuiltinCommands() {
ws.switcher = ws.switcher === null ? initial : null
}
for (const cmd of [
{ id: 'app.quickOpen', title: '快速打开笔记', run: () => toggleSwitcher('') },
{ id: 'app.commandPalette', title: '命令面板', run: () => toggleSwitcher('>') },
{ id: 'app.search', title: '全文搜索', run: openSearch },
{ id: 'note.new', title: '新建笔记', run: () => withSidebar(() => createNote('')) },
{ id: 'folder.new', title: '新建文件夹', run: () => withSidebar(() => createFolder('')) },
{ id: 'note.save', title: '保存当前笔记', when: hasNote, run: () => void flush(ws.active!) },
{ id: 'note.rename', title: '重命名当前笔记', when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) },
{ id: 'note.move', title: '移动当前笔记到…', when: hasNote, run: () => (ws.moving = ws.active) },
{ id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
{ id: 'note.insertImage', title: '插入图片…', when: hasNote, run: insertImagesFromPicker },
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.settings', title: '设置', run: () => (ws.settings = 'index') },
{ id: 'view.sidebar', title: '显示/隐藏侧栏', run: toggleSidebar },
{ id: 'view.outline', title: '显示/隐藏大纲', when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode },
{ id: 'view.focus', title: '开启/关闭专注写作', when: hasNote, run: () => (ws.focusMode = !ws.focusMode) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'app.images', title: '管理根目录图片', run: () => {
{ id: 'app.quickOpen', get title() {
return translate('workspace.quick_open_note_0d376f')
}, run: () => toggleSwitcher('') },
{ id: 'app.commandPalette', get title() {
return translate('workspace.command_palette_4b495d')
}, run: () => toggleSwitcher('>') },
{ id: 'app.search', get title() {
return translate('workspace.full_text_search_a6c47a')
}, run: openSearch },
{ id: 'note.new', get title() {
return translate('workspace.new_note_d3d2d9')
}, run: () => withSidebar(() => createNote('')) },
{ id: 'folder.new', get title() {
return translate('workspace.new_folder_84244a')
}, run: () => withSidebar(() => createFolder('')) },
{ id: 'note.save', get title() {
return translate('workspace.save_current_note_5a7e29')
}, when: hasNote, run: () => void flush(ws.active!) },
{ id: 'note.rename', get title() {
return translate('workspace.rename_current_note_de288b')
}, when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) },
{ id: 'note.move', get title() {
return translate('workspace.move_current_note_to_f1fc16')
}, when: hasNote, run: () => (ws.moving = ws.active) },
{ id: 'note.delete', get title() {
return translate('workspace.delete_current_note_657a00')
}, when: hasNote, run: () => void remove(ws.active!) },
{ id: 'note.insertImage', get title() {
return translate('workspace.insert_image_52c554')
}, when: hasNote, run: insertImagesFromPicker },
{ id: 'tab.close', get title() {
return translate('workspace.close_current_tab_cc8a4e')
}, when: hasNote, run: () => void closeTab(ws.active!) },
{ id: 'app.settings', get title() {
return translate('common.settings_df3d58')
}, run: () => (ws.settings = 'index') },
{ id: 'view.sidebar', get title() {
return translate('workspace.show_hide_sidebar_96b1a4')
}, run: toggleSidebar },
{ id: 'view.outline', get title() {
return translate('workspace.show_hide_outline_1da8d2')
}, when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'view.source', get title() {
return translate('workspace.toggle_source_editing_602b33')
}, when: hasNote, run: toggleSourceMode },
{ id: 'view.focus', get title() {
return translate('workspace.toggle_focus_mode_b52fb6')
}, when: hasNote, run: () => (ws.focusMode = !ws.focusMode) },
{ id: 'app.trash', get title() {
return translate('workspace.open_trash_8a939d')
}, run: () => (ws.settings = 'trash') },
{ id: 'app.images', get title() {
return translate('workspace.manage_root_folder_images_fed368')
}, run: () => {
ws.imagesAll = false; ws.images = ''
} },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'account') },
{ id: 'app.tokens', get title() {
return translate('common.api_tokens_79db53')
}, run: () => (ws.settings = 'account') },
]) {
registerCommand({ ...cmd, keys: typoraKeys(cmd.id) })
registerCommand({ ...cmd, get title() {
return cmd.title
}, keys: typoraKeys(cmd.id) })
}
}
@@ -1290,11 +1347,11 @@ export function stopWorkspace() {
return content === undefined ? Promise.resolve() : drafts.put({ path: tab.path, content, baseRev: tab.rev, updatedAt: Date.now() })
})
draftPreserved = Promise.all(dirty).then(() => {}).catch((error) => {
console.error('保留本地草稿失败', error); toast('本地草稿保存失败,请勿关闭页面')
console.error('保留本地草稿失败', error); toast(translate('workspace.could_not_save_the_local_4b21e1'))
})
for (const slot of slots.values()) clearTimeout(slot.timer)
clearTimeout(treeTimer)
for (const waiter of treeWaiters.splice(0)) waiter.reject(new CanceledError('工作区已关闭'))
for (const waiter of treeWaiters.splice(0)) waiter.reject(new CanceledError(translate('workspace.workspace_closed_221ea3')))
treeFetching = false
treeAgain = false
opsInFlight = 0
@@ -1325,21 +1382,21 @@ async function openMarkdownLink(note: string, href: string) {
try {
url = new URL(decoded, `https://rawmark.local/${encodePath(note)}`)
} catch {
toast('链接格式无效'); return
toast(translate('workspace.invalid_link_format_651be5')); return
}
if (url.origin !== 'https://rawmark.local') {
if (['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) window.open(url.href, '_blank', 'noopener')
else toast('不支持的链接协议')
else toast(translate('workspace.unsupported_link_protocol_cf426f'))
return
}
let target: string
try {
target = decodeURIComponent(url.pathname.slice(1)); decodeURIComponent(url.hash.slice(1))
} catch {
toast('链接编码无效'); return
toast(translate('workspace.invalid_link_encoding_2c6a7e')); return
}
if (!ws.entries.some(e => e.path === target && !e.dir)) {
toast(`链接目标不存在:${target}`); return
toast(translate('workspace.link_target_does_not_exist_2503d1', { p0: target })); return
}
if (!target.endsWith('.md')) {
const response = await getRawFileApi(target)
@@ -1398,7 +1455,7 @@ export async function cleanupImages(paths: string[]) {
const version = user.getSessionVersion()
await flushAll()
user.assertSession(version)
if (!readyToReload()) throw new Error('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再清理')
if (!readyToReload()) throw new Error(translate('workspace.finish_saving_pasting_uploading_or_e67d3d'))
const result = await withOp(() => cleanupImagesApi({ paths }).then(({ data }) => data))
await refreshTree()
await refreshMetadata()
+81 -31
View File
@@ -6,15 +6,18 @@ import * as storageApi from '@@/apis/storage'
import * as themesApi from '@@/apis/themes'
import * as tokensApi from '@@/apis/tokens'
import * as trashApi from '@@/apis/trash'
import { applyLocale, chooseAnonymousLocale, locale } from '@@/i18n/runtime'
import { confirmAction } from '@@/utils/confirm'
import { toasts } from '@@/utils/notifications'
import { flushPromises } from '@vue/test-utils'
import { CanceledError, isCancel } from 'axios'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from 'vue'
import { ApiError } from '@/http/axios'
import { useSettingsChange } from '@/pages/settings/composables/use-settings-change'
import { pinia } from '@/pinia'
import { apiSuccess } from '../../../tests/api-response'
@@ -34,6 +37,8 @@ let app: App | undefined
let host: HTMLElement | undefined
const cleanup: (() => void)[] = []
beforeEach(() => {
applyLocale('en')
setActivePinia(pinia)
vi.mocked(confirmAction).mockResolvedValue(true)
applyFontCatalog([])
Object.assign(settings, DEFAULTS, { colorMode: 'system', theme: 'default', cssSnippets: [], shortcuts: {} })
@@ -42,7 +47,7 @@ beforeEach(() => {
vi.spyOn(settingsApi, 'getSettingsApi').mockResolvedValue(apiSuccess({}))
vi.spyOn(fontsApi, 'getFontsApi').mockResolvedValue(apiSuccess({ fonts: [] }))
vi.spyOn(maintenanceApi, 'getMaintenanceStatusApi').mockResolvedValue(apiSuccess({ busy: false, operation: '', error: '', finishedAt: 0 }))
vi.spyOn(themesApi, 'getThemesApi').mockResolvedValue(apiSuccess({ themes: [{ id: 'default', name: '默认', version: '1' }] }))
vi.spyOn(themesApi, 'getThemesApi').mockResolvedValue(apiSuccess({ themes: [{ id: 'default', name: 'Default', version: '1' }] }))
})
afterEach(async () => {
app?.unmount()
@@ -77,9 +82,9 @@ describe('设置查找与重置', () => {
ws.settings = 'account'
vi.spyOn(tokensApi, 'getTokensApi').mockResolvedValue(apiSuccess({ tokens: [] }))
await mount(SettingsDialog)
expect([...document.body.querySelectorAll('.rm-settings-tab')].map(el => el.textContent)).toEqual(['外观', '编辑器', '快捷键', '插件', '存储空间', '回收站', '账户与安全', '关于'])
expect([...document.body.querySelectorAll('.rm-settings-tab')].map(el => el.textContent)).toEqual(['Appearance', 'Editor', 'Shortcuts', 'Plugins', 'Storage', 'Trash', 'Account and security', 'About'])
await vi.waitFor(() => expect(document.body.querySelector('#rm-setting-tokens')).not.toBeNull())
expect(searchSettings('API 令牌')).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'tokens', section: 'account' })]))
expect(searchSettings('API tokens')).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'tokens', section: 'account' })]))
})
it('编辑器字号在输入时立即生效,无需回车或失焦', async () => {
@@ -101,7 +106,7 @@ describe('设置查找与重置', () => {
it('移除日记和模板设置、搜索与命令入口', async () => {
expect(searchSettings('日记')).toEqual([])
expect(searchSettings('日历')).toEqual([])
expect(searchSettings('Calendar')).toEqual([])
expect(DEFAULTS).not.toHaveProperty('dailyFolder')
expect(searchSettings('模板')).toEqual([])
expect(DEFAULTS).not.toHaveProperty('templatesFolder')
@@ -118,23 +123,23 @@ describe('设置查找与重置', () => {
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
expect(document.body.querySelector('button[aria-label^="删除字体 Maple"]')).toBeNull()
document.body.querySelector<HTMLButtonElement>('button[aria-label="删除字体 Noto Serif SC · 思源宋体"]')!.click()
expect(document.body.querySelector('button[aria-label^="Delete font Maple"]')).toBeNull()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Delete font Noto Serif SC · Serif"]')!.click()
await vi.waitFor(() => expect(settings.hiddenFonts).toContain('serif'))
expect(settings.uiFont).toBe('maple')
expect(settings.textFont).toBe('maple')
expect(settings.codeFont).toBe('maple')
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent?.trim() === '编辑器')!
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(button => button.textContent?.trim() === 'Editor')!
editor.click()
await nextTick()
document.body.querySelector<HTMLButtonElement>('#rm-text-font')!.click()
await nextTick()
expect(document.body.querySelector('.rm-font-options')!.textContent).not.toContain('Noto Serif SC')
expect(document.body.querySelector('.rm-font-options')!.textContent).toContain('导入字体')
expect(document.body.querySelector('.rm-font-options')!.textContent).toContain('Import font')
})
it('先装载字体资源再恢复服务器上的自定义字体,字体删除后恢复Maple', async () => {
const font = { id: `custom-${'a'.repeat(64)}`, name: '导入字体', format: 'woff2' as const }
const font = { id: `custom-${'a'.repeat(64)}`, name: 'Import font', format: 'woff2' as const }
vi.mocked(fontsApi.getFontsApi).mockResolvedValue(apiSuccess({ fonts: [font] }))
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ uiFont: font.id, textFont: font.id, codeFont: font.id }))
await loadSettings()
@@ -154,7 +159,7 @@ describe('设置查找与重置', () => {
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
const serif = [...document.body.querySelectorAll<HTMLButtonElement>('.el-select-dropdown__item')].find(button => button.textContent?.trim() === 'Noto Serif SC · 思源宋体')!
const serif = [...document.body.querySelectorAll<HTMLButtonElement>('.el-select-dropdown__item')].find(button => button.textContent?.trim() === 'Noto Serif SC · Serif')!
serif.click()
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('字体设置写入失败'))
expect(settings.uiFont).toBe('maple')
@@ -170,7 +175,7 @@ describe('设置查找与重置', () => {
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.click()
await nextTick()
document.body.querySelector<HTMLButtonElement>('button[aria-label="删除字体 不可删除测试"]')!.click()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Delete font 不可删除测试"]')!.click()
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('字体删除失败'))
expect(settings.uiFont).toBe(font.id)
expect(document.body.querySelector<HTMLButtonElement>('#rm-ui-font')!.disabled).toBe(false)
@@ -194,8 +199,8 @@ describe('设置查找与重置', () => {
it('按名称、关键词及多词匹配,只索引静态说明', () => {
expect(searchSettings(' ')).toEqual([])
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'account', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })]))
expect(searchSettings('CSS advanced')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'account', title: 'API tokens' }), expect.objectContaining({ id: 'openapi' })]))
settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }]
expect(searchSettings('private')).toEqual([])
})
@@ -203,12 +208,12 @@ describe('设置查找与重置', () => {
it('从搜索结果跳转设置行,并展开所在折叠区', async () => {
ws.settings = 'editor'
await mount(SettingsDialog)
await search('input[aria-label="搜索设置"]', 'CSS 高级')
await search('input[aria-label="Search settings"]', 'CSS advanced')
document.body.querySelector<HTMLButtonElement>('.rm-settings-result')!.click()
await vi.waitFor(() => expect(document.activeElement?.id).toBe('rm-setting-css-snippets'))
expect(ws.settings).toBe('appearance')
expect(document.body.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open).toBe(true)
expect(document.body.querySelector<HTMLInputElement>('input[aria-label="搜索设置"]')!.value).toBe('')
expect(document.body.querySelector<HTMLInputElement>('input[aria-label="Search settings"]')!.value).toBe('')
})
it('单项重置只提交对应键,不影响另一类字号,并可重新读取', async () => {
@@ -217,13 +222,13 @@ describe('设置查找与重置', () => {
settings.lineHeight = 2
ws.settings = 'appearance'
await mount(SettingsDialog)
document.body.querySelector<HTMLButtonElement>('button[aria-label="重置界面字号"]')!.click()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Reset Interface font size"]')!.click()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenCalledWith({ uiFontSize: DEFAULTS.uiFontSize }))
expect(settings.fontSize).toBe(24)
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent?.trim() === '编辑器')!
const editor = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-settings-tab')].find(e => e.textContent?.trim() === 'Editor')!
editor.click()
await nextTick()
document.body.querySelector<HTMLButtonElement>('button[aria-label="重置正文行高"]')!.click()
document.body.querySelector<HTMLButtonElement>('button[aria-label="Reset Body line height"]')!.click()
await vi.waitFor(() => expect(settingsApi.patchSettingsApi).toHaveBeenLastCalledWith({ lineHeight: DEFAULTS.lineHeight }))
expect(settings.fontSize).toBe(24)
vi.mocked(settingsApi.getSettingsApi).mockResolvedValue(apiSuccess({ uiFontSize: DEFAULTS.uiFontSize, fontSize: 24, lineHeight: DEFAULTS.lineHeight }))
@@ -400,19 +405,19 @@ describe('保存反馈', () => {
const failure = await updateSettings({ theme: 'unavailable' }).catch(error => error)
expect(failure).toBeInstanceOf(SettingsAppearanceError)
expect(failure.cause).toBe(cause)
expect(failure.message).toContain('设置已保存,但主题应用失败')
expect(failure.message).toContain('Settings saved, but the theme could not be applied')
expect(settingsSave).toMatchObject({ pending: 0, result: 'saved' })
})
it('取消的主题读取保留取消语义,不伪装成主题应用失败', async () => {
vi.spyOn(themesApi, 'getThemeApi').mockRejectedValueOnce(new CanceledError('会话已变化'))
vi.spyOn(themesApi, 'getThemeApi').mockRejectedValueOnce(new CanceledError('Session changed'))
const failure = await updateSettings({ theme: 'cancelled-theme' }).catch(error => error)
expect(isCancel(failure)).toBe(true)
expect(failure).not.toBeInstanceOf(SettingsAppearanceError)
})
it('失效会话的设置保存取消后不弹错误或启动新会话的回退读取', async () => {
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new CanceledError('会话已变化'))
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new CanceledError('Session changed'))
ws.settings = 'editor'
await mount(SettingsDialog)
toasts.splice(0)
@@ -430,10 +435,10 @@ describe('保存反馈', () => {
ws.settings = 'appearance'
await mount(SettingsDialog)
document.body.querySelector<HTMLDetailsElement>('.rm-appearance-advanced')!.open = true
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent?.trim() === '新建片段')!
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(e => e.textContent?.trim() === 'New snippet')!
add.click()
await nextTick()
await search('input[aria-label="片段名称"]', '主题失败时的片段')
await search('input[aria-label="Snippet name"]', '主题失败时的片段')
const css = document.body.querySelector<HTMLTextAreaElement>('textarea')!
css.value = ':root { --test-marker: 1; }'
css.dispatchEvent(new Event('input', { bubbles: true }))
@@ -450,23 +455,23 @@ describe('快捷键查找', () => {
const occupied = { id: 'plugin:settings-test:hidden', title: '隐藏占用命令', keys: ['Mod+Alt+Shift+9'], run() {} }
cleanup.push(registerCommand({ id: 'settings-test.target', title: '审查定位命令', run() {} }), registerCommand(occupied))
await mount(ShortcutSettings)
await search('input[aria-label="搜索快捷键"]', '审查定位命令')
await search('input[aria-label="Search shortcuts"]', '审查定位命令')
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
const add = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent?.trim() === '添加')!
const add = [...document.body.querySelectorAll<HTMLButtonElement>('.rm-shortcut-row button')].find(e => e.textContent?.trim() === 'Add')!
add.click()
await nextTick()
document.body.querySelector('[data-shortcut-recorder]')!.dispatchEvent(new KeyboardEvent('keydown', { key: '9', code: 'Digit9', ctrlKey: true, altKey: true, shiftKey: true, bubbles: true, cancelable: true }))
await nextTick()
expect(document.body.querySelector('[role="alert"]')!.textContent).toContain('隐藏占用命令')
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
await search('input[aria-label="搜索快捷键"]', 'plugin:settings-test:hidden')
await search('input[aria-label="Search shortcuts"]', 'plugin:settings-test:hidden')
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(1)
expect(document.body.querySelector('[data-shortcut-recorder]')).toBeNull()
await search('input[aria-label="搜索快捷键"]', formatKeys(commandKeys(occupied)[0]))
await search('input[aria-label="Search shortcuts"]', formatKeys(commandKeys(occupied)[0]))
expect(document.body.textContent).toContain('隐藏占用命令')
await search('input[aria-label="搜索快捷键"]', '不存在的命令')
await search('input[aria-label="Search shortcuts"]', '不存在的命令')
expect(document.body.querySelectorAll('.rm-shortcut-row')).toHaveLength(0)
expect(document.body.textContent).toContain('没有匹配的快捷键')
expect(document.body.textContent).toContain('no matching shortcuts')
})
})
@@ -488,7 +493,7 @@ describe('应用存储与回收站', () => {
expect(storage).toHaveBeenCalledTimes(1)
resolve(usage)
await vi.waitFor(() => expect(document.body.textContent).toContain('1 KiB'))
expect(document.body.textContent).toContain('附件')
expect(document.body.textContent).toContain('Attachments')
storage.mockRejectedValueOnce(new Error('磁盘读取错误'))
button.click()
await vi.waitFor(() => expect(document.body.querySelector('[role="alert"]')?.textContent).toContain('磁盘读取错误'))
@@ -583,3 +588,48 @@ it('换会话后旧设置队列不得使用新Token提交,也不得覆盖新
expect(settingsSave.result).toBe('idle')
user.setToken('')
})
describe('账户语言', () => {
it('登录页的临时语言不写服务器,登录后以账户语言覆盖', async () => {
chooseAnonymousLocale('zh-CN')
expect(locale.value).toBe('zh-CN')
expect(settingsApi.patchSettingsApi).not.toHaveBeenCalled()
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ locale: 'en' }))
await loadSettings()
expect(locale.value).toBe('en')
expect(settings.locale).toBe('en')
expect(JSON.parse(localStorage.getItem('rawmark.settings')!).locale).toBe('en')
})
it('语言立即生效、串行保存并从服务器同步恢复,缺省恢复英文', async () => {
const pending = updateSettings({ locale: 'zh-CN' })
expect(locale.value).toBe('zh-CN')
expect(document.documentElement.lang).toBe('zh-CN')
await pending
expect(settingsApi.patchSettingsApi).toHaveBeenCalledWith({ locale: 'zh-CN' })
vi.mocked(settingsApi.getSettingsApi).mockResolvedValueOnce(apiSuccess({ locale: 'zh-CN' }))
await loadSettings()
expect(locale.value).toBe('zh-CN')
await loadSettings()
expect(locale.value).toBe('en')
})
it('语言保存失败会报告错误并重新应用服务器语言', async () => {
vi.mocked(settingsApi.patchSettingsApi).mockRejectedValueOnce(new ApiError(503, 'Cannot save language'))
expect(await useSettingsChange()({ locale: 'zh-CN' })).toBe(false)
expect(locale.value).toBe('en')
expect(settings.locale).toBe('en')
expect(toasts.some(item => item.text.includes('Cannot save language'))).toBe(true)
})
it('切换语言会更新已打开的设置分类与搜索,不翻译插件自带标题', async () => {
ws.settings = 'appearance'
await mount(SettingsDialog)
expect(document.body.querySelector('.rm-settings-tab')!.textContent).toContain('Appearance')
await updateSettings({ locale: 'zh-CN' })
await nextTick()
expect(document.body.querySelector('.rm-settings-tab')!.textContent).toContain('外观')
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets' })])
cleanup.push(registerCommand({ id: 'plugin:i18n:sample', title: '插件原始标题', run() {} }))
await updateSettings({ locale: 'en' })
expect(allCommands().find(command => command.id === 'plugin:i18n:sample')?.title).toBe('插件原始标题')
expect(allCommands().find(command => command.id === 'note.new')?.title).toBe('New note')
})
})
+21 -10
View File
@@ -2,10 +2,11 @@ import type { Appearance } from '@@/appearance/appearance'
import { getFontsApi } from '@@/apis/fonts'
import { getSettingsApi, patchSettingsApi } from '@@/apis/settings'
import { getThemeApi } from '@@/apis/themes'
import { appearancePaused, applyAppearance, sanitizeAppearance } from '@@/appearance/appearance'
import { applyFontCatalog, customFonts, parseFontCatalog } from '@@/appearance/fonts'
import { applyDisplaySettings, DISPLAY_DEFAULTS, FONT_PRESETS, sanitizeDisplaySettings } from '@@/appearance/typography'
import { applyLocale, DEFAULT_LOCALE, isLocale, t as translate } from '@@/i18n/runtime'
import { normalizeKeys } from '@@/utils/keys'
import { isCancel } from 'axios'
import { defineStore } from 'pinia'
@@ -18,15 +19,23 @@ import { useUserStore } from './user'
export const TAB_SIZES = [2, 4, 8]
// 0 为全宽
export const CONTENT_WIDTHS = [
{ label: '窄', value: 680 },
{ label: '标准', value: 760 },
{ label: '宽', value: 960 },
{ label: '全宽', value: 0 },
{ get label() {
return translate('common.narrow_a614db')
}, value: 680 },
{ get label() {
return translate('common.standard_6bea77')
}, value: 760 },
{ get label() {
return translate('common.wide_8dd966')
}, value: 960 },
{ get label() {
return translate('common.full_width_8ecc7d')
}, value: 0 },
]
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
export const DEFAULTS = { trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export const DEFAULTS = { locale: DEFAULT_LOCALE, trashRetentionDays: 30, hiddenFonts: [] as string[], selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
const CACHE_KEY = 'rawmark.settings'
@@ -35,7 +44,7 @@ export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts'
export class SettingsAppearanceError extends Error {
constructor(cause: unknown) {
super(`设置已保存,但主题应用失败:${cause instanceof Error ? cause.message : String(cause)}`, { cause })
super(translate('common.settings_saved_but_the_theme_9f87f8', { p0: cause instanceof Error ? cause.message : String(cause) }), { cause })
}
}
@@ -44,6 +53,7 @@ function sanitize(raw: Record<string, unknown>): Settings {
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
const hiddenFonts = Array.isArray(raw.hiddenFonts) ? raw.hiddenFonts.filter((id): id is string => typeof id === 'string' && id !== 'maple' && Object.hasOwn(FONT_PRESETS, id)) : []
return {
locale: isLocale(raw.locale) ? raw.locale : DEFAULT_LOCALE,
hiddenFonts,
trashRetentionDays: Number.isSafeInteger(raw.trashRetentionDays) && (raw.trashRetentionDays as number) >= 0 ? raw.trashRetentionDays as number : DEFAULTS.trashRetentionDays,
...sanitizeDisplaySettings(raw, customFonts.value.map(font => font.id), hiddenFonts),
@@ -61,10 +71,10 @@ function sanitize(raw: Record<string, unknown>): Settings {
function sanitizeShortcuts(raw: unknown): Record<string, string[]> {
if (raw === undefined) return {}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('快捷键设置格式无效')
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(translate('common.invalid_shortcut_settings_2f07e6'))
const out: Record<string, string[]> = {}
for (const [id, keys] of Object.entries(raw)) {
if (!Array.isArray(keys) || keys.some(key => typeof key !== 'string')) throw new Error('快捷键设置格式无效')
if (!Array.isArray(keys) || keys.some(key => typeof key !== 'string')) throw new Error(translate('common.invalid_shortcut_settings_2f07e6'))
out[id] = keys.map(normalizeKeys)
}
return out
@@ -95,6 +105,7 @@ export const useSettingsStore = defineStore('settings', () => {
function applied(next: Settings) {
Object.assign(settings, next)
applyLocale(settings.locale)
localStorage.setItem(CACHE_KEY, JSON.stringify(settings))
const root = document.documentElement.style
applyDisplaySettings(settings)
@@ -172,7 +183,7 @@ export const useSettingsStore = defineStore('settings', () => {
/** 维护前等待现有设置保存;失败时不得继续替换配置。 */
async function flushSettings() {
await saveQueue
if (settingsSave.result === 'error') throw new Error(`设置保存失败:${settingsSave.error}`)
if (settingsSave.result === 'error') throw new Error(translate('common.could_not_save_settings_1cdd7f', { p0: settingsSave.error }))
}
return { settings, settingsSave, loadSettings, updateSettings, flushSettings, reloadTheme }
+3 -2
View File
@@ -1,4 +1,5 @@
import { getCurrentUserApi, logoutApi } from '@@/apis/auth'
import { t as translate } from '@@/i18n/runtime'
import { CanceledError } from 'axios'
import { defineStore } from 'pinia'
@@ -33,7 +34,7 @@ export const useUserStore = defineStore('user', () => {
token.value = value
}
function assertSession(version: number) {
if (version !== sessionVersion.value) throw new CanceledError('会话已变化')
if (version !== sessionVersion.value) throw new CanceledError(translate('common.session_changed_073f28'))
}
function invalidate(version: number) {
if (version === sessionVersion.value) replaceToken('')
@@ -47,7 +48,7 @@ export const useUserStore = defineStore('user', () => {
if (!data || !Array.isArray(data.permissions)
|| data.permissions.some(value => typeof value !== 'string')
|| typeof data.expiresAt !== 'string' || !Number.isFinite(Date.parse(data.expiresAt))) {
throw new Error('身份信息格式错误')
throw new Error(translate('common.invalid_identity_information_e69b1a'))
}
permissions.value = data.permissions
expiresAt.value = data.expiresAt
+12
View File
@@ -1,6 +1,8 @@
import * as authApi from '@@/apis/auth'
import { applyLocale } from '@@/i18n/runtime'
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { pinia } from '@/pinia'
@@ -12,6 +14,7 @@ import { constantRoutes } from './routes'
const user = useUserStore(pinia)
const identity = { permissions: ['vault:read', 'vault:write', 'instance:manage'], expiresAt: '2030-01-01T00:00:00Z' }
beforeEach(() => {
applyLocale('en')
user.setToken(''); vi.spyOn(authApi, 'getAuthStateApi').mockResolvedValue(apiSuccess({ setUp: true })); vi.spyOn(authApi, 'getCurrentUserApi').mockResolvedValue(apiSuccess(identity))
})
afterEach(() => {
@@ -61,3 +64,12 @@ it('身份恢复的网络失败必须拒绝,不能伪装成匿名或成功', a
it.each(['//evil.example', 'https://evil.example', '/\\evil.example', '/login', '/setup'])('拒绝外部及循环回跳 %s', (value) => {
expect(loginRedirect(value)).toBe('/')
})
it('登录页语言切换同步标题,不依赖再次导航', async () => {
const router = makeRouter()
await router.push('/login')
expect(document.title).toBe('Sign in')
applyLocale('zh-CN')
await nextTick()
expect(document.title).toBe('登录')
})
+9 -4
View File
@@ -1,7 +1,9 @@
import type { Router } from 'vue-router'
import { getAuthStateApi } from '@@/apis/auth'
import { locale, t as translate } from '@@/i18n/runtime'
import { isCancel } from 'axios'
import { watch } from 'vue'
import { pinia } from '@/pinia'
import { usePermissionStore } from '@/pinia/stores/permission'
import { useUserStore } from '@/pinia/stores/user'
@@ -48,7 +50,10 @@ export function registerNavigationGuard(router: Router) {
if (to.meta.anonymous) return loginRedirect(to.query.redirect)
return true
})
router.afterEach((to) => {
document.title = typeof to.meta.title === 'string' ? to.meta.title : 'RawMark'
})
function updateTitle() {
const meta = router.currentRoute.value.meta
document.title = typeof meta.titleKey === 'string' ? translate(meta.titleKey) : typeof meta.title === 'string' ? meta.title : 'RawMark'
}
watch(locale, updateTitle)
router.afterEach(updateTitle)
}
+4 -4
View File
@@ -1,10 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
export const constantRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: () => import('@/pages/login/index.vue'), meta: { title: '登录', anonymous: true } },
{ path: '/setup', name: 'Setup', component: () => import('@/pages/login/index.vue'), meta: { title: '初始化', anonymous: true } },
{ path: '/403', name: 'Forbidden', component: () => import('@/pages/error/403.vue'), meta: { title: '访问受限' } },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/pages/error/404.vue'), meta: { title: '页面不存在' } },
{ path: '/login', name: 'Login', component: () => import('@/pages/login/index.vue'), meta: { titleKey: 'common.sign_in_1e2df9', anonymous: true } },
{ path: '/setup', name: 'Setup', component: () => import('@/pages/login/index.vue'), meta: { titleKey: 'common.setup_65622e', anonymous: true } },
{ path: '/403', name: 'Forbidden', component: () => import('@/pages/error/403.vue'), meta: { titleKey: 'common.access_denied_25fb07' } },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/pages/error/404.vue'), meta: { titleKey: 'common.page_not_found_e75737' } },
]
export const dynamicRoutes: RouteRecordRaw[] = [
{ path: '/', name: 'Workspace', component: () => import('@/pages/workspace/index.vue'), meta: { title: 'RawMark', permissions: ['vault:read'] } },