feat(i18n): add English and Simplified Chinese
This commit is contained in:
1 parent
38c4c8b781
commit
1e9e0678ee
149 files changed
+5356
-1144
No files matched your search
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;设置同步不重复替换相同字体声明', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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": "网络或本地存储错误"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
} })),
|
||||
})
|
||||
@@ -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(' / ')})` : '')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? '' : '+')
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user