refactor(web): adopt modular Vue architecture and isolate bearer sessions

This commit is contained in:
eezd committed 2026-10-25 20:47:32 +08:00
1 parent 1b2b2b12ff
commit 3cd641a380
379 files changed
+19736 -8712

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import Toasts from '@@/components/Toasts.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useUserStore } from '@/pinia/stores/user'
import { navigationError, router } from '@/router'
const user = useUserStore()
function retry() {
const { path, query, hash } = router.resolve(navigationError.value!.path)
void router.replace({ path, query, hash, force: true }).catch(() => { /* 错误由路由错误界面展示。 */ })
}
</script>
<template>
<el-config-provider :locale="zhCn">
<div v-if="navigationError" class="rm-splash" role="alert">
<p>{{ navigationError.message }}</p><el-button @click="retry">
重试
</el-button>
</div>
<router-view v-else v-slot="{ Component, route }">
<component :is="Component" :key="route.meta.anonymous ? route.name : user.sessionVersion" />
</router-view>
<Toasts />
</el-config-provider>
</template>
-45
View File
@@ -1,45 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, setUnauthorizedHandler } from './api'
import AuthView from './AuthView.vue'
import AppStatus from './AppStatus.vue'
import { online, registerPwa } from './pwa'
import Toasts from './Toasts.vue'
import Workspace from './Workspace.vue'
import { stopWorkspace } from './workspace'
const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading')
async function check() {
try {
const s = await api.authState()
// 请求成功比浏览器的网络状态提示更可靠(局域网和网络切换时可能不同步)。
online.value = true
phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login'
} catch {
phase.value = 'error'
}
}
setUnauthorizedHandler(() => {
stopWorkspace()
phase.value = 'login'
})
onMounted(() => {
void check()
void registerPwa()
})
</script>
<template>
<AppStatus />
<AuthView v-if="phase === 'setup' || phase === 'login'" :mode="phase" @done="phase = 'ready'" />
<Workspace v-else-if="phase === 'ready'" @logout="phase = 'login'" />
<div v-else class="rm-splash">
<template v-if="phase === 'error'">
<p>{{ online ? '无法连接到服务器' : '离线,无法加载笔记' }}</p>
<button class="rm-btn" @click="check">重试</button>
</template>
</div>
<Toasts />
</template>
-108
View File
@@ -1,108 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, ApiError, type Theme } from './api'
import { DISPLAY_DEFAULTS } from '../themes/typography'
import { appearancePaused, fixedThemeMode, type CssSnippet } from '../themes/appearance'
import { loadSettings, reloadTheme, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { toast } from './workspace'
const themes = ref<Theme[]>([{ id: 'default', name: '默认', version: '1' }])
const error = ref('')
const busy = ref(false)
const editing = ref<string | null>(null)
const draft = ref({ name: '', css: '' })
const missingTheme = computed(() => !themes.value.some((theme) => theme.id === settings.theme))
const fixedMode = computed(() => fixedThemeMode(settings.theme))
async function refreshThemes() {
error.value = ''
try {
themes.value = await api.themes()
await reloadTheme()
}
catch (err) { error.value = err instanceof ApiError ? err.message : '读取主题失败,请检查网络' }
}
onMounted(refreshThemes)
async function change(patch: Partial<Settings>): Promise<boolean> {
busy.value = true
try {
await updateSettings(patch)
return true
} catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return true }
toast('保存外观失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch((err) => toast('重新读取外观失败:' + (err instanceof ApiError ? err.message : '网络错误')))
return false
} finally { busy.value = false }
}
function edit(snippet?: CssSnippet) {
editing.value = snippet?.id ?? ''
draft.value = { name: snippet?.name ?? '', css: snippet?.css ?? '' }
}
async function saveSnippet() {
if (!draft.value.name.trim()) return
const snippets = settings.cssSnippets.map((s) => ({ ...s }))
const existing = snippets.find((s) => s.id === editing.value)
if (existing) Object.assign(existing, draft.value, { name: draft.value.name.trim() })
else {
// getRandomValues 在局域网 HTTP 开发页面也可用,randomUUID 需要安全上下文。
const id = [...crypto.getRandomValues(new Uint32Array(4))].map((n) => n.toString(16).padStart(8, '0')).join('')
snippets.push({ ...draft.value, name: draft.value.name.trim(), id, enabled: true })
}
if (await change({ cssSnippets: snippets })) editing.value = null
}
async function toggle(snippet: CssSnippet) {
await change({ cssSnippets: settings.cssSnippets.map((s) => s.id === snippet.id ? { ...s, enabled: !s.enabled } : { ...s }) })
}
async function remove(snippet: CssSnippet) {
if (!window.confirm(`删除 CSS 片段“${snippet.name}”?`)) return
if (await change({ cssSnippets: settings.cssSnippets.filter((s) => s.id !== snippet.id) })) {
if (editing.value === snippet.id) editing.value = null
}
}
async function reset() {
if (!await change({ colorMode: 'system', theme: 'default', ...DISPLAY_DEFAULTS,
cssSnippets: settings.cssSnippets.map((s) => ({ ...s, enabled: false })) })) return
toast('已恢复默认外观与排版,CSS 片段已停用并保留')
}
</script>
<template>
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
<SettingRow id="theme-select" control="rm-theme-select">
<select id="rm-theme-select" :value="settings.theme" :disabled="busy" @change="change({ theme: ($event.target as HTMLSelectElement).value })">
<option v-for="theme in themes" :key="theme.id" :value="theme.id">{{ theme.name }}</option>
<option v-if="missingTheme" :value="settings.theme">{{ settings.theme }}(不可用)</option>
</select>
<button class="rm-btn" :disabled="busy" @click="refreshThemes">刷新列表</button>
</SettingRow>
<SettingRow id="color-mode">
<div class="rm-segmented" role="radiogroup" aria-label="明暗模式">
<button v-for="mode in [{ id: 'light', title: '浅色' }, { id: 'dark', title: '深色' }, { id: 'system', title: '跟随系统' }]" :key="mode.id" role="radio" :aria-checked="(fixedMode ?? settings.colorMode) === mode.id" :class="{ on: (fixedMode ?? settings.colorMode) === mode.id }" :disabled="busy || !!fixedMode" @click="change({ colorMode: mode.id as Settings['colorMode'] })">{{ mode.title }}</button>
</div>
</SettingRow>
<p v-if="error" class="rm-appearance-note rm-auth-error" role="alert">{{ error }}</p>
<p v-if="fixedMode" class="rm-appearance-note">白色、黑色主题固定使用对应明暗模式;切换回默认或自定义主题后可调整明暗设置。</p>
<details class="rm-settings-details rm-appearance-advanced"><summary>自定义 CSS 与恢复</summary>
<SettingRow id="css-snippets"><button class="rm-btn" :disabled="busy" @click="edit()">新建片段</button></SettingRow>
<div v-for="snippet in settings.cssSnippets" :key="snippet.id" class="rm-snippet-row">
<label><input type="checkbox" :checked="snippet.enabled" :disabled="busy" @change="toggle(snippet)" />{{ snippet.name }}</label>
<button class="rm-btn" :disabled="busy" @click="edit(snippet)">编辑</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(snippet)">删除</button>
</div>
<form v-if="editing !== null" class="rm-snippet-form" @submit.prevent="saveSnippet">
<label>片段名称<input v-model="draft.name" aria-label="片段名称" maxlength="100" required /></label>
<label>CSS<textarea v-model="draft.css" aria-label="CSS" rows="9" spellcheck="false" placeholder=":root { --rm-accent: #805ad5; }" /></label>
<div><button class="rm-btn" :disabled="busy" type="submit">保存并应用</button> <button class="rm-btn" type="button" @click="editing = null">取消</button></div>
</form>
<SettingRow id="reset-appearance"><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观与排版</button></SettingRow>
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
</details>
</template>
-59
View File
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api, ApiError } from './api'
const props = defineProps<{ mode: 'setup' | 'login' }>()
const emit = defineEmits<{ done: [] }>()
const password = ref('')
const confirm = ref('')
const error = ref('')
const busy = ref(false)
const isSetup = computed(() => props.mode === 'setup')
async function submit() {
error.value = ''
if (isSetup.value && password.value !== confirm.value) {
error.value = '两次输入的密码不一致'
return
}
busy.value = true
try {
if (isSetup.value) await api.setup(password.value)
else await api.login(password.value)
emit('done')
} catch (err) {
error.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
} finally {
busy.value = false
}
}
</script>
<template>
<div class="rm-auth">
<form class="rm-auth-card" @submit.prevent="submit">
<h1>RawMark</h1>
<p v-if="isSetup" class="rm-auth-hint">
首次使用,请设置登录密码。
</p>
<label>
<span>密码</span>
<input
v-model="password"
type="password"
:autocomplete="isSetup ? 'new-password' : 'current-password'"
:minlength="isSetup ? 8 : undefined"
required
autofocus
/>
</label>
<label v-if="isSetup">
<span>确认密码</span>
<input v-model="confirm" type="password" autocomplete="new-password" required />
</label>
<p v-if="error" class="rm-auth-error">{{ error }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">{{ isSetup ? '完成设置' : '登录' }}</button>
</form>
</div>
</template>
-113
View File
@@ -1,113 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api } from './api'
import { customFonts, loadFonts } from './fonts'
import { FONT_PRESETS } from '../themes/typography'
import { loadSettings, settings, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import { toast } from './workspace'
import Icon from './Icon.vue'
const props = defineProps<{ id: string; setting: 'uiFont' | 'textFont' | 'codeFont' }>()
const expanded = ref(false)
const busy = ref(false)
const error = ref('')
const fileInput = ref<HTMLInputElement>()
const fonts = computed(() => [
...Object.entries(FONT_PRESETS).filter(([id]) => !settings.hiddenFonts.includes(id)).map(([id, font]) => ({ id, name: font.label })),
...customFonts.value,
])
const selected = computed(() => fonts.value.find(font => font.id === settings[props.setting]) ?? { id: 'maple', name: FONT_PRESETS.maple.label })
async function choose(id: string) {
error.value = ''
busy.value = true
try { if (await save({ [props.setting]: id })) expanded.value = false }
finally { busy.value = false }
}
async function save(patch: Partial<Settings>) {
try { await updateSettings(patch); return true }
catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return true }
error.value = cause instanceof Error ? cause.message : String(cause)
try { await loadSettings() }
catch (reload) { error.value += ';重新读取设置失败:' + String(reload) }
return false
}
}
async function importFont(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
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 字体')
// 让浏览器校验实际字体;无效字体不会上传或进入页面的字体加载集合。
const data = await file.arrayBuffer()
try { await new FontFace('rawmark-import-check', data).load() }
catch (cause) { throw new Error('无法解析字体文件,请选择有效的字体。', { cause }) }
const font = await api.importFont(file)
await loadFonts()
if (!await save({ [props.setting]: font.id })) return
expanded.value = false
toast('字体已导入')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
async function remove(id: string) {
busy.value = true
error.value = ''
try {
if (Object.hasOwn(FONT_PRESETS, id)) {
const patch: Partial<typeof settings> = { hiddenFonts: [...settings.hiddenFonts, id] }
for (const key of ['uiFont', 'textFont', 'codeFont'] as const) if (settings[key] === id) patch[key] = 'maple'
if (!await save(patch)) return
} else {
await api.deleteFont(id)
await loadSettings()
}
toast('字体已删除,使用它的位置已恢复 Maple')
} catch (cause) { error.value = cause instanceof Error ? cause.message : String(cause) }
finally { busy.value = false }
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && expanded.value) { event.stopPropagation(); expanded.value = false }
}
</script>
<template>
<div class="rm-font-picker" @keydown="onKey">
<button :id="id" class="rm-btn rm-font-trigger" :aria-expanded="expanded" :aria-controls="id + '-options'" :disabled="busy" @click="expanded = !expanded"><span>{{ selected.name }}</span><Icon name="chevron" /></button>
<div v-if="expanded" :id="id + '-options'" class="rm-font-options">
<div v-for="font in fonts" :key="font.id" class="rm-font-option">
<button class="rm-font-choice" :class="{ on: font.id === settings[setting] }" :aria-pressed="font.id === settings[setting]" :disabled="busy" @click="choose(font.id)"><span>{{ font.name }}</span><small v-if="font.id === 'maple'">默认</small></button>
<button v-if="font.id !== 'maple'" class="rm-icon-btn" :aria-label="'删除字体 ' + font.name" title="删除字体" :disabled="busy" @click="remove(font.id)"><Icon name="trash" /></button>
</div>
<button class="rm-btn rm-font-import" :disabled="busy" @click="fileInput!.click()"><Icon name="import" />{{ busy ? '处理中…' : '导入字体' }}</button>
<p class="rm-share-note">支持 TTF、OTF、WOFF、WOFF2,单个文件不超过 30 MB。</p>
</div>
<input ref="fileInput" type="file" accept=".ttf,.otf,.woff,.woff2" hidden :aria-label="'导入字体 ' + id" @change="importFont" />
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
</div>
</template>
<style scoped>
.rm-font-picker { position: relative; width: 260px; max-width: 100%; min-width: 0; }
.rm-font-trigger { width: 100%; justify-content: space-between; text-align: left; }
.rm-font-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-font-options { position: absolute; top: calc(100% + 4px); right: 0; z-index: 10; width: 100%; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 8px; padding: var(--rm-space-1); max-height: min(320px, 45dvh); overflow: auto; background: var(--rm-bg); box-shadow: var(--rm-shadow); }
.rm-font-option { display: flex; align-items: center; gap: var(--rm-space-1); }
.rm-font-choice { display: flex; align-items: center; flex: 1; min-width: 0; padding: 6px 8px; text-align: left; overflow-wrap: anywhere; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.rm-font-choice span { flex: 1; min-width: 0; }
.rm-font-choice:hover, .rm-font-choice.on { background: var(--rm-hover); }
.rm-font-choice small { flex: none; margin-left: 8px; color: var(--rm-text-muted); white-space: nowrap; }
.rm-font-import { width: 100%; margin-top: var(--rm-space-1); }
.rm-font-options p { margin: var(--rm-space-2); }
@media (pointer: coarse) { .rm-font-choice { min-height: 44px; } }
</style>
-133
View File
@@ -1,133 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ApiError } from './api'
import Icon from './Icon.vue'
import { basename, dirname, isImage, joinPath } from './paths'
import { imageUrl, move, remove, replaceImage, ws } from './workspace'
const props = defineProps<{ path: string }>()
const dialog = ref<HTMLElement>()
const picker = ref<HTMLInputElement>()
const busy = ref(false)
const error = ref('')
const loadError = ref(false)
const dimensions = ref('')
const name = ref('')
const path = computed(() => props.path)
const entry = computed(() => ws.entries.find((e) => e.path === path.value))
const extension = computed(() => basename(path.value).slice(basename(path.value).lastIndexOf('.')))
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' : '')
watch(path, (p) => { name.value = basename(p).slice(0, -extension.value.length); error.value = '' }, { immediate: true })
watch(src, () => { loadError.value = false; dimensions.value = '' })
onMounted(() => dialog.value?.focus())
function close() {
if (!busy.value) ws.image = null
}
function select(offset: number) {
const target = siblings.value[index.value + offset]
if (target && !busy.value) ws.image = target.path
}
function loaded(event: Event) {
const image = event.target as HTMLImageElement
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}`
}
async function rename() {
const base = name.value.trim()
if (!base || base.startsWith('.') || /[/\\]/.test(base)) { error.value = '图片名称不能为空、以点开头或包含斜杠'; return }
const to = joinPath(dirname(path.value), base + extension.value)
if (to === path.value || busy.value) return
busy.value = true
error.value = ''
try { await move(path.value, to) }
catch (err) { error.value = err instanceof Error ? err.message : '修改后同步失败,请检查目录' }
finally { busy.value = false }
}
async function replace(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || busy.value) return
if (file.size > 50 * 1024 * 1024) { error.value = '图片不能超过 50 MB'; return }
if (!file.name.toLowerCase().endsWith(extension.value.toLowerCase())) { error.value = '请选择与原图片扩展名相同的文件'; return }
if (!confirm('替换会影响所有引用此路径的笔记,旧图片不会进入回收站。确定替换?')) return
busy.value = true
error.value = ''
try { await replaceImage(path.value, file) }
catch (err) { error.value = err instanceof ApiError ? err.message : '网络错误,替换结果尚未确认,请检查图片后再重试' }
finally { busy.value = false }
}
async function trash() {
if (busy.value || !confirm('将图片移入回收站?引用它的笔记会暂时无法显示此图片,恢复后可重新显示。')) return
busy.value = true
try { await remove(path.value) }
finally { busy.value = false }
}
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
<section ref="dialog" class="rm-dialog rm-image-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="图片管理" :aria-busy="busy">
<div class="rm-dialog-head">
<h2>图片管理</h2>
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"><Icon name="close" /></button>
</div>
<div class="rm-image-body">
<p class="rm-image-path">{{ path }}</p>
<div class="rm-image-preview">
<p v-if="!entry">图片已不存在,请刷新目录</p>
<p v-else-if="loadError" role="alert">图片无法预览,文件可能损坏或浏览器不支持此格式。</p>
<img v-else :key="src" :src="src" :alt="basename(path)" @load="loaded" @error="loadError = true" />
</div>
<div class="rm-image-navigation">
<button class="rm-btn" :disabled="busy || index <= 0" @click="select(-1)">上一张</button>
<span>{{ index + 1 }} / {{ siblings.length }} · {{ dimensions }} {{ size }}</span>
<button class="rm-btn" :disabled="busy || index >= siblings.length - 1" @click="select(1)">下一张</button>
</div>
<form class="rm-image-rename" @submit.prevent="rename">
<label for="rm-image-name">图片名称</label>
<div>
<input id="rm-image-name" v-model="name" :disabled="busy || !entry" required />
<span>{{ extension }}</span>
<button class="rm-btn" :disabled="busy || !entry || name.trim() + extension === basename(path)">重命名</button>
</div>
</form>
<p class="rm-image-hint">改名会更新笔记中的本地引用;替换保留原路径,请选择同格式图片(最大 50 MB)。</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<div class="rm-image-actions">
<input ref="picker" type="file" :accept="extension" hidden :disabled="busy" @change="replace" />
<button class="rm-btn" :disabled="busy || !entry" @click="ws.moving = path">移动到…</button>
<button class="rm-btn rm-btn-danger" :disabled="busy || !entry" @click="trash">移入回收站</button>
<button class="rm-btn rm-btn-primary" :disabled="busy || !entry" @click="picker?.click()">{{ busy ? '处理中…' : '替换图片' }}</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.rm-image-dialog { max-width: 860px; width: min(860px, calc(100vw - 24px)); }
.rm-image-body { display: flex; flex-direction: column; gap: 14px; padding: 0 20px 20px; overflow-y: auto; font-size: var(--rm-ui-font-size); }
.rm-image-body p { margin: 0; }
.rm-image-path { overflow-wrap: anywhere; color: var(--rm-text-muted); }
.rm-image-preview { display: flex; align-items: center; justify-content: center; min-height: 140px; background: var(--rm-bg); border: 1px solid var(--rm-divider); border-radius: 6px; padding: 12px; }
.rm-image-preview img { display: block; max-width: 100%; max-height: 44dvh; object-fit: contain; }
.rm-image-navigation, .rm-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.rm-image-actions { flex-wrap: wrap; }
.rm-image-navigation span { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); text-align: center; }
.rm-image-navigation button, .rm-image-rename button { white-space: nowrap; }
.rm-image-rename { display: flex; flex-direction: column; gap: 8px; }
.rm-image-rename > div { display: flex; align-items: center; gap: 8px; }
.rm-image-rename input { min-width: 0; flex: 1; width: 0; border: 1px solid var(--rm-divider); background: var(--rm-bg); color: var(--rm-text); padding: 8px; border-radius: 6px; font: inherit; }
.rm-image-hint { color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: 1.6; }
@media(max-width:767px), (pointer:coarse) { .rm-image-rename input { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
-114
View File
@@ -1,114 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { type Entry } from './api'
import Icon from './Icon.vue'
import { metadata, refreshMetadata } from './metadata'
import { basename, hiddenAssetPath, imageInFolder, isImage } from './paths'
import { cleanupImages, imageUrl, openReference, toast, uploadFolderImages, ws } from './workspace'
const props = defineProps<{ dir: string }>()
const query = ref('')
const format = ref('')
const usage = ref('all')
const sort = ref('modified')
const list = ref(false)
const selected = ref('')
const dimensions = ref('')
const broken = ref(false)
const picker = ref<HTMLInputElement>()
const busy = ref(false)
const report = ref<string[]>([])
const cleanupCandidates = ref<Entry[] | null>(null)
const cleanupSelected = ref<string[]>([])
const uploadDir = ref(props.dir)
const directories = computed(() => ws.entries.filter(entry => entry.dir && !hiddenAssetPath(entry.path)))
const allImages = computed(() => ws.entries.filter(entry => !entry.dir && isImage(entry.path)))
const scopeImages = computed(() => allImages.value.filter(entry => ws.imagesAll || imageInFolder(entry.path, props.dir)))
const references = computed(() => {
const result = new Map<string, { path: string; title: string; line: number }[]>()
for (const note of metadata.notes) for (const asset of note.assets) {
const refs = result.get(asset.target) ?? []
if (!refs.some(ref => ref.path === note.path)) refs.push({ path: note.path, title: note.title, line: asset.line })
result.set(asset.target, refs)
}
return result
})
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
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))
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))
const currentRefs = computed(() => references.value.get(selected.value) ?? [])
const orphans = computed(() => certain.value ? scopeImages.value.filter(entry => !references.value.has(entry.path)) : [])
function size(bytes: number) { return bytes < 1024 * 1024 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB' }
function select(path: string) { selected.value = path; dimensions.value = ''; broken.value = false }
function loaded(event: Event) { const image = event.target as HTMLImageElement; dimensions.value = image.naturalWidth + ' × ' + image.naturalHeight }
onMounted(() => { void refreshMetadata() })
async function upload(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]; input.value = ''
if (!files.length) return
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 }; return true
})
try { const result = await uploadFolderImages(uploadDir.value, valid); report.value.push(`已处理 ${result.uploaded} 张图片,相同内容自动去重`, ...result.failures) }
catch (error) { report.value.push('上传后刷新失败,请重新检查目录:' + (error instanceof Error ? error.message : '网络错误')) }
finally { busy.value = false }
}
async function proposeCleanup() {
await refreshMetadata()
if (!certain.value) { toast('引用状态尚未确认,不能列出清理候选'); return }
cleanupCandidates.value = [...orphans.value]
cleanupSelected.value = orphans.value.map(entry => entry.path)
}
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))
cleanupCandidates.value = null
} catch (error) { report.value.push('清理失败:' + (error instanceof Error ? error.message : '网络错误')) }
finally { busy.value = false }
}
</script>
<template>
<section class="rm-media-library" aria-label="图片库" @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><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header>
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">清理孤立图片</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 || '根目录' }}</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">{{ 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></aside>
<div class="rm-media-content">
<div class="rm-media-toolbar"><label class="rm-media-search"><Icon name="search" /><input v-model="query" type="search" placeholder="搜索图片名称或路径…" aria-label="搜索图片" /></label><select v-model="format" aria-label="图片格式"><option value="">所有格式</option><option v-for="type in types" :key="type" :value="type">{{ type.toUpperCase() }}</option></select><select v-model="sort" aria-label="图片排序"><option value="modified">最近修改</option><option value="name">名称</option><option value="size">文件大小</option></select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list"><Icon :name="list ? 'grid' : 'outline'" /></button></div>
<div class="rm-media-upload"><label>上传至 <select v-model="uploadDir" aria-label="上传目录"><option value="">根目录 / assets</option><option v-for="folder in directories" :key="folder.path" :value="folder.path">{{ folder.path }} / assets</option></select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload" /><button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()"><Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}</button></div>
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <button class="rm-btn" @click="refreshMetadata()">刷新状态</button></p>
<div v-if="report.length" class="rm-media-report" role="status"><p v-for="(line,i) in report" :key="i">{{ line }}</p></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></button>
</div>
<p v-if="!images.length" class="rm-tree-empty">没有符合条件的图片</p>
</div>
<aside v-if="current" class="rm-media-inspector"><button class="rm-icon-btn rm-media-deselect" title="关闭详情" @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>无法预览此图片</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><button class="rm-btn rm-btn-primary" @click="ws.image = current.path"><Icon name="edit" />预览与编辑</button><h3>引用此图片的笔记</h3><button v-for="ref in currentRefs" :key="ref.path" class="rm-media-reference" @click="ws.images = null; openReference(ref.path, ref.line)"><Icon name="note" /><span>{{ ref.title }}<small>{{ ref.path }}</small></span></button><p v-if="certain && !currentRefs.length">没有笔记引用</p></aside>
</div>
<div v-if="cleanupCandidates" class="rm-dialog-backdrop" @click.self="!busy && (cleanupCandidates = null)"><section class="rm-dialog rm-cleanup-dialog" role="dialog" aria-label="确认清理孤立图片"><div class="rm-dialog-head"><h2>清理候选 · {{ cleanupCandidates.length }} 张</h2><button class="rm-icon-btn" :disabled="busy" title="关闭" @click="cleanupCandidates = null"><Icon name="close" /></button></div><p class="rm-share-note">服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。</p><ul><li v-for="image in cleanupCandidates" :key="image.path"><label><input v-model="cleanupSelected" type="checkbox" :value="image.path" :disabled="busy" />{{ image.path }} <small>{{ size(image.size) }}</small></label></li></ul><p v-if="!cleanupCandidates.length" class="rm-share-note">当前范围没有可清理的孤立图片。</p><div class="rm-cleanup-actions"><button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">取消</button><button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">移入回收站({{ cleanupSelected.length }})</button></div></section></div>
</section>
</template>
<style scoped>
.rm-media-library { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: var(--rm-space-6); overflow: hidden; }
.rm-media-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0 0 24px; gap: 12px; } .rm-media-header small { font-size: var(--rm-ui-font-caption); color: var(--rm-accent); letter-spacing: .14em; } h1 { font: 600 var(--rm-ui-font-title)/var(--rm-ui-line-height) var(--rm-font-text); margin: var(--rm-space-2) 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.7; margin: 6px 0; }
.rm-media-workspace { display: flex; flex: 1; min-height: 0; border: 1px solid var(--rm-divider); border-radius: 16px; overflow: hidden; background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); }
.rm-media-filters { flex: none; width: 190px; padding: var(--rm-space-4) var(--rm-space-2); border-right: 1px solid var(--rm-divider); background: var(--rm-sidebar-bg); overflow: auto; } .rm-media-section-label { display: block; margin: 12px 8px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-filters button { overflow-wrap: anywhere; display: flex; align-items: center; gap: 8px; width: 100%; padding: var(--rm-space-2); min-height: var(--rm-control-size); margin: 4px 0; background: none; border: 0; border-radius: 7px; font-size: var(--rm-ui-font-size); text-align: left; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .rm-media-filters button.on { background: var(--rm-active); color: var(--rm-accent); } .rm-media-filters p { font-size: var(--rm-ui-font-caption); padding: 8px; }
.rm-media-content { flex: 1; min-width: 0; overflow: auto; padding: var(--rm-space-4); } .rm-media-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rm-media-search { box-sizing: border-box; min-height: var(--rm-control-size); display: flex; align-items: center; gap: 8px; flex: 1; min-width: 130px; padding: 8px 10px; border: 1px solid var(--rm-divider); border-radius: 8px; } .rm-media-search input { width: 100%; min-width: 0; background: none; color: var(--rm-text); border: 0; outline: none; font: var(--rm-ui-font-size) var(--rm-font-text); } select { min-height: var(--rm-control-size); max-width: 100%; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-bg); color: var(--rm-text); font: var(--rm-ui-font-size) var(--rm-font-text); }
.rm-media-upload { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; align-items: center; margin: 16px 0; } .rm-media-upload label { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--rm-ui-font-size); } .rm-media-upload label select { min-height: var(--rm-control-size); max-width: 100%; min-width: 0; flex: 1; } .rm-media-upload .rm-btn { font-size: var(--rm-ui-font-size); }
.rm-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(145px,1fr)); gap: 16px; padding: 8px 0; } .rm-media-card { display: flex; flex-direction: column; gap: 9px; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 10px; background: var(--rm-active); text-align: left; min-width: 0; cursor: pointer; } .rm-media-card:hover, .rm-media-card.selected { border-color: var(--rm-accent); box-shadow: 0 3px 12px var(--rm-selection); } .rm-media-thumbnail { display: grid; place-items: center; height: 130px; width: 100%; border-radius: 7px; overflow: hidden; background: var(--rm-sidebar-bg); } .rm-media-thumbnail img { width: 100%; height: 100%; object-fit: contain; } .rm-media-card strong { max-width: 100%; font-size: var(--rm-ui-font-size); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-media-card small { font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-media-grid.list { grid-template-columns: 1fr; gap: 6px; } .list .rm-media-card { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 12px; } .list .rm-media-thumbnail { width: 54px; height: 42px; flex: none; } .list .rm-media-card small { margin-left: auto; white-space: nowrap; }
.rm-media-inspector { flex: none; width: 230px; padding: var(--rm-space-4); border-left: 1px solid var(--rm-divider); overflow: auto; } .rm-media-deselect { margin-left: auto; display: flex; } .rm-media-detail-image { height: 145px; border-radius: 8px; background: var(--rm-sidebar-bg); display: grid; place-items: center; overflow: hidden; margin: 12px 0; } .rm-media-detail-image img { width: 100%; height: 100%; object-fit: contain; } h2 { font-size: var(--rm-ui-font-size); overflow-wrap: anywhere; } .rm-media-path { font-size: var(--rm-ui-font-caption); overflow-wrap: anywhere; } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; font-size: var(--rm-ui-font-caption); margin: 20px 0; } dt { color: var(--rm-text-muted); } dd { margin: 0; overflow-wrap: anywhere; } .rm-media-inspector > .rm-btn { width: 100%; justify-content: center; } h3 { font-size: var(--rm-ui-font-size); border-top: 1px solid var(--rm-divider); margin-top: 24px; padding-top: 20px; } .rm-media-reference { overflow-wrap: anywhere; display: flex; gap: 8px; text-align: left; background: none; border: 0; padding: 10px 0; width: 100%; cursor: pointer; font-size: var(--rm-ui-font-size); } .rm-media-reference small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-caption); margin-top: 5px; overflow-wrap: anywhere; }
.rm-media-warning, .rm-media-report { padding: 12px; border-radius: 8px; background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); } .rm-media-warning .rm-btn { font-size: var(--rm-ui-font-size); margin-left: 8px; } .rm-media-report p { color: inherit; overflow-wrap: anywhere; }
.rm-cleanup-dialog { width: min(640px,calc(100vw - 24px)); } .rm-cleanup-dialog ul { list-style: none; overflow: auto; padding: 12px 20px; margin: 0; } .rm-cleanup-dialog li { padding: 10px 0; font-size: var(--rm-ui-font-small); overflow-wrap: anywhere; } .rm-cleanup-dialog label { display: flex; gap: 10px; } .rm-cleanup-dialog small { margin-left: auto; color: var(--rm-text-muted); white-space: nowrap; } .rm-cleanup-actions { display: flex; justify-content: flex-end; gap: 12px; padding: var(--rm-space-4); }
.rm-media-mobile-filters { display: none; }
@media(max-width:1199px) { .rm-media-library { padding: var(--rm-space-4); } .rm-media-filters { width: 180px; padding: 12px 8px; } .rm-media-inspector { width: 180px; padding: var(--rm-space-4); } }
@media(max-width:767px) { .rm-media-mobile-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } .rm-media-mobile-filters .rm-btn { font-size: var(--rm-ui-font-size); } .rm-media-library { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-media-header { padding-bottom: 16px; } .rm-media-filters { display: none; } .rm-media-content { padding: 12px; } .rm-media-toolbar { gap: 6px; } .rm-media-inspector { position: absolute; inset: 0; z-index: 20; background: var(--rm-bg); width: auto; padding: var(--rm-space-4); } .rm-media-workspace { position: relative; } .rm-media-search { flex-basis: 100%; } .rm-media-grid { grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 10px; } .rm-media-upload { flex-wrap: wrap; } .rm-media-upload label { flex: 1; } .rm-media-upload select { min-height: var(--rm-control-size); max-width: 180px; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
-58
View File
@@ -1,58 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { HOLLOW, INLINE_FONTS, SHADOW, type InlineStyles } from '../markdown/inlineStyles'
import { fixedThemeMode } from '../themes/appearance'
import { settings } from './settings'
const props = defineProps<{ styles: InlineStyles; baseSize: number }>()
const emit = defineEmits<{ change: [patch: InlineStyles]; clear: [] }>()
const system = matchMedia('(prefers-color-scheme: dark)')
const systemDark = ref(system.matches)
const onSystem = () => { systemDark.value = system.matches }
system.addEventListener('change', onSystem)
onBeforeUnmount(() => system.removeEventListener('change', onSystem))
const dark = computed(() => (fixedThemeMode(settings.theme) ?? (settings.colorMode === 'system' ? (systemDark.value ? 'dark' : 'light') : settings.colorMode)) === 'dark')
// 思源色板顺序;只影响选中文字,不修改主题背景。
const foreground = computed(() => dark.value
? ['#f39993','#ffd599','#a6d5fa','#b7dfb9','#9aa0a6','#3575f0','#f3a92f','#d23f31','#f47c9c','#4dd0e1','#10df54','#ecd424','#1e1e1e']
: ['#790600','#8a4f00','#005599','#006b04','#5f6368','#3575f0','#ff9200','#d23f31','#a40074','#007080','#006e2a','#6b5a00','#ffffff'])
const background = computed(() => dark.value
? ['#442724','#554636','#28405c','#425347','#3a3f42','#031840','#593905','#3a0c09','#4d1b40','#1a5459','#305415','#4a4712','#dadada']
: ['#f5d1cf','#ffe8c8','#d6eaf9','#d7eed8','#e2e3e4','#acd0fc','#fddaab','#ffb0a9','#fdbfff','#b1ffff','#affad1','#fff88f','#222222'])
const relative = ref(false)
const size = ref(props.baseSize)
watch(() => props.styles['font-size'], value => {
relative.value = value?.endsWith('em') ?? false
size.value = value ? Math.round(parseFloat(value) * (relative.value ? 100 : 1)) : props.baseSize
}, { immediate: true })
function setSize() {
if (!Number.isFinite(size.value) || size.value < (relative.value ? 56 : 9) || size.value > (relative.value ? 450 : 72)) return
emit('change', { 'font-size': relative.value ? `${size.value / 100}em` : `${size.value}px` })
}
function switchSize() {
size.value = Math.max(relative.value ? 56 : 9, Math.min(relative.value ? 450 : 72, Math.round(relative.value ? size.value / props.baseSize * 100 : size.value / 100 * props.baseSize)))
setSize()
}
</script>
<template>
<div class="rm-inline-appearance" role="region" aria-label="文字外观">
<fieldset><legend>配色</legend><div class="rm-inline-swatches">
<button type="button" title="默认配色" aria-label="默认配色" @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] })">A</button>
</div></fieldset>
<fieldset v-for="group in [{ key: 'color', title: '字体颜色', colors: foreground }, { key: 'background-color', title: '背景色', 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>
<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>
</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' })">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 })">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>
<div class="rm-inline-control"><span>字号</span><label><input v-model="relative" type="checkbox" aria-label="相对字号" @change="switchSize" /> 相对字号</label><button type="button" class="rm-btn" @mousedown.prevent @click="emit('change', { 'font-size': undefined })">默认</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></form>
<button type="button" class="rm-btn rm-inline-clear" @mousedown.prevent @click="emit('clear')">清除样式</button>
</div>
</template>
-85
View File
@@ -1,85 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { metadata, refreshMetadata } from './metadata'
import { openNote, openReference, ws } from './workspace'
const query = ref('')
const tag = ref('')
const local = ref(false)
const camera = ref({ x: -420, y: -290, width: 840 })
const tags = computed(() => {
const counts = new Map<string, number>()
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'))
})
const visibleNotes = computed(() => {
const nearby = new Set([ws.active])
for (const note of metadata.notes) for (const link of note.links) if (note.path === ws.active || link.target === ws.active) { nearby.add(note.path); nearby.add(link.target) }
return metadata.notes.filter(note => (!local.value || nearby.has(note.path)) && (!tag.value || note.tags.includes(tag.value)) && `${note.path} ${note.title}`.toLocaleLowerCase().includes(query.value.toLocaleLowerCase()))
})
const graph = computed(() => {
const notes = [...visibleNotes.value].sort((a,b) => a.path.localeCompare(b.path))
const paths = new Set(notes.map(note => note.path))
const seen = new Set<string>()
const edges = notes.flatMap(note => note.links.filter(link => {
const key = note.path + '\0' + link.target
if (seen.has(key) || !paths.has(link.target)) return false
seen.add(key); return true
}).map(link => ({ from: note.path, to: link.target })))
const nodes = notes.map((note, i) => {
const center = local.value && note.path === ws.active
const angle = i * Math.PI * 2 / Math.min(notes.length, 28)
const radius = notes.length === 1 || center ? 0 : 145 + Math.floor(i / 28) * 90
return { ...note, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, degree: edges.filter(edge => edge.from === note.path || edge.to === note.path).length }
})
const byPath = new Map(nodes.map(node => [node.path, node]))
return { nodes, edges: edges.map(edge => ({ from: byPath.get(edge.from)!, to: byPath.get(edge.to)! })) }
})
const missing = computed(() => {
const paths = new Set(metadata.notes.map(note => note.path))
return visibleNotes.value.flatMap(note => note.links.filter(link => !paths.has(link.target)).map(link => ({ ...link, path: note.path })))
})
function zoom(factor: number) { const old = camera.value; const width = Math.max(180, Math.min(5000, old.width * factor)); camera.value = { x: old.x + (old.width - width) / 2, y: old.y + (old.width - width) * .345, width } }
let drag: { x: number; y: number; left: number; top: number } | undefined
function start(event: PointerEvent) {
if (event.target instanceof Element && event.target.closest('[data-note]')) return
const svg = event.currentTarget as SVGSVGElement
svg.setPointerCapture(event.pointerId)
drag = { x: event.clientX, y: event.clientY, left: camera.value.x, top: camera.value.y }
}
function pan(event: PointerEvent) { if (!drag) return; const scale = camera.value.width / (event.currentTarget as SVGSVGElement).getBoundingClientRect().width; camera.value.x = drag.left - (event.clientX - drag.x) * scale; camera.value.y = drag.top - (event.clientY - drag.y) * scale }
watch(() => ws.view, () => { query.value = ''; tag.value = '' })
</script>
<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><button class="rm-btn" @click="ws.view = 'notes'">返回笔记</button></header>
<div class="rm-knowledge-tools"><label><Icon name="search" /><input v-model="query" type="search" placeholder="搜索笔记…" aria-label="搜索笔记" /></label><select v-model="tag" aria-label="筛选标签"><option value="">全部标签</option><option v-for="[name, count] in tags" :key="name" :value="name">#{{ name }} · {{ count }}</option></select><label v-if="ws.view === 'graph'"><input v-model="local" type="checkbox" :disabled="!ws.active" />仅当前笔记关联</label></div>
<p v-if="metadata.error" role="alert">{{ metadata.error }} <button class="rm-btn" @click="refreshMetadata">重试</button></p>
<p v-else-if="!metadata.ready" class="rm-tree-empty">关系索引准备中…</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)">
<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">没有符合条件的笔记</p>
<div class="rm-graph-controls"><span>{{ graph.nodes.length }} 节点 · {{ graph.edges.length }} 关系</span><button class="rm-icon-btn" title="放大" @click="zoom(.8)">+</button><button class="rm-icon-btn" title="缩小" @click="zoom(1.25)">−</button><button class="rm-icon-btn" title="重置视图" @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 }} 行</button></details>
</template>
<template v-else>
<div class="rm-tags-cloud"><button :class="{ on: !tag }" @click="tag = ''">全部</button><button v-for="[name, count] in tags" :key="name" :class="{ on: tag === name }" @click="tag = name">#{{ name }} <small>{{ count }}</small></button></div>
<div class="rm-tag-results"><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">没有符合条件的笔记</p></div>
</template>
</section>
</template>
<style scoped>
.rm-knowledge-panel { flex: 1; overflow: auto; padding: var(--rm-space-6); }
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--rm-space-4); } .rm-eyebrow { font-size: var(--rm-ui-font-caption); letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-text); font-size: var(--rm-ui-font-title); font-weight: 600; margin: 12px 0; } p { font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: var(--rm-space-6) 0 var(--rm-space-4); } label { display: flex; align-items: center; gap: 8px; font-size: var(--rm-ui-font-size); } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: var(--rm-ui-font-size); }
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: var(--rm-ui-font-caption); } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
.rm-missing-links { margin-top: 20px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: var(--rm-ui-font-size); cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: var(--rm-space-3); border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: var(--rm-ui-font-size); font-weight: 500; } small { display: block; margin-top: 5px; font-size: var(--rm-ui-font-caption); color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: var(--rm-ui-font-caption); color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
@media(max-width:767px) { .rm-knowledge-panel { padding: var(--rm-space-4); } h1 { font-size: var(--rm-ui-font-heading); } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: var(--rm-ui-font-size); } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
@media(max-width:767px), (pointer:coarse) { input[type=search], select { font-size: max(16px, var(--rm-ui-font-size)); } }
</style>
-139
View File
@@ -1,139 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type BackupKind, type MaintenanceStatus } from './api'
import { flushAll, readyToReload, ws } from './workspace'
import { flushSettings, loadSettings, reloadTheme } from './settings'
import { refreshPlugins } from '../plugins/host'
import SettingRow from './SettingRow.vue'
const emit = defineEmits<{ changed: [] }>()
const busy = ref(false)
const progress = ref('')
const error = ref('')
const message = ref('')
const last = ref<MaintenanceStatus | null>(null)
const fullInput = ref<HTMLInputElement>()
const settingsInput = ref<HTMLInputElement>()
let mounted = true
onBeforeUnmount(() => { mounted = false })
onMounted(async () => {
try { const status = await api.maintenanceStatus(); if (mounted) last.value = status }
catch (cause) { if (mounted) error.value = '读取维护状态失败:' + String(cause) }
})
async function prepare() {
await Promise.all([flushAll(), flushSettings()])
if (!readyToReload()) throw new Error('仍有未保存内容或正在上传、操作文件,请完成后再试')
}
async function perform(label: string, action: () => Promise<void>) {
if (busy.value) return
busy.value = true; progress.value = label; error.value = ''; message.value = ''
try {
await prepare()
await action()
if (mounted) { emit('changed'); last.value = await api.maintenanceStatus() }
} catch (cause) { if (mounted) error.value = String(cause) }
finally { if (mounted) { busy.value = false; progress.value = '' } }
}
async function download(response: Response, fallback: string) {
const blob = await response.blob()
const header = response.headers.get('Content-Disposition') ?? ''
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1]
const plain = /filename="([^"]+)"/i.exec(header)?.[1]
const name = encoded ? decodeURIComponent(encoded) : plain ?? fallback
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 = '导出文件已生成'
}
async function exportPackage(kind: BackupKind) { await perform('正在导出…', async () => download(await api.exportBackup(kind), `rawmark-${kind}.zip`)) }
async function exportMarkdown() { await perform('正在导出笔记…', async () => download(await api.exportNotes(), 'rawmark-notes.zip')) }
async function exportSystemLogs() { await perform('正在导出日志…', async () => download(await api.exportLogs(), 'rawmark-system-logs.zip')) }
async function syncPreferences() { await loadSettings(); await reloadTheme(); await refreshPlugins() }
async function reset() {
if (!confirm('恢复全部 RawMark 偏好?快捷键恢复默认,回收站期限恢复为 30 天,插件停用。保留笔记、账号、已安装资源、插件存储和本机标签页/布局。操作前会备份设置。')) return
await perform('正在重置设置…', async () => { await api.resetSettings(); await syncPreferences(); message.value = '设置已重置,原设置已备份' })
}
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 之间')
const { id } = await api.startBackupUpload(kind, file.size)
let submitted = false
try {
let offset = 0
while (offset < file.size) {
if (!mounted) throw new Error('已取消上传')
const chunk = file.slice(offset, offset + 8 * 1024 ** 2)
const result = await api.uploadBackupChunk(id, offset, chunk)
if (result.offset !== offset + chunk.size) throw new Error('服务端返回的上传进度不一致')
offset = result.offset; progress.value = `正在上传… ${Math.round(offset / file.size * 100)}%`
}
progress.value = '正在校验备份…'
const summary = await api.validateBackup(id)
if (!mounted) throw new Error('已取消导入')
const description = kind === 'full'
? '将替换全部实例数据,恢复备份中的密码与 API 令牌,短暂维护后需要重新登录。'
: '将替换全部偏好和插件配置,合并字体、主题和插件,同 ID 资源以导入包为准。'
if (!confirm(`备份时间:${new Date(summary.createdAt).toLocaleString()}\n条目:${summary.entries},展开大小:${(summary.size / 1024 ** 2).toFixed(2)} MiB\n${description}\n操作前会备份当前内容。确认继续?`)) return
await prepare()
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
if (kind === 'settings') {
await api.importSettings(id);submitted = true
await syncPreferences();message.value = '设置与资源已导入,原设置已备份'
} else {
await api.restoreBackup(id);submitted = true
for (let attempt = 0; attempt < 30; attempt++) {
await new Promise(resolve => setTimeout(resolve, 2000))
try { if (!(await api.authState()).authenticated) { location.reload(); return } }
catch { /* 服务关闭和重新监听期间继续等待,最终超时会明确提示。 */ }
}
throw new Error('恢复请求已提交,服务仍在维护,请稍后重新打开页面核对结果')
}
} finally {
if (!submitted) {
try { await api.cancelBackupUpload(id) }
catch (cause) { if (mounted) error.value = '清理上传暂存失败:' + String(cause) }
}
}
})
}
async function index(operation: 'optimize' | 'rebuild') {
await perform(operation === 'optimize' ? '正在优化索引…' : '正在重建索引…', async () => {
await api.maintainIndex(operation);message.value = operation === 'optimize' ? '索引已优化' : '索引已重建'
})
}
async function clearTemp() {
await perform('正在清理临时文件…', async () => {
const result = await api.clearTemp()
message.value = `已清理 ${result.files} 个文件,释放 ${(result.bytes / 1024 ** 2).toFixed(2)} MiB`
if (result.failed.length) error.value = '部分清理失败:\n' + result.failed.join('\n')
})
}
function importNotes() { if (!busy.value) { ws.settings = null; ws.importing = '' } }
</script>
<template>
<div class="rm-maintenance" :aria-busy="busy">
<h3 class="rm-settings-group-title">备份与迁移</h3>
<SettingRow id="backup-full"><button class="rm-btn" :disabled="busy" @click="exportPackage('full')">导出</button><button class="rm-btn" :disabled="busy" @click="fullInput!.click()">恢复</button><input ref="fullInput" type="file" accept=".zip" hidden @change="restore('full', $event)" /></SettingRow>
<SettingRow id="backup-notes"><button class="rm-btn" :disabled="busy" @click="exportMarkdown">导出</button><button class="rm-btn" :disabled="busy" @click="importNotes">导入</button></SettingRow>
<SettingRow id="backup-settings"><button class="rm-btn" :disabled="busy" @click="exportPackage('settings')">导出</button><button class="rm-btn" :disabled="busy" @click="settingsInput!.click()">导入</button><input ref="settingsInput" type="file" accept=".zip" hidden @change="restore('settings', $event)" /></SettingRow>
<h3 class="rm-settings-group-title">维护工具</h3>
<SettingRow id="index-optimize"><button class="rm-btn" :disabled="busy" @click="index('optimize')">优化</button></SettingRow>
<SettingRow id="index-rebuild"><button class="rm-btn" :disabled="busy" @click="index('rebuild')">重建</button></SettingRow>
<SettingRow id="temp-clear"><button class="rm-btn" :disabled="busy" @click="clearTemp">清理</button></SettingRow>
<SettingRow id="settings-reset"><button class="rm-btn" :disabled="busy" @click="reset">重置</button></SettingRow>
<SettingRow id="logs-export"><button class="rm-btn" :disabled="busy" @click="exportSystemLogs">导出</button></SettingRow>
<p v-if="busy" role="status" aria-live="polite">{{ progress }}</p>
<p v-if="message" role="status">{{ message }}</p>
<p v-if="error" class="rm-auth-error" role="alert">{{ error }}</p>
<p v-if="last?.error" class="rm-auth-error" role="alert">上次维护失败:{{ last.error }}</p>
<p v-if="last?.backup" class="rm-share-note">操作前备份:backups/{{ last.backup }}</p>
</div>
</template>
<style scoped>
.rm-maintenance [role="alert"] { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
-68
View File
@@ -1,68 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { api, ApiError, type InstalledPlugin } from './api'
import { pluginState, refreshPlugins, retryPlugin } from '../plugins/host'
import { pluginsPaused, settingTabs } from '../plugins/registry'
import PluginMount from './PluginMount.vue'
const busy = ref(false)
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: '临时跳过' }
async function perform(operation: () => Promise<unknown>) {
busy.value = true
error.value = ''
try { await operation(); await refreshPlugins() }
catch (err) { error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' }
finally { busy.value = false }
}
async function install(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!window.confirm('插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')) return
await perform(() => api.installPlugin(file))
}
async function remove(plugin: InstalledPlugin) {
if (!window.confirm(`卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)) return
await perform(() => api.uninstallPlugin(plugin.id))
}
function toggle(plugin: InstalledPlugin) {
void perform(() => api.togglePlugin(plugin.id, !plugin.enabled))
}
</script>
<template>
<p v-if="pluginsPaused" class="rm-appearance-note">已临时跳过外部插件。可停用问题插件后,移除地址中的 disable-plugins 参数重新打开。</p>
<div class="rm-setting">
<span class="rm-setting-label">已安装插件</span>
<button class="rm-btn" :disabled="busy" @click="perform(() => refreshPlugins())">刷新列表</button>
<button class="rm-btn" :disabled="busy" @click="fileInput?.click()">安装 ZIP</button>
<input ref="fileInput" type="file" accept=".zip,application/zip" hidden @change="install" />
</div>
<p class="rm-appearance-note">插件拥有页面内完整权限,请只安装可信来源。安装后默认停用;启用状态在所有设备同步。</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">尚未安装插件。</p>
<div v-for="plugin in pluginState.installed" :key="plugin.id" class="rm-plugin-row">
<label><input type="checkbox" :checked="plugin.enabled" :disabled="busy" @change="toggle(plugin)" />
<span>{{ plugin.name }} <small>{{ plugin.version }} · {{ labels[pluginState.status[plugin.id]?.phase] ?? (plugin.enabled ? '等待加载' : '已停用') }}</small></span>
</label>
<button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" class="rm-btn" :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">重试</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="remove(plugin)">卸载</button>
<p v-if="pluginState.status[plugin.id]?.error" class="rm-auth-error">{{ pluginState.status[plugin.id].error }}</p>
</div>
<p class="rm-appearance-note">若插件导致界面无法操作,在应用地址末尾加 <code>?disable-plugins</code> 可跳过加载并打开此页。卸载保留插件数据。</p>
<template v-if="settingTabs.size">
<div class="rm-setting"><label for="rm-plugin-tab" class="rm-setting-label">插件设置</label>
<select id="rm-plugin-tab" v-model="selected"><option value="">选择插件设置页</option><option v-for="[id, tab] in settingTabs" :key="id" :value="id">{{ tab.title }}</option></select>
</div>
<PluginMount v-if="currentTab" :key="selected" :view="currentTab" />
</template>
</template>
-21
View File
@@ -1,21 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from './Icon.vue'
import { SETTING_ITEMS, type SettingId } from './settingsCatalog'
const props = defineProps<{ id: SettingId; control?: string; resettable?: boolean }>()
defineEmits<{ reset: [] }>()
const item = computed(() => SETTING_ITEMS[props.id])
</script>
<template>
<div :id="'rm-setting-' + id" class="rm-setting" tabindex="-1">
<div class="rm-setting-info">
<component :is="control ? 'label' : 'span'" :for="control" class="rm-setting-label">{{ item.title }}</component>
<p>{{ item.description }}</p>
</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')"><Icon name="restore" /></button>
</div>
</div>
</template>
-220
View File
@@ -1,220 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api'
import { runCommand } from './commands'
import Icon from './Icon.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import { UI_FONT_SIZE } from '../themes/typography'
import FontPicker from './FontPicker.vue'
import PluginsPanel from './PluginsPanel.vue'
import { installApp, installPrompt } from './pwa'
import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings'
import SettingRow from './SettingRow.vue'
import { SETTINGS_SECTIONS, searchSettings, type SettingSearchResult } from './settingsCatalog'
import ShortcutSettings from './ShortcutSettings.vue'
import TokensPanel from './TokensPanel.vue'
import TrashPanel from './TrashPanel.vue'
import StoragePanel from './StoragePanel.vue'
import { toast, ws, type SettingsSection } from './workspace'
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
if (!buildVersion) throw new Error('缺少应用构建版本信息')
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
// 手机上一次只显示分组列表或分组内容
const showList = ref(ws.settings === 'index')
const current = computed(() => SETTINGS_SECTIONS.find((s) => s.id === section.value)!)
const query = ref('')
const searching = computed(() => !!query.value.trim())
const results = computed(() => searchSettings(query.value))
const dialog = ref<HTMLElement>()
const content = ref<HTMLElement>()
const heading = ref<HTMLElement>()
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts', 'trash'].includes(section.value))
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
async function open(id: SettingsSection) {
query.value = ''
ws.settings = id
showList.value = false
await nextTick()
content.value!.scrollTop = 0
heading.value!.focus({ preventScroll: true })
}
async function locate(result: SettingSearchResult) {
await open(result.section)
if (!result.id) return
const target = document.getElementById('rm-setting-' + result.id)!
// 设置可能位于 CSS 或排版预览的折叠区内。
for (let parent = target.parentElement; parent && parent !== content.value; parent = parent.parentElement) {
if (parent instanceof HTMLDetailsElement) parent.open = true
}
if (target instanceof HTMLDetailsElement) target.open = true
await nextTick()
target.focus({ preventScroll: true })
target.scrollIntoView({ block: 'center' })
}
function back() {
query.value = ''
showList.value = true
void nextTick(() => dialog.value!.querySelector<HTMLInputElement>('.rm-settings-search input')!.focus())
}
function close() {
ws.settings = null
}
function onKey(e: KeyboardEvent) {
if (e.isComposing) return
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close() }
if (e.key !== 'Tab') return
const controls = [...dialog.value!.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], summary, [tabindex="0"]')].filter(el => el.getClientRects().length > 0 && !el.closest('[hidden]'))
const first = controls[0], last = controls.at(-1)
if (e.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); last?.focus()
} else if (!e.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) {
e.preventDefault(); first?.focus()
}
}
onMounted(() => dialog.value!.focus({ preventScroll: true }))
onBeforeUnmount(() => { if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }) })
async function change(patch: Partial<Settings>) {
try {
await updateSettings(patch)
} catch (err) {
if (err instanceof SettingsAppearanceError) { toast(err.message); return }
toast('保存设置失败:' + (err instanceof ApiError ? err.message : '网络错误,请检查连接'))
void loadSettings().catch(error => toast('重新读取设置失败:' + (error instanceof Error ? error.message : '网络错误')))
}
}
function changeFont(event: Event) {
const input = event.target as HTMLInputElement
const value = input.valueAsNumber
if (!Number.isFinite(value) || value <= 0) {
if (event.type === 'input') return
toast('字号必须为正数')
input.value = String(settings.fontSize)
return
}
if (value !== settings.fontSize) void change({ fontSize: value })
}
function changeUIFont(event: Event) {
const input = event.target as HTMLInputElement
const value = input.valueAsNumber
if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) {
toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`)
input.value = String(settings.uiFontSize)
return
}
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
}
// ---- 修改登录密码 ----
const pw = ref({ current: '', next: '', confirm: '' })
const pwError = ref('')
const pwBusy = ref(false)
async function changePassword() {
pwError.value = ''
if (pw.value.next.length < 8) return void (pwError.value = '新密码至少 8 位')
if (pw.value.next !== pw.value.confirm) return void (pwError.value = '两次输入的新密码不一致')
pwBusy.value = true
try {
await api.changePassword(pw.value.current, pw.value.next)
pw.value = { current: '', next: '', confirm: '' }
toast('登录密码已修改,其他设备需要重新登录')
} catch (err) {
pwError.value = err instanceof ApiError ? err.message : '网络错误,请检查连接'
} finally {
pwBusy.value = false
}
}
</script>
<template>
<div class="rm-dialog-backdrop rm-settings-backdrop" @click.self="close">
<div ref="dialog" class="rm-dialog rm-settings" :class="{ 'show-list': showList, searching }" role="dialog" aria-modal="true" aria-label="设置" tabindex="-1" @keydown="onKey">
<nav class="rm-settings-nav" aria-label="设置分类">
<div class="rm-dialog-head">
<h2>设置</h2>
<button class="rm-icon-btn rm-settings-close-list" title="关闭设置" @click="close"><Icon name="close" /></button>
</div>
<label class="rm-settings-search"><Icon name="search" /><input v-model="query" type="search" aria-label="搜索设置" placeholder="搜索设置" /><button v-if="query" class="rm-icon-btn" aria-label="清空设置搜索" @click="query = ''"><Icon name="close" /></button></label>
<div class="rm-settings-categories">
<button v-for="s in SETTINGS_SECTIONS" :key="s.id" class="rm-settings-tab" :class="{ on: s.id === section && !searching }" :aria-current="s.id === section && !searching ? 'page' : undefined" @click="open(s.id)"><Icon :name="s.icon" />{{ s.title }}</button>
</div>
<div class="rm-settings-nav-foot"><span>RawMark</span><small>{{ buildVersion }}</small></div>
</nav>
<section class="rm-settings-body">
<div class="rm-dialog-head">
<button class="rm-icon-btn rm-settings-back" title="返回设置分类" @click="back"><Icon name="chevron" class="flip" /></button>
<h2 ref="heading" tabindex="-1">{{ searching ? '搜索结果' : 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"><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} 项设置` : '没有匹配的设置,试试其他关键词。' }}</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" /></button>
</div>
<div v-show="!searching" class="rm-settings-page">
<PluginsPanel v-if="section === 'plugins'" />
<template v-else-if="section === 'appearance'">
<SettingRow id="ui-font" control="rm-ui-font" resettable @reset="change({ uiFont: DEFAULTS.uiFont })"><FontPicker id="rm-ui-font" setting="uiFont" /></SettingRow>
<SettingRow id="ui-font-size" control="rm-ui-font-size" resettable @reset="change({ uiFontSize: DEFAULTS.uiFontSize })">
<label class="rm-font-input"><input id="rm-ui-font-size" type="number" :min="UI_FONT_SIZE.min" :max="UI_FONT_SIZE.max" step="1" :value="settings.uiFontSize" @blur="changeUIFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label>
</SettingRow>
<SettingRow id="glass-opacity" control="rm-glass-opacity" resettable @reset="change({ glassOpacity: DEFAULTS.glassOpacity })"><input id="rm-glass-opacity" type="range" min="0.4" max="1" step="0.05" :value="settings.glassOpacity" @change="change({ glassOpacity: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-glass-opacity">{{ Math.round(settings.glassOpacity * 100) }}%</output></SettingRow>
<AppearanceSettings />
</template>
<template v-else-if="section === 'editor'">
<h3 class="rm-settings-group-title">正文与代码</h3>
<SettingRow id="text-font" control="rm-text-font" resettable @reset="change({ textFont: DEFAULTS.textFont })"><FontPicker id="rm-text-font" setting="textFont" /></SettingRow>
<SettingRow id="code-font" control="rm-code-font" resettable @reset="change({ codeFont: DEFAULTS.codeFont })"><FontPicker id="rm-code-font" setting="codeFont" /></SettingRow>
<SettingRow id="font-size" control="rm-font-size" resettable @reset="change({ fontSize: DEFAULTS.fontSize })"><label class="rm-font-input"><input id="rm-font-size" type="number" min="0" step="any" :value="settings.fontSize" @input="changeFont" @blur="changeFont" @keydown.enter="($event.target as HTMLInputElement).blur()" /> px</label></SettingRow>
<SettingRow id="content-width" resettable @reset="change({ contentWidth: DEFAULTS.contentWidth })"><div class="rm-segmented" role="radiogroup" aria-label="正文宽度"><button v-for="w in CONTENT_WIDTHS" :key="w.value" role="radio" :aria-checked="settings.contentWidth === w.value" :class="{ on: settings.contentWidth === w.value }" @click="change({ contentWidth: w.value })">{{ w.label }}</button></div></SettingRow>
<SettingRow id="line-height" control="rm-line-height" resettable @reset="change({ lineHeight: DEFAULTS.lineHeight })"><input id="rm-line-height" type="range" min="1.2" max="2.5" step="0.05" :value="settings.lineHeight" @change="change({ lineHeight: Number(($event.target as HTMLInputElement).value) })" /><output for="rm-line-height">{{ settings.lineHeight.toFixed(2) }}</output></SettingRow>
<details id="rm-setting-typography-preview" class="rm-settings-details" tabindex="-1"><summary>排版预览</summary><article class="rm-typography-preview"><h2>让文字自由呼吸</h2><p>用简洁的记录,连接每一个想法。Markdown 文件始终属于你。</p><p>真实内容,自然排版,专注当下的灵感。</p><code>const idea = 'Hello, RawMark'</code></article></details>
<h3 class="rm-settings-group-title">编辑行为</h3>
<SettingRow id="selection-toolbar" control="rm-selection-toolbar"><input id="rm-selection-toolbar" type="checkbox" :checked="settings.selectionToolbar" @change="change({ selectionToolbar: ($event.target as HTMLInputElement).checked })" /></SettingRow>
<SettingRow id="spellcheck" control="rm-spellcheck"><input id="rm-spellcheck" type="checkbox" :checked="settings.spellcheck" @change="change({ spellcheck: ($event.target as HTMLInputElement).checked })" /></SettingRow>
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"><select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"><option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option></select></SettingRow>
</template>
<template v-else-if="section === 'account'">
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
<h3>修改登录密码</h3>
<p class="rm-share-note">修改后其他设备需要重新登录。</p>
<label><span>当前密码</span><input v-model="pw.current" type="password" autocomplete="current-password" required /></label>
<label><span>新密码</span><input v-model="pw.next" type="password" autocomplete="new-password" required /></label>
<label><span>确认新密码</span><input v-model="pw.confirm" type="password" autocomplete="new-password" required /></label>
<p v-if="pwError" class="rm-auth-error" role="alert">{{ pwError }}</p>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="pwBusy">修改密码</button>
</form>
<section id="rm-setting-tokens" tabindex="-1"><h3 class="rm-settings-group-title">API 令牌</h3><TokensPanel /></section>
<SettingRow id="logout"><button class="rm-btn" @click="close(), runCommand('app.logout')"><Icon name="logout" />退出</button></SettingRow>
</template>
<ShortcutSettings v-else-if="section === 'shortcuts'" />
<TrashPanel v-else-if="section === 'trash'" />
<StoragePanel v-else-if="section === 'storage'" />
<template v-else-if="section === 'about'">
<p class="rm-share-note">RawMark · 纯 Markdown 笔记编辑器,文件即真源。</p>
<SettingRow id="version"><code>{{ buildVersion }}</code></SettingRow>
<SettingRow id="install"><button v-if="installPrompt" class="rm-btn" @click="installApp">安装 RawMark</button><span v-else class="rm-settings-unavailable">当前浏览器暂未提供安装入口</span></SettingRow>
<SettingRow id="openapi"><a class="rm-btn" href="/api/v1/openapi.json" target="_blank" rel="noopener">查看 OpenAPI 文档</a></SettingRow>
</template>
</div>
</div>
</section>
</div>
</div>
</template>
-82
View File
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands'
import { recordedKeys } from '../keys'
import { loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
import { toast } from './workspace'
import Icon from './Icon.vue'
const commands = computed(allCommands)
const query = ref('')
const filteredCommands = computed(() => {
const terms = query.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
return commands.value.filter(command => {
const text = [command.title, command.id, ...commandKeys(command).map(formatKeys)].join(' ').toLocaleLowerCase()
return terms.every(term => text.includes(term))
})
})
const recording = ref<string | null>(null)
const recorder = ref<HTMLInputElement>()
const error = ref('')
const busy = ref(false)
watch(query, () => { recording.value = null; error.value = '' })
async function save(shortcuts: Record<string, string[]>) {
busy.value = true
try { await updateSettings({ shortcuts }) }
catch (cause) {
if (cause instanceof SettingsAppearanceError) { toast(cause.message); return }
toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause)))
await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause)))
} finally { busy.value = false }
}
async function record(id: string) {
error.value = ''
recording.value = id
await nextTick()
recorder.value?.focus()
}
async function capture(event: KeyboardEvent, command: Command) {
if (event.key === 'Escape') { recording.value = null; return }
const key = recordedKeys(event)
if (!key) return
const conflict = shortcutConflict(command.id, [key])
if (conflict) { error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return }
const keys = [...new Set([...commandKeys(command), key])]
recording.value = null
await save({ ...settings.shortcuts, [command.id]: keys })
}
async function reset(command: Command) {
const conflict = shortcutConflict(command.id, command.keys ?? [])
if (conflict) { error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return }
const next = { ...settings.shortcuts }
delete next[command.id]
error.value = ''
await save(next)
}
</script>
<template>
<div class="rm-shortcut-settings">
<div class="rm-shortcut-toolbar"><input v-model="query" type="search" aria-label="搜索快捷键" placeholder="搜索命令或快捷键" /><button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button></div>
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。默认跟随 Typora;无对应项(含插件)留空。自定义设置在所有设备间同步。</p>
<p class="rm-share-note">浏览器可能保留 Ctrl/Cmd+N、W、数字键等组合,网页无法保证拦截;可自行重新绑定。</p>
<p class="rm-settings-result-count" role="status">{{ filteredCommands.length }} / {{ commands.length }} 个命令{{ filteredCommands.length ? '' : ',没有匹配的快捷键。' }}</p>
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
<div v-for="command in filteredCommands" :key="command.id" class="rm-shortcut-row">
<span class="rm-shortcut-title">{{ command.title }}<small>{{ command.id }}</small></span>
<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>
</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" />
<button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">添加</button>
<button class="rm-btn" :disabled="busy" @click="reset(command)">重置</button>
</div>
</div>
</div>
</template>
-90
View File
@@ -1,90 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, type StorageUsage } from './api'
import MaintenancePanel from './MaintenancePanel.vue'
import SettingRow from './SettingRow.vue'
const usage = ref<StorageUsage | null>(null)
const busy = ref(false)
const error = ref('')
let mounted = true
onBeforeUnmount(() => { mounted = false })
const names: Record<StorageUsage['categories'][number]['id'], string> = { vault: '笔记库', databases: '数据库', fonts: '字体', themes: '主题', plugins: '插件', other: '其他' }
const colors = ['var(--rm-accent)', '#56a89c', '#bd9364', '#a88ac2', '#d57e8d', '#8d99a8']
const segments = computed(() => {
let offset = 0
return (usage.value?.categories ?? []).map((category, index) => {
const percent = usage.value!.totalSize ? category.size / usage.value!.totalSize * 100 : 0
const segment = { ...category, percent, offset, color: colors[index] }
offset += percent
return segment
})
})
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]}`
}
const updated = computed(() => usage.value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(usage.value.calculatedAt) : '')
async function scan() {
if (busy.value) return
busy.value = true
error.value = ''
try {
const next = await api.storage()
if (mounted) usage.value = next
} catch (cause) {
if (mounted) error.value = '统计失败:' + String(cause)
} finally { if (mounted) busy.value = false }
}
onMounted(scan)
</script>
<template>
<div>
<h3 class="rm-settings-group-title">空间占用</h3>
<SettingRow id="storage"><button class="rm-btn" :disabled="busy" @click="scan">{{ busy ? '统计中…' : '刷新' }}</button></SettingRow>
<p class="rm-share-note">统计应用数据与内置可选字体,不包含默认 Maple 和程序本体;附件与回收站已包含在笔记库内。不跟随内部符号链接,不包含数据目录外的运维备份。</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="各类存储空间占比">
<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>
<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} 字节`">{{ 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></div>
<div class="rm-storage-sub"><dt>导入字体</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} 字节`">{{ 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} 字节`">{{ size(usage.trashSize) }} <small>{{ (usage.totalSize ? usage.trashSize / usage.totalSize * 100 : 0).toFixed(1) }}%</small></dd></div>
</template>
</template>
</dl>
</div>
<MaintenancePanel @changed="scan" />
</div>
</template>
<style scoped>
.rm-storage-summary { display: flex; align-items: center; gap: 20px; margin: 20px 0; }
.rm-storage-summary svg { width: 128px; height: 128px; flex-shrink: 0; }
.rm-storage-summary strong { display: block; font-size: var(--rm-ui-font-heading); margin: 6px 0; }
.rm-storage-summary small { display: block; color: var(--rm-text-muted); }
.rm-storage-list { margin: 0; }
.rm-storage-list > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; }
.rm-storage-list dt { display: flex; align-items: center; gap: 8px; }
.rm-storage-list i { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.rm-storage-list dd { margin: 0; text-align: right; }
.rm-storage-list small { display: inline-block; min-width: 52px; color: var(--rm-text-muted); }
.rm-storage-list .rm-storage-sub { padding-left: 18px; color: var(--rm-text-muted); }
@media (max-width: 480px) { .rm-storage-summary { gap: 12px; flex-wrap: wrap; } .rm-storage-summary svg { width: 100px; height: 100px; } }
</style>
-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type TrashItem } from './api'
import Icon from './Icon.vue'
import SettingRow from './SettingRow.vue'
import { isImage } from './paths'
import { DEFAULTS, loadSettings, settings, SettingsAppearanceError, updateSettings } from './settings'
import { restore, toast } from './workspace'
const items = ref<TrashItem[] | null>(null)
const error = ref('')
const failures = ref<{ id: string; error: string }[]>([])
const busy = ref(false)
const saving = ref(false)
async function load() {
try {
items.value = await api.trash()
error.value = ''
} catch (cause) {
error.value = '读取回收站失败:' + String(cause)
}
}
async function retention(event: Event) {
const input = event.target as HTMLInputElement
const days = input.valueAsNumber
if (!Number.isSafeInteger(days) || days < 0) {
input.value = String(settings.trashRetentionDays)
toast('请输入非负整数天数,0 表示永不自动删除')
return
}
if (days === settings.trashRetentionDays) return
saving.value = true
try { await updateSettings({ trashRetentionDays: days }) }
catch (cause) {
toast('保存自动删除期限失败:' + String(cause))
if (!(cause instanceof SettingsAppearanceError)) await loadSettings().catch(cause => toast('重新读取设置失败:' + String(cause)))
} finally { saving.value = false }
}
async function doRestore(item: TrashItem) {
if (busy.value) return
busy.value = true
try { await restore(item.id); await load() }
finally { busy.value = false }
}
async function purge(item?: TrashItem) {
if (busy.value) return
if (!confirm(item ? `彻底删除“${item.path}”?此操作无法撤销。` : '清空回收站全部条目?此操作无法撤销。')) return
busy.value = true
failures.value = []
try {
if (item) await api.purge(item.id)
else {
const result = await api.clearTrash()
failures.value = result.failed
toast(`已永久删除 ${result.deleted.length} 项${result.failed.length ? `,${result.failed.length} 项失败` : ''}`)
}
} catch (cause) {
toast('删除失败:' + String(cause))
} finally {
await load()
busy.value = false
}
}
const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
onMounted(load)
</script>
<template>
<div>
<SettingRow id="trash-retention" control="rm-trash-retention">
<label class="rm-font-input"><input id="rm-trash-retention" type="number" min="0" step="1" :placeholder="String(DEFAULTS.trashRetentionDays)" :value="settings.trashRetentionDays" :disabled="saving" @change="retention" /> 天</label>
</SettingRow>
<SettingRow id="trash-clear"><button class="rm-btn rm-btn-danger" :disabled="busy || items?.length === 0" @click="purge()">清空全部</button></SettingRow>
<p v-if="error" role="alert" class="rm-auth-error">{{ error }} <button class="rm-btn" :disabled="busy" @click="load">重试</button></p>
<ul v-if="failures.length" role="alert"><li v-for="failure in failures" :key="failure.id">{{ items?.find(item => item.id === failure.id)?.path || failure.id }}:{{ failure.error }}</li></ul>
<p v-if="items && !items.length" class="rm-tree-empty">回收站是空的</p>
<p v-else-if="items === null && !error" class="rm-tree-empty" role="status">读取中…</p>
<ul v-else class="rm-trash-list" :aria-busy="busy">
<li v-for="item in items ?? []" :key="item.id">
<Icon :name="item.dir ? 'folder' : isImage(item.path) ? 'image' : 'note'" />
<div class="rm-trash-info">
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
</div>
<button class="rm-btn" :disabled="busy" @click="doRestore(item)">恢复</button>
<button class="rm-btn rm-btn-danger" :disabled="busy" @click="purge(item)">彻底删除</button>
</li>
</ul>
</div>
</template>
-175
View File
@@ -1,175 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Gemoji } from 'gemoji'
import Icon from './Icon.vue'
import { iconPaths, type IconName } from './icons'
import type { TreeIcon } from './api'
import { emojiCategories, emojiLabel, iconLabels, matchesEmoji } from './treeIconCatalog'
import { basename } from './paths'
import { treeIconTarget } from './tree'
import { saveTreeIcon } from './workspace'
const props = defineProps<{ target: { path: string; anchor: HTMLElement } }>()
const panel = ref<HTMLElement>()
const search = ref<HTMLInputElement>()
const position = ref({ left: 8, top: 8 })
const tab = ref<'emoji' | 'builtin'>('emoji')
const category = ref('Objects')
const query = ref('')
const emojis = ref<Gemoji[]>([])
const loading = ref(true)
const loadError = ref('')
const error = ref('')
const busy = ref(false)
const recent = ref<TreeIcon[]>([])
let resizeObserver: ResizeObserver | undefined
const RECENT_KEY = 'rawmark.recent-tree-icons'
const iconNames = Object.keys(iconPaths) as IconName[]
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return emojis.value.filter(e => q ? matchesEmoji(e, q) : e.category === category.value)
})
const linear = computed(() => {
const q = query.value.trim().toLowerCase()
return iconNames.filter(name => (name + ' ' + iconLabels[name]).toLowerCase().includes(q))
})
const visibleRecent = computed(() => recent.value.filter(icon => icon.kind === tab.value))
const chosen = (icon: TreeIcon) => `${icon.kind}:${icon.value}`
function close() {
if (busy.value) return
treeIconTarget.value = null
props.target.anchor.focus({ preventScroll: true })
}
function place() {
if (!panel.value) return
const anchor = props.target.anchor.getBoundingClientRect()
const { width, height } = panel.value.getBoundingClientRect()
position.value = {
left: Math.max(8, Math.min(anchor.left, innerWidth - width - 8)),
top: Math.max(8, Math.min(anchor.bottom + 4, innerHeight - height - 8)),
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() }
if (event.key !== 'Tab' || !panel.value) return
const buttons = [...panel.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')]
const index = buttons.indexOf(document.activeElement as HTMLElement)
if (event.shiftKey && index <= 0) { event.preventDefault(); buttons.at(-1)?.focus() }
else if (!event.shiftKey && index === buttons.length - 1) { event.preventDefault(); buttons[0]?.focus() }
}
async function loadEmojis() {
loading.value = true
loadError.value = ''
try { emojis.value = (await import('gemoji')).gemoji }
catch (err) { loadError.value = '表情加载失败:' + (err instanceof Error ? err.message : String(err)) }
finally { loading.value = false }
}
async function save(icon: TreeIcon | null) {
if (busy.value) return
busy.value = true
error.value = ''
try {
await saveTreeIcon(props.target.path, icon)
if (icon) {
recent.value = [icon, ...recent.value.filter(item => chosen(item) !== chosen(icon))].slice(0, 24)
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value)) }
catch (err) { console.warn('保存最近图标失败', err) }
}
busy.value = false
close()
} catch (err) {
error.value = '图标保存失败:' + (err instanceof Error ? err.message : String(err))
} finally { busy.value = false }
}
onMounted(() => {
try {
const data: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
if (Array.isArray(data)) recent.value = data.filter((item): item is TreeIcon => item && typeof item === 'object' && typeof item.value === 'string' && (item.kind === 'emoji' || item.kind === 'builtin' && iconNames.includes(item.value))).slice(0, 24)
} catch (err) { console.warn('读取最近图标失败', err) }
place()
search.value?.focus({ preventScroll: true })
resizeObserver = new ResizeObserver(place)
resizeObserver.observe(panel.value!)
window.addEventListener('resize', place)
document.addEventListener('keydown', onKeydown, true)
void loadEmojis()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', place)
document.removeEventListener('keydown', onKeydown, true)
})
</script>
<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' }">
<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"><Icon name="close" /></button></header>
<div class="rm-tree-icon-tabs" role="tablist" aria-label="图标类型">
<button role="tab" :aria-selected="tab === 'emoji'" :disabled="busy" @click="tab = 'emoji'">表情</button>
<button role="tab" :aria-selected="tab === 'builtin'" :disabled="busy" @click="tab = 'builtin'">线性图标</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="表情分类">
<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>
<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)">
<Icon v-if="icon.kind === 'builtin'" :name="icon.value" :size="20" /><template v-else>{{ icon.value }}</template>
</button>
</div>
</template>
<template v-if="tab === 'emoji'">
<p v-if="loading">正在加载表情…</p>
<div v-else-if="loadError" role="alert">{{ loadError }} <button class="rm-btn" @click="loadEmojis">重试</button></div>
<template v-else>
<p>{{ query ? '搜索结果' : 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">没有找到图标,试试其他关键词</p>
</template>
</template>
<template v-else>
<p>线性图标</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">没有找到图标,试试其他关键词</p>
</template>
</div>
<p v-if="error" class="rm-tree-icon-error" role="alert">{{ error }}</p>
<footer><button class="rm-btn" :disabled="busy" @click="save(null)">{{ busy ? '正在保存…' : '恢复默认图标' }}</button></footer>
</section>
</Teleport>
</template>
<style scoped>
.rm-tree-icon-backdrop { z-index: 46; }
.rm-tree-icon-picker { position: fixed; z-index: 47; box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rm-space-2); width: 360px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); padding: var(--rm-space-3); border: 1px solid var(--rm-divider); border-radius: 14px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); backdrop-filter: blur(24px); box-shadow: var(--rm-shadow); font-size: var(--rm-ui-font-size); }
header { flex: none; display: flex; align-items: center; gap: var(--rm-space-2); }
.rm-tree-icon-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tree-icon-tabs { display: flex; border-bottom: 1px solid var(--rm-divider); }
.rm-tree-icon-tabs button { flex: 1; min-height: var(--rm-control-size); border: 0; border-bottom: 2px solid transparent; background: none; color: var(--rm-text-muted); cursor: pointer; }
.rm-tree-icon-tabs [aria-selected="true"] { color: var(--rm-text); border-bottom-color: var(--rm-accent); }
.rm-tree-icon-categories { display: flex; flex-wrap: wrap; gap: 2px; }
.rm-tree-icon-categories button { flex: 1; min-width: 28px; min-height: var(--rm-control-size); border: 0; border-radius: 4px; background: none; font-size: 18px; cursor: pointer; }
.rm-tree-icon-results { flex: 1 1 auto; min-height: 0; max-height: 300px; overflow: auto; overscroll-behavior: contain; }
.rm-tree-icon-results p { margin: var(--rm-space-2) 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-tree-icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 3px; }
.rm-tree-icon-grid button { display: flex; align-items: center; justify-content: center; min-width: 0; height: 36px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--rm-text); font-size: 22px; cursor: pointer; }
.rm-tree-icon-grid button:hover, .rm-tree-icon-categories button:hover, .rm-tree-icon-categories [aria-pressed="true"] { background: var(--rm-hover); }
.rm-tree-icon-error { margin: 0; max-height: 80px; overflow: auto; color: var(--rm-danger); overflow-wrap: anywhere; }
footer { flex: none; display: flex; justify-content: flex-end; }
@media (pointer: coarse) { .rm-tree-icon-grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); } .rm-tree-icon-grid button, .rm-tree-icon-categories button, .rm-tree-icon-tabs button { min-height: 44px; } }
</style>
-149
View File
@@ -1,149 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api, ApiError } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import KnowledgePanel from './KnowledgePanel.vue'
import EditorChrome from './EditorChrome.vue'
import Icon from './Icon.vue'
import SelectionToolbar from './SelectionToolbar.vue'
import MoveDialog from './MoveDialog.vue'
import ImportDialog from './ImportDialog.vue'
import ImageGallery from './ImageGallery.vue'
import ImageDialog from './ImageDialog.vue'
import NoteBanner from './NoteBanner.vue'
import OutlinePanel from './OutlinePanel.vue'
import QuickSwitcher from './QuickSwitcher.vue'
import SettingsDialog from './SettingsDialog.vue'
import { noteTitle } from './paths'
import { SIDEBAR_WIDTH, settings, SettingsAppearanceError, updateSettings } from './settings'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
import { flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
const resizingSidebar = ref(false)
// 自定义字体需登录后读取;初始化期间保留排版,但不先显示默认字体。
const initializing = ref(true)
// 拖动侧栏右缘调整宽度:拖动中只改 CSS 变量,松手后保存到设置
function startResize(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
resizingSidebar.value = true
const startX = e.clientX
const startWidth = settings.sidebarWidth
let width = startWidth
const onMove = (ev: PointerEvent) => {
width = Math.round(Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min, startWidth + ev.clientX - startX)))
document.documentElement.style.setProperty('--rm-sidebar-width', width + 'px')
}
const onUp = () => {
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
handle.removeEventListener('lostpointercapture', onUp)
resizingSidebar.value = false
if (width !== startWidth) updateSettings({ sidebarWidth: width }).catch(error => toast(error instanceof SettingsAppearanceError ? error.message : '保存侧栏宽度失败'))
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
handle.addEventListener('lostpointercapture', onUp)
}
async function logout() {
await flushAll()
if (!readyToReload()) {
toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
return
}
try {
await api.logout()
} catch {
toast('退出失败')
return
}
stopWorkspace()
emit('logout')
}
// 移动端:从屏幕左边缘右滑打开侧栏
let touchX: number | null = null
// 侧栏打开时向左滑关闭(以水平方向为主,避免与列表上下滚动冲突)
let touchStart: { x: number; y: number } | null = null
function onTouchStart(e: TouchEvent) {
const t = e.touches[0]
touchX = t.clientX < 24 ? t.clientX : null
touchStart = { x: t.clientX, y: t.clientY }
}
function onTouchMove(e: TouchEvent) {
const t = e.touches[0]
if (touchX !== null && t.clientX - touchX > 60) {
ws.sidebarOpen = true
touchX = null
}
if (ws.sidebarOpen && touchStart) {
const dx = t.clientX - touchStart.x
if (dx < -60 && Math.abs(t.clientY - touchStart.y) < Math.abs(dx) / 2) {
ws.sidebarOpen = false
touchStart = null
}
}
}
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
onMounted(async () => {
try { await startWorkspace() }
catch (err) { toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误')) }
finally { initializing.value = false }
})
onBeforeUnmount(() => {
unregisterLogout()
stopWorkspace()
})
</script>
<template>
<div
class="rm-shell"
:style="initializing ? { opacity: 0, pointerEvents: 'none' } : undefined"
:inert="initializing"
:class="{ 'sidebar-open': ws.sidebarOpen, 'sidebar-collapsed': ws.sidebarCollapsed, 'sidebar-resizing': resizingSidebar, 'focus-mode': ws.focusMode }"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
>
<div class="rm-workspace">
<aside class="rm-sidebar">
<Sidebar @logout="logout" />
<div class="rm-sidebar-resize" title="拖动调整宽度" @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"><Icon name="menu" /></button>
<button class="rm-mobile-title" title="切换笔记" @click="ws.switcher = ''">{{ title }}</button>
<button class="rm-icon-btn" title="快速打开" @click="ws.switcher = ''"><Icon name="search" /></button>
<button class="rm-icon-btn" title="命令" @click="ws.switcher = '>'"><Icon name="command" /></button>
</header>
<div v-show="ws.view === 'notes' && ws.images === null" class="rm-notes-view">
<TabBar />
<EditorChrome />
<NoteBanner />
<EditorPane />
<SelectionToolbar />
</div>
<KnowledgePanel v-if="ws.view !== 'notes' && ws.images === null" />
<ImageGallery v-if="ws.images !== null" :dir="ws.images" />
</main>
<Transition name="rm-outline-panel">
<OutlinePanel v-if="ws.outlineOpen && !ws.focusMode && ws.active && ws.view === 'notes' && ws.images === null" />
</Transition>
</div>
<QuickSwitcher v-if="ws.switcher !== null" />
<SettingsDialog v-if="ws.settings" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
</div>
</template>
-257
View File
@@ -1,257 +0,0 @@
import type { IconName } from './icons'
// REST API 客户端,与服务端 internal/api 对应
export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName }
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
icon?: TreeIcon
}
export interface ImportResult {
paths: string[]
notes: number
assets: number
warnings: string[]
}
export interface NoteMetadata {
path: string
rev: string
title: string
tags: string[]
links: { target: string; line: number }[]
assets: { target: string; line: number }[]
warnings: string[]
}
export interface Theme {
id: string
name: string
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
export interface InstalledPlugin {
id: string
name: string
version: string
apiVersion: 1
revision: string
enabled: boolean
}
export interface TrashItem {
id: string
path: string
dir: boolean
deletedAt: number
}
export interface StorageUsage {
totalSize: number
categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[]
builtinFontsSize: number
customFontsSize: number
assetsSize: number
trashSize: number
calculatedAt: number
}
export interface MaintenanceStatus { busy: boolean; operation: string; error: string; finishedAt: number; backup?: string }
export interface BackupSummary { kind: 'full' | 'settings'; createdAt: number; entries: number; size: number }
export type BackupKind = 'full' | 'settings'
export interface TrashPurgeResult {
deleted: string[]
failed: { id: string; error: string }[]
}
export interface SearchResult {
path: string
// 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0
line: number
snippet: string
}
export interface ApiToken {
id: string
name: string
scope: 'read' | 'write'
createdAt: number
// 0 为从未使用
lastUsedAt: number
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly data: Record<string, unknown> = {},
) {
super(message)
}
}
// 401 时通知应用回到登录页
let onUnauthorized: () => void = () => {}
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn
}
export function encodePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}
async function request(
method: string,
url: string,
init: { json?: unknown; body?: BodyInit; headers?: Record<string, string>; keepalive?: boolean } = {},
) {
const headers: Record<string, string> = { ...init.headers }
let body = init.body
if (init.json !== undefined) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(init.json)
}
const res = await fetch(url, { method, headers, body, credentials: 'same-origin', cache: 'no-store', keepalive: init.keepalive })
if (!res.ok) {
let data: Record<string, unknown> = {}
try {
data = await res.json()
} catch {
// 非 JSON 错误体(如反向代理返回的页面)
}
if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized()
throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data)
}
return res
}
const json = async <T>(p: Promise<Response>): Promise<T> => (await p).json()
export const api = {
authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')),
setup: (password: string) => request('POST', '/api/v1/auth/setup', { json: { password } }),
login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }),
logout: () => request('POST', '/api/v1/auth/logout'),
changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }),
maintenanceStatus: () => json<MaintenanceStatus>(request('GET', '/api/v1/maintenance/status')),
exportBackup: (kind: BackupKind) => request('POST', '/api/v1/maintenance/export/' + kind),
exportNotes: () => request('POST', '/api/v1/maintenance/notes/export'),
startBackupUpload: (kind: BackupKind, size: number) => json<{ id: string }>(request('POST', '/api/v1/maintenance/uploads', { json: { kind, size } })),
uploadBackupChunk: (id: string, offset: number, body: Blob) => json<{ offset: number }>(request('PUT', '/api/v1/maintenance/uploads/' + id, { body, headers: { 'Upload-Offset': String(offset) } })),
validateBackup: (id: string) => json<BackupSummary>(request('POST', '/api/v1/maintenance/uploads/' + id + '/validate')),
cancelBackupUpload: (id: string) => request('DELETE', '/api/v1/maintenance/uploads/' + id),
importSettings: (id: string) => request('POST', '/api/v1/maintenance/settings/import', { json: { id, confirmed: true } }),
restoreBackup: (id: string) => request('POST', '/api/v1/maintenance/restore', { json: { id, confirmed: true } }),
resetSettings: () => request('POST', '/api/v1/maintenance/settings/reset', { json: { confirmed: true } }),
maintainIndex: (operation: 'optimize' | 'rebuild') => request('POST', '/api/v1/maintenance/index/' + operation),
clearTemp: () => json<{ files: number; bytes: number; failed: string[] }>(request('POST', '/api/v1/maintenance/temp/clear')),
exportLogs: () => request('POST', '/api/v1/maintenance/logs/export'),
settings: () => json<Record<string, unknown>>(request('GET', '/api/v1/settings')),
fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return json<CustomFont>(request('POST', '/api/v1/fonts', { body }))
},
deleteFont: (id: string) => request('DELETE', '/api/v1/fonts/' + encodeURIComponent(id)),
themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes,
theme: (id: string) => json<Theme & { css: string }>(request('GET', '/api/v1/themes/' + encodeURIComponent(id))),
plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins,
installPlugin: (file: File) => {
const body = new FormData()
body.append('file', file)
return json<InstalledPlugin>(request('POST', '/api/v1/plugins', { body }))
},
togglePlugin: (id: string, enabled: boolean) => json<InstalledPlugin>(request('PATCH', '/api/v1/plugins/' + encodeURIComponent(id), { json: { enabled } })),
uninstallPlugin: (id: string) => request('DELETE', '/api/v1/plugins/' + encodeURIComponent(id)),
pluginData: (id: string) => json<{ data: unknown }>(request('GET', '/api/v1/plugins/' + encodeURIComponent(id) + '/data')),
savePluginData: (id: string, data: unknown) => request('PUT', '/api/v1/plugins/' + encodeURIComponent(id) + '/data', { json: { data } }),
// 按顶层键合并,值为 null 的键被删除
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
importNotes: (dir: string, folder: string, files: File[]) => {
const body = new FormData()
body.append('dir', dir)
body.append('folder', folder)
for (const file of files) body.append('files', file)
return json<ImportResult>(request('POST', '/api/v1/import', { body }))
},
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }),
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
async readFile(path: string): Promise<{ content: string; rev: string }> {
const res = await request('GET', '/api/v1/files/' + encodePath(path))
return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
// ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖
// keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求)
async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'text/markdown; charset=utf-8' }
if (ifMatch === '') headers['If-None-Match'] = '*'
else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"`
const res = request('PUT', '/api/v1/files/' + encodePath(path), {
body: content,
headers,
keepalive: keepalive && new Blob([content]).size < 60_000,
})
return (await json<{ rev: string }>(res)).rev
},
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
async upload(note: string, file: File): Promise<string> {
const form = new FormData()
form.append('file', file, file.name || 'image')
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
},
cleanupImages: (paths: string[]) => json<{ deleted: TrashItem[]; skipped: { path: string; reason: string }[] }>(request('POST', '/api/v1/assets/cleanup', { json: { paths } })),
uploadFolderImage: async (folder: string, file: File) => {
const body = new FormData()
body.append('file', file)
return json<{ link: string }>(request('POST', '/api/v1/assets?folder=' + encodeURIComponent(folder), { body }))
},
replaceImage: (path: string, file: File) => {
const body = new FormData()
body.append('file', file)
return request('PUT', '/api/v1/assets/' + encodePath(path), { body })
},
importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens,
// 返回的 token 明文只有这一次
createToken: (name: string, scope: ApiToken['scope']) =>
json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })),
deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)),
storage: () => json<StorageUsage>(request('GET', '/api/v1/storage')),
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
}
-727
View File
@@ -1,727 +0,0 @@
/* 应用外壳样式,颜色与字体沿用 themes/base.css 中的 --rm-* 变量 */
html,
body {
height: 100%;
}
/* 软键盘弹出时为可视区域高度,见 viewport.ts */
#app {
height: var(--rm-app-height, 100%);
display: flex;
flex-direction: column;
}
.rm-app-status { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 12px; padding-top: max(6px, env(safe-area-inset-top)); background: var(--rm-warn-bg); color: var(--rm-warn-text); font-size: var(--rm-ui-font-small); flex: none; }
#app > .rm-shell, #app > .rm-splash, #app > .rm-auth { flex: 1; min-height: 0; height: auto; }
@media (pointer: coarse) { .rm-app-status .rm-btn { min-height: var(--rm-control-size); } }
body {
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
input, select, textarea { font-size: var(--rm-ui-font-size); }
input:not([type=checkbox],[type=radio],[type=range],[type=file]), select { box-sizing: border-box; min-height: var(--rm-control-size); }
button {
font: inherit;
color: inherit;
}
/* ---- 通用控件 ---- */
.rm-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
font-size: var(--rm-ui-font-size);
cursor: pointer;
white-space: nowrap;
}
.rm-btn:hover { border-color: var(--rm-accent); color: var(--rm-accent-strong); }
.rm-btn:disabled { opacity: 0.6; cursor: default; }
.rm-btn-primary { background: var(--rm-accent); border-color: var(--rm-accent); color: #fff; }
.rm-btn-primary:hover { background: var(--rm-accent-strong); color: #fff; }
.rm-btn-danger:hover { border-color: var(--rm-danger); color: var(--rm-danger); }
.rm-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
flex: none;
}
.rm-icon-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
.rm-icon-btn[aria-pressed="true"] { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-splash,
.rm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
color: var(--rm-text-muted);
}
/* ---- 登录 ---- */
.rm-auth { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 24px; box-sizing: border-box; }
.rm-auth-card { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 14px; }
.rm-auth-card h1 { margin: 0 0 8px; font-size: var(--rm-ui-font-title); color: var(--rm-accent-strong); }
.rm-auth-hint { margin: 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
.rm-auth-card input {
height: var(--rm-control-size);
padding: 0 12px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-auth-card input:focus { border-color: var(--rm-accent); box-shadow: 0 0 0 3px var(--rm-selection); }
.rm-auth-card .rm-btn { height: var(--rm-control-size); justify-content: center; font-size: var(--rm-ui-font-size); }
.rm-auth-error { margin: 0; color: var(--rm-danger); font-size: var(--rm-ui-font-small); }
/* ---- 布局 ---- */
.rm-shell { display: flex; flex-direction: column; height: 100%; }
.rm-workspace { display: flex; flex: 1; min-height: 0; }
.rm-sidebar {
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
position: relative;
box-sizing: border-box;
width: var(--rm-sidebar-width);
flex: none;
overflow: clip;
background: color-mix(in srgb, var(--rm-sidebar-bg) 78%, transparent);
backdrop-filter: blur(18px) saturate(150%);
border-right: 1px solid var(--rm-divider);
transition: width var(--rm-panel-duration) var(--rm-panel-easing), border-width var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s;
}
.rm-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-sidebar-resize { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 5; touch-action: none; }
.rm-sidebar-resize:hover,
.rm-sidebar-resize:active { background: var(--rm-accent); opacity: 0.4; }
.sidebar-collapsed .rm-sidebar,
.focus-mode .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); }
.sidebar-resizing .rm-sidebar { transition: none; }
/* ---- 大纲 ---- */
.rm-outline-backdrop { display: flex; flex: none; width: 256px; overflow: clip; }
.rm-outline { box-sizing: border-box; flex: none; width: 256px; display: flex; flex-direction: column; border-left: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 60%, transparent); color: var(--rm-text-muted); }
.rm-outline-panel-enter-active,
.rm-outline-panel-leave-active { transition: width var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-outline-panel-enter-from,
.rm-outline-panel-leave-to { width: 0; opacity: 0; }
.rm-outline-panel-leave-active { pointer-events: none; }
.rm-outline-head { display: flex; align-items: center; justify-content: space-between; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-1) var(--rm-space-3); font-size: var(--rm-ui-font-size); font-weight: 500; color: var(--rm-text-muted); }
.rm-outline-list { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: var(--rm-space-1) var(--rm-space-2); }
.rm-outline-list li { box-sizing: border-box; height: var(--rm-control-compact); padding: 0 var(--rm-space-2); border-radius: 6px; font-size: var(--rm-ui-font-size); line-height: var(--rm-control-compact); cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-outline-list li:hover { background: var(--rm-hover); }
.rm-outline-list li.on { color: var(--rm-text); background: var(--rm-active); font-weight: 500; }
.rm-backdrop,
.rm-mobile-bar { display: none; }
/* ---- 侧栏 ---- */
.rm-sidebar-inner { box-sizing: border-box; display: flex; flex-direction: column; width: calc(var(--rm-sidebar-width) - 1px); height: 100%; overflow-y: auto; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing), transform var(--rm-panel-duration) var(--rm-panel-easing); }
.sidebar-collapsed .rm-sidebar-inner,
.focus-mode .rm-sidebar-inner { opacity: 0; transform: translateX(-12px); }
.rm-brand { flex: 1; font-weight: 600; font-size: var(--rm-ui-font-size); color: var(--rm-accent-strong); }
.rm-tree { flex: 1; min-height: 100px; overflow: auto; padding: 0 6px 12px; }
.rm-tree.drop { background: var(--rm-active); }
.rm-tree-list { list-style: none; margin: 0; padding: 0; }
.rm-tree-row {
--rm-tree-row-overlay: transparent;
position: relative;
display: flex;
align-items: center;
gap: 4px;
height: var(--rm-control-size);
padding-right: 4px;
border-radius: 6px;
font-size: inherit;
cursor: pointer;
user-select: none;
}
.rm-tree-row:hover { --rm-tree-row-overlay: var(--rm-hover); background: var(--rm-hover); }
/* 长按由应用处理,不弹出 iOS 的系统菜单 */
.rm-tree-row { -webkit-touch-callout: none; }
.rm-tree-row.active { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); color: var(--rm-text); font-weight: 500; }
.rm-tree-row.drop { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-tree-chevron { display: inline-flex; flex: none; width: var(--rm-ui-font-size); color: var(--rm-text-muted); transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-tree-chevron.open { transform: rotate(90deg); }
.rm-tree-chevron.hidden { visibility: hidden; }
.rm-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tree-input {
flex: 1;
min-width: 0;
height: var(--rm-control-compact);
padding: 0 6px;
border: 1px solid var(--rm-accent);
border-radius: 4px;
background: var(--rm-bg);
color: var(--rm-text);
font: inherit;
outline: none;
}
.rm-tree-more { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); z-index: 1; width: var(--rm-control-compact); height: var(--rm-control-compact); opacity: 0; pointer-events: none; }
/* 按钮覆盖名称末尾,不为隐藏按钮留位;不透明底色遮住下方文字。 */
.rm-tree-row .rm-tree-more { background: linear-gradient(var(--rm-tree-row-overlay), var(--rm-tree-row-overlay)), var(--rm-sidebar-bg); }
.rm-tree-row:hover .rm-tree-more,
.rm-tree-row:focus-within .rm-tree-more,
.rm-tree-row.menu-open .rm-tree-more { opacity: 1; pointer-events: auto; }
@media (pointer: coarse), (hover: none) {
.rm-tree-more { position: static; transform: none; opacity: 1; pointer-events: auto; width: max(44px, var(--rm-control-compact)); height: max(44px, var(--rm-control-size)); }
.rm-tree-row .rm-tree-more { background: none; }
}
.rm-tree-empty { padding: 16px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); text-align: center; }
/* ---- 搜索 ---- */
.rm-search { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.rm-search-box {
display: flex;
align-items: center;
gap: 6px;
margin: 0 10px 8px;
padding: 0 4px 0 8px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: var(--rm-text-muted);
}
.rm-search-box:focus-within { border-color: var(--rm-accent); }
.rm-search-input {
flex: 1;
min-width: 0;
height: var(--rm-control-size);
border: 0;
background: none;
color: var(--rm-text);
font: inherit;
outline: none;
}
.rm-search-input::-webkit-search-cancel-button { display: none; }
.rm-search-results { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 12px; }
.rm-search-item { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rm-search-item.active { background: var(--rm-hover); }
.rm-search-item.current .rm-search-title { color: var(--rm-accent-strong); }
.rm-search-title { display: flex; align-items: baseline; gap: 6px; font-size: inherit; overflow: hidden; white-space: nowrap; }
.rm-search-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-search-snippet {
margin-top: 2px;
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
/* ---- 快速切换 ---- */
.rm-switcher-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: rgba(0, 0, 0, 0.18); }
.rm-switcher {
display: flex;
flex-direction: column;
width: min(560px, calc(100vw - 24px));
max-height: 60vh;
padding-top: 10px;
border-radius: 10px;
background: var(--rm-bg);
box-shadow: var(--rm-shadow);
overflow: hidden;
}
.rm-switcher-list { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 6px; }
.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: var(--rm-control-size); padding: 0 10px; border-radius: 6px; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); cursor: pointer; }
.rm-switcher-item.active { background: var(--rm-active); }
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-switcher-dir { flex: 1; min-width: 0; font-size: var(--rm-ui-font-small); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
.rm-switcher-keys span { padding: 1px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; font-family: inherit; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: var(--rm-control-size);
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-size);
cursor: pointer;
}
.rm-foot-btn:hover { background: var(--rm-hover); color: var(--rm-text); }
/* ---- 菜单 ---- */
.rm-menu {
position: absolute;
top: 100%;
right: 4px;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 150px;
padding: 4px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
box-shadow: var(--rm-shadow);
cursor: default;
}
.rm-menu button {
display: flex;
align-items: center;
gap: 8px;
height: var(--rm-control-size);
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
font-size: var(--rm-ui-font-size);
text-align: left;
cursor: pointer;
}
.rm-menu button:hover { background: var(--rm-hover); }
.rm-menu button.danger:hover { color: var(--rm-danger); background: rgba(229, 72, 77, 0.1); }
.rm-menu-backdrop { position: fixed; inset: 0; z-index: 19; cursor: default; }
/* ---- 标签页 ---- */
.rm-tabs {
display: flex;
gap: 2px;
height: 38px;
padding: 4px 8px 0;
overflow-x: auto;
border-bottom: 1px solid var(--rm-divider);
scrollbar-width: none;
flex: none;
}
.rm-tabs.empty { display: none; }
.rm-tabs-side,
.rm-tabs-actions { flex: none; align-self: center; margin-bottom: 4px; }
.rm-tabs-actions { display: flex; margin-left: auto; position: sticky; right: 0; background: var(--rm-bg); }
.rm-tabs-outline.on { color: var(--rm-accent-strong); }
.rm-tab.dragging { opacity: 0.5; }
.rm-tab.drop-before { box-shadow: inset 2px 0 0 var(--rm-accent); }
.rm-tab.drop-after { box-shadow: inset -2px 0 0 var(--rm-accent); }
.rm-tab-menu { position: fixed; z-index: 20; min-width: 160px; }
.rm-tab {
display: flex;
align-items: center;
gap: 4px;
max-width: 200px;
height: var(--rm-control-size);
padding: 0 4px 0 12px;
border-radius: 8px 8px 0 0;
font-size: var(--rm-ui-font-size);
color: var(--rm-text-muted);
cursor: pointer;
flex: none;
}
.rm-tab:hover { background: var(--rm-hover); }
.rm-tab.active { background: var(--rm-active); color: var(--rm-text); }
.rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tab-pin { display: inline-flex; flex: none; color: var(--rm-accent-strong); }
.rm-tab-close {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
}
.rm-tab-close:hover { background: var(--rm-hover); }
.rm-tab-close svg { opacity: 0; }
.rm-tab:hover .rm-tab-close svg,
.rm-tab.active .rm-tab-close svg { opacity: 1; }
.rm-dirty-dot { display: none; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--rm-accent); }
/* 有未保存修改时显示圆点,悬停时换回关闭图标 */
.rm-tab-close.dirty .rm-dirty-dot { display: block; }
.rm-tab-close.dirty svg { opacity: 0 !important; }
.rm-tab-close.dirty:hover .rm-dirty-dot { display: none; }
.rm-tab-close.dirty:hover svg { opacity: 1 !important; }
/* ---- 提示条 ---- */
.rm-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: var(--rm-ui-font-small);
flex: none;
}
.rm-banner span { flex: 1; min-width: 200px; }
.rm-banner.warn,
.rm-banner.error { background: var(--rm-warn-bg); color: var(--rm-warn-text); }
/* ---- 编辑区 ---- */
.rm-editor-pane { flex: 1; min-height: 0; position: relative; }
.rm-editor-host { height: 100%; }
.rm-editor-host .cm-editor { height: 100%; }
/* ---- 对话框 ---- */
.rm-dialog-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); padding: 16px; }
.rm-dialog {
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
border-radius: 12px;
background: var(--rm-bg);
box-shadow: var(--rm-shadow);
}
.rm-dialog-head { display: flex; align-items: center; padding: var(--rm-space-3) var(--rm-space-4) var(--rm-space-2); }
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: var(--rm-ui-font-size); }
.rm-share-form { display: flex; gap: 8px; padding: 4px 20px 0; }
.rm-share-form input,
.rm-share-form select {
height: var(--rm-control-size);
padding: 0 10px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: var(--rm-text);
font-size: var(--rm-ui-font-size);
outline: none;
}
.rm-share-form input { flex: 1; min-width: 0; }
.rm-share-form input:focus,
.rm-share-form select:focus { border-color: var(--rm-accent); }
.rm-share-note { margin: 10px 20px 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); line-height: 1.6; }
.rm-share-note a { color: var(--rm-accent-strong); }
.rm-token-created { margin: 12px 20px 0; padding: 10px 12px; border-radius: 8px; background: var(--rm-selection); font-size: var(--rm-ui-font-small); }
.rm-token-created p { margin: 0 0 6px; }
.rm-token-value { display: flex; align-items: center; gap: 8px; }
.rm-token-value code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--rm-font-mono); font-size: var(--rm-ui-font-small); user-select: all; }
/* ---- 设置 ---- */
.rm-setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: var(--rm-control-size); padding: 6px 20px; }
.rm-setting-label { font-size: var(--rm-ui-font-size); }
.rm-setting select { min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; line-height: 1.4; }
.rm-segmented { flex: none; display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--rm-divider); border-radius: 6px; overflow: hidden; }
.rm-segmented button { flex: 1 0 auto; white-space: nowrap; min-height: var(--rm-control-size); padding: 4px 10px; border: 0; border-left: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; cursor: pointer; }
.rm-segmented button:first-child { border-left: 0; }
.rm-segmented button.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.flip { transform: rotate(180deg); }
.rm-dialog-backdrop.rm-settings-backdrop { padding: 24px; background: color-mix(in srgb, var(--rm-bg) 30%, transparent); backdrop-filter: blur(4px); }
.rm-dialog.rm-settings { box-sizing: border-box; flex-direction: row; width: 100%; max-width: 960px; height: min(720px, 100%); max-height: 100%; overflow: hidden; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) calc(var(--rm-glass-opacity) * 100%), transparent); backdrop-filter: blur(20px); outline: none; }
.rm-settings-nav { box-sizing: border-box; flex: none; width: 192px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-sidebar-bg) 70%, transparent); }
.rm-settings .rm-dialog-head { flex: none; box-sizing: border-box; min-height: 52px; gap: 8px; padding: 8px 16px; }
.rm-settings .rm-dialog-head h2 { font-size: var(--rm-ui-font-size); font-weight: 600; min-width: 0; outline: none; }
.rm-settings-close-list, .rm-settings-back { display: none; }
.rm-settings-search { display: flex; align-items: center; gap: 4px; min-width: 0; min-height: var(--rm-control-size); margin: 0 8px 8px; padding: 0 6px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); }
.rm-settings-search > .rm-icon { flex: none; width: 16px; height: 16px; color: var(--rm-text-muted); }
.rm-settings-search input { box-sizing: border-box; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 4px 0; border: 0; outline: 0; background: none; color: var(--rm-text); font: inherit; }
.rm-settings-search input::-webkit-search-cancel-button { display: none; }
.rm-settings-search:focus-within { border-color: var(--rm-accent); }
.rm-settings-categories { min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.rm-settings-tab { box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; min-height: var(--rm-control-size); margin: 2px 0; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--rm-text-muted); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-tab .rm-icon { flex: none; }
.rm-settings-tab:hover { background: var(--rm-hover); }
.rm-settings-tab.on { background: var(--rm-active); color: var(--rm-accent-strong); }
.rm-settings-nav-foot { margin-top: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-nav-foot small { font-size: inherit; overflow-wrap: anywhere; }
.rm-settings-body { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.rm-settings-body > .rm-dialog-head { border-bottom: 1px solid var(--rm-divider); }
.rm-settings-save { flex: none; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-save.error { color: var(--rm-danger); }
.rm-settings-content { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; padding: 0 24px 16px; }
.rm-settings-page { min-width: 0; }
.rm-settings .rm-setting { flex-wrap: wrap; gap: 12px 16px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); font-size: var(--rm-ui-font-size); }
.rm-setting-info { flex: 1 1 180px; min-width: 0; }
.rm-setting-info p { margin: 4px 0 0; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); line-height: var(--rm-ui-line-height); overflow-wrap: anywhere; }
.rm-setting-control { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-settings .rm-setting input:not([type=checkbox],[type=range]), .rm-settings .rm-setting select { box-sizing: border-box; max-width: 260px; min-width: 0; min-height: var(--rm-control-size); padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-settings .rm-setting-control > input:not([type=checkbox],[type=range]) { width: 240px; max-width: 100%; }
.rm-settings .rm-setting select { max-width: min(260px, 100%); }
.rm-settings .rm-setting input[type=range] { width: 120px; min-width: 0; max-width: 100%; margin: 0; accent-color: var(--rm-accent); }
.rm-settings input[type=checkbox] { accent-color: var(--rm-accent); }
.rm-setting-control output { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.rm-setting-control code { overflow-wrap: anywhere; }
.rm-setting-reset { flex: none; color: var(--rm-text-muted); }
.rm-settings-group-title { margin: 20px 0 0; font-size: var(--rm-ui-font-size); font-weight: 600; color: var(--rm-text-muted); }
.rm-settings-group-title:first-child { margin-top: 16px; }
.rm-settings-details { margin: 12px 0; }
.rm-settings-details > summary { padding: 8px 0; min-height: var(--rm-control-size); box-sizing: border-box; cursor: pointer; font-size: var(--rm-ui-font-size); color: var(--rm-text-muted); }
.rm-settings :is(.rm-setting, .rm-settings-details, .rm-settings-form):focus { outline: 2px solid var(--rm-accent); outline-offset: -2px; border-radius: 6px; }
.rm-typography-preview { margin: 8px 0 0; padding: 16px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-sidebar-bg); font: var(--rm-font-size)/var(--rm-line-height) var(--rm-font-editor, var(--rm-font-text)); overflow-wrap: anywhere; }
.rm-typography-preview h2 { margin-top: 0; font-family: var(--rm-font-heading); font-size: 1.5em; }
.rm-typography-preview p { margin: 0; }
.rm-typography-preview p + p { margin-top: 1lh; }
.rm-typography-preview code { font-family: var(--rm-font-mono); font-size: .9em; color: var(--rm-inline-code-text); }
.rm-settings .rm-share-note, .rm-settings .rm-appearance-note { margin: 12px 0; overflow-wrap: anywhere; }
.rm-settings .rm-share-form { flex-wrap: wrap; padding: 16px 0 0; }
.rm-settings .rm-share-form input { flex-basis: 180px; }
.rm-settings .rm-trash-list { padding: 0; }
.rm-settings .rm-trash-list li { flex-wrap: wrap; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); border-radius: 0; }
.rm-settings .rm-trash-info { flex-basis: 180px; }
.rm-settings .rm-token-created { margin: 12px 0; }
.rm-settings :is(.rm-snippet-row, .rm-plugin-row) { padding: 12px 0; flex-wrap: wrap; }
.rm-settings .rm-snippet-form { padding: 12px 0; }
.rm-settings .rm-plugin-view { padding: 12px 0; }
.rm-settings .rm-settings-form { box-sizing: border-box; max-width: 400px; padding: 16px 0; border: 0; border-radius: 0; background: none; box-shadow: none; }
.rm-settings-form h3 { margin: 0; font-size: var(--rm-ui-font-size); }
.rm-settings-form .rm-share-note { margin: 0; }
.rm-settings-unavailable { max-width: 240px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result-count { margin: 12px 0; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); }
.rm-settings-result { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid var(--rm-divider); background: none; color: var(--rm-text); font: inherit; text-align: left; cursor: pointer; }
.rm-settings-result:hover { background: var(--rm-hover); }
.rm-settings-result > span { flex: 1; min-width: 0; }
.rm-settings-result strong { font-weight: 500; }
.rm-settings-result small { display: block; margin-top: 4px; font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-settings-result > .rm-icon { flex: none; color: var(--rm-text-muted); }
@media (max-width: 767px) {
.rm-dialog-backdrop.rm-settings-backdrop { padding: 0; }
.rm-dialog.rm-settings { height: 100%; max-width: none; border: 0; border-radius: 0; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--rm-bg); }
.rm-settings-nav { display: none; width: 100%; border-right: 0; }
.rm-settings.show-list .rm-settings-nav { display: flex; }
.rm-settings.show-list .rm-settings-body { display: none; }
.rm-settings.show-list.searching { flex-direction: column; }
.rm-settings.show-list.searching .rm-settings-body { display: flex; }
.rm-settings.show-list.searching :is(.rm-settings-categories, .rm-settings-nav-foot, .rm-settings-body > .rm-dialog-head) { display: none; }
.rm-settings.show-list:not(.searching) .rm-settings-nav { flex: 1; }
.rm-settings .rm-dialog-head { padding: 8px 12px; }
.rm-settings-close-list, .rm-settings-back { display: inline-flex; }
.rm-settings-content { padding: 0 16px 16px; }
.rm-settings-search { min-height: 44px; }
.rm-settings-tab { min-height: 44px; }
.rm-settings-save { max-width: 6em; }
.rm-settings .rm-setting-info { flex-basis: 100%; }
.rm-settings .rm-setting-control { width: 100%; max-width: 100%; }
.rm-setting-control > select { flex: 1 1 0; }
.rm-settings input:not([type=checkbox],[type=radio],[type=range]), .rm-settings select, .rm-settings textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
.rm-trash-list li:hover { background: var(--rm-hover); }
.rm-trash-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rm-trash-path { color: var(--rm-text); font-size: var(--rm-ui-font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-trash-time { font-size: var(--rm-ui-font-small); }
/* ---- 通知 ---- */
.rm-toasts { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; }
.rm-toast {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 8px;
background: #1f2328;
color: #fff;
font-size: var(--rm-ui-font-small);
box-shadow: var(--rm-shadow);
pointer-events: auto;
}
.rm-toast-action { border: 0; background: none; color: #42d392; font-weight: 600; cursor: pointer; padding: 0; }
/* ---- 移动端 ---- */
@media (max-width: 767px) {
.rm-sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 40;
width: min(84vw, 320px);
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition: transform var(--rm-panel-duration) var(--rm-panel-easing), visibility 0s var(--rm-panel-duration);
padding-top: env(safe-area-inset-top);
}
.sidebar-collapsed .rm-sidebar,
.focus-mode .rm-sidebar { width: min(84vw, 320px); border-right-width: 1px; transition-delay: 0s, var(--rm-panel-duration); }
.rm-sidebar-inner { width: 100%; }
.sidebar-collapsed .rm-sidebar-inner { opacity: 1; transform: none; }
.sidebar-open:not(.focus-mode) .rm-sidebar { transform: none; visibility: visible; pointer-events: auto; transition-delay: 0s; box-shadow: var(--rm-shadow); }
.rm-backdrop { position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, 0.3); opacity: 0; pointer-events: none; transition: opacity var(--rm-panel-duration) var(--rm-panel-easing); display: block; }
.sidebar-open:not(.focus-mode) .rm-backdrop { opacity: 1; pointer-events: auto; }
.rm-tabs { display: none; }
.rm-mobile-bar {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
padding: env(safe-area-inset-top) 8px 0;
border-bottom: 1px solid var(--rm-divider);
flex: none;
}
.rm-mobile-bar .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-size); }
.rm-mobile-title { flex: 1; min-width: 0; height: var(--rm-control-size); padding: 0 4px; border: 0; background: none; color: var(--rm-text); font: inherit; font-weight: 600; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-sidebar-resize { display: none; }
/* 大纲在手机上为从右侧滑入的浮层 */
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(0, 0, 0, 0.3); }
.rm-outline { position: absolute; top: 0; right: 0; bottom: 0; width: min(80vw, 300px); padding-top: env(safe-area-inset-top); box-shadow: var(--rm-shadow); }
/* 触控热区不小于 44px */
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
.rm-search-input { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-auth-card input,
.rm-share-form input,
.rm-share-form select,
.rm-setting select { font-size: var(--rm-ui-font-size); }
.rm-share-form { flex-wrap: wrap; }
.rm-search-item { padding: 10px 8px; }
/* 手机上从顶部弹出,给软键盘留出空间 */
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
.rm-switcher { max-height: 50vh; }
.rm-switcher-item { height: var(--rm-control-size); font-size: var(--rm-ui-font-size); }
.rm-trash-list li { flex-wrap: wrap; }
}
/* 主题与 CSS 编辑 */
.rm-appearance-note { margin: var(--rm-space-2) var(--rm-space-4) var(--rm-space-4); font-size: var(--rm-ui-font-small); color: var(--rm-text-muted); overflow-wrap: anywhere; }
.rm-snippet-row { display: flex; align-items: center; gap: 8px; padding: 6px 20px; }
.rm-snippet-row label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--rm-ui-font-size); }
.rm-snippet-row input { accent-color: var(--rm-accent); flex: none; }
.rm-snippet-form { display: flex; flex-direction: column; gap: 12px; padding: 12px 20px; }
.rm-snippet-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--rm-ui-font-size); }
.rm-snippet-form input, .rm-snippet-form textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
.rm-snippet-form textarea { font-family: var(--rm-font-mono); resize: vertical; }
@media (pointer: coarse) { .rm-snippet-row .rm-btn, .rm-snippet-form .rm-btn { min-height: var(--rm-control-size); } .rm-snippet-form input, .rm-snippet-form textarea { font-size: var(--rm-ui-font-size); } }
.rm-plugin-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--rm-divider); }
.rm-plugin-row label { display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px; overflow-wrap: anywhere; }
.rm-plugin-row small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-plugin-row p { width: 100%; font-size: var(--rm-ui-font-small); }
.rm-plugin-row input { accent-color: var(--rm-accent); flex: none; }
.rm-plugin-view { padding: 16px; min-width: 0; overflow: auto; overflow-wrap: anywhere; }
.rm-sidebar-inner > .rm-plugin-view { flex: 1; }
.rm-plugin-tabs { display: flex; flex: none; gap: 4px; padding: 8px; overflow-x: auto; }
.rm-plugin-tabs .on { background: var(--rm-active); color: var(--rm-accent-strong); }
@media (pointer: coarse) { .rm-plugin-row .rm-btn, .rm-plugin-tabs .rm-btn { min-height: var(--rm-control-size); } }
.rm-font-input { display: inline-flex; align-items: center; gap: 6px; }
.rm-font-input input { min-height: var(--rm-control-size); box-sizing: border-box; font: inherit; width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
.rm-shortcut-settings { padding: 16px 0 0; }
.rm-shortcut-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rm-shortcut-toolbar input { box-sizing: border-box; flex: 1 1 180px; width: 100%; min-width: 0; min-height: var(--rm-control-size); padding: 6px 8px; border: 1px solid var(--rm-divider); border-radius: 6px; color: var(--rm-text); background: var(--rm-bg); }
.rm-shortcut-title { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
.rm-shortcut-title small { display: block; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); margin-top: 4px; }
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: var(--rm-ui-font-size); }
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; max-width: 100%; min-width: 0; margin-left: auto; }
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
.rm-context-menu { position: fixed; right: auto; z-index: 45; width: max-content; min-width: 0; max-width: calc(100vw - 8px); max-height: calc(100dvh - 8px); box-sizing: border-box; overflow: auto; }
.rm-context-menu button { flex: none; white-space: nowrap; }
.rm-context-menu-backdrop { z-index: 44; }
.rm-tree-row.selected { --rm-tree-row-overlay: var(--rm-active); background: var(--rm-active); }
/* 轻玻璃工作台 */
.rm-vault-heading { display: flex; align-items: center; padding: 2px 4px; }
.rm-tree-label { margin-left: auto; flex: none; min-height: var(--rm-control-compact); padding: var(--rm-space-1) var(--rm-space-3); border: 0; background: none; font-size: var(--rm-ui-font-small); font-weight: 500; color: var(--rm-text-muted); text-align: right; cursor: pointer; }
.rm-tree-label:hover { background: var(--rm-hover); }
.rm-tree { padding: 0 var(--rm-space-2) var(--rm-space-2); }
.rm-tree-row { height: var(--rm-control-compact); font-size: inherit; gap: var(--rm-space-1); }
.rm-sidebar-foot { border: 0; padding: var(--rm-space-1) var(--rm-space-2) var(--rm-space-2); }
.rm-foot-btn { height: var(--rm-control-size); font-size: inherit; }
.rm-tabs { height: auto; min-height: 31px; padding: 2px 6px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
.rm-tab { box-sizing: border-box; min-width: 84px; height: max(31px, calc(var(--rm-ui-font-size) + 15px)); max-width: 196px; padding: 0 3px 0 8px; font-size: var(--rm-ui-font-size); font-weight: 500; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
.rm-tab-add { margin: 0 3px; align-self: center; }
.rm-tab-close { width: max(24px, calc(var(--rm-ui-font-size) + 10px)); height: max(24px, calc(var(--rm-ui-font-size) + 10px)); }
@media (pointer: coarse) {
.rm-tab { height: max(48px, calc(var(--rm-ui-font-size) + 26px)); }
.rm-tab-close { width: var(--rm-control-compact); height: var(--rm-control-compact); }
}
.rm-editor-chrome { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 2px; padding: 2px 6px; border-bottom: 1px solid var(--rm-divider); }
.rm-editor-chrome .rm-icon-btn { width: var(--rm-control-compact); height: var(--rm-control-compact); }
.rm-btn { border-radius: 8px; background: color-mix(in srgb, var(--rm-bg) 80%, transparent); }
.rm-btn-primary { background: var(--rm-accent); box-shadow: 0 2px 5px var(--rm-selection); }
.rm-auth-card { box-sizing: border-box; gap: 18px; padding: 40px; border: 1px solid var(--rm-divider); border-radius: 24px; background: color-mix(in srgb, var(--rm-bg) 75%, transparent); box-shadow: var(--rm-shadow); }
.rm-dialog-backdrop { background: rgba(19,27,46,.15); backdrop-filter: blur(8px); }
.rm-dialog { border-radius: 20px; border: 1px solid var(--rm-divider); background: color-mix(in srgb, var(--rm-bg) 96%, transparent); }
.rm-menu { background: color-mix(in srgb, var(--rm-bg) 92%, transparent); backdrop-filter: blur(24px); border-radius: 14px; padding: 7px; }
.rm-menu button { min-height: var(--rm-control-size); }
@media (min-width: 768px) and (max-width: 1199px) {
.rm-outline-backdrop { display: block; position: fixed; inset: 0; width: auto; z-index: 41; background: rgba(19,27,46,.12); }
.rm-outline { position: absolute; right: 0; top: 0; bottom: 0; box-shadow: var(--rm-shadow); }
}
@media (max-width: 767px) {
.rm-tree-row { height: var(--rm-control-size); font-size: inherit; }
.rm-outline { width: min(84vw, 320px); }
.rm-auth-card { padding: 28px 24px; }
}
.rm-notes-view { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; }
/* 选区工具栏与局部外观面板 */
.rm-selection-popover { position: fixed; display: flex; flex-direction: column; box-sizing: border-box; max-width: calc(100vw - 16px); padding: 4px; border: 1px solid var(--rm-divider); border-radius: 10px; background: color-mix(in srgb, var(--rm-bg) 96%, transparent); color: var(--rm-text); font: var(--rm-ui-font-size)/var(--rm-ui-line-height) var(--rm-font-text); backdrop-filter: blur(18px); box-shadow: var(--rm-shadow); z-index: 20; }
.rm-selection-toolbar { display: flex; flex: none; align-items: center; max-width: 100%; overflow-x: auto; }
.rm-selection-toolbar > button { display: inline-flex; align-items: center; justify-content: center; width: var(--rm-control-size); height: var(--rm-control-size); flex: none; padding: 0; border: 0; border-radius: 6px; background: none; color: inherit; cursor: pointer; font: inherit; touch-action: manipulation; }
.rm-selection-toolbar > button:hover, .rm-selection-toolbar > button[aria-pressed=true], .rm-selection-toolbar > button[aria-expanded=true] { background: var(--rm-selection); color: var(--rm-accent-strong); }
.rm-selection-toolbar .bold { font-weight: 700; } .rm-selection-toolbar .italic { font-style: italic; } .rm-selection-toolbar .strike { text-decoration: line-through; } .rm-selection-toolbar .underline { text-decoration: underline; } .rm-selection-toolbar .highlight { text-decoration: underline; text-decoration-color: var(--rm-accent); text-decoration-thickness: 3px; }
.rm-inline-appearance { box-sizing: border-box; width: 328px; max-width: 100%; min-height: 0; padding: 4px 8px 8px; overflow-y: auto; overscroll-behavior: contain; border-top: 1px solid var(--rm-divider); }
.rm-inline-appearance fieldset { min-width: 0; margin: 8px 0; padding: 0 0 10px; border: 0; border-bottom: 1px solid var(--rm-divider); }
.rm-inline-appearance legend { padding: 0 0 6px; color: var(--rm-text-muted); font-size: var(--rm-ui-font-small); }
.rm-inline-swatches { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
.rm-inline-swatches button, .rm-inline-effects button { box-sizing: border-box; min-height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 5px; background: var(--rm-bg); color: var(--rm-text); font: inherit; cursor: pointer; }
.rm-inline-swatches button:hover, .rm-inline-effects button:hover, .rm-inline-appearance button[aria-pressed=true] { outline: 2px solid var(--rm-accent); outline-offset: -2px; }
.rm-inline-custom { grid-column: span 3; display: flex; align-items: center; justify-content: space-between; gap: 4px; font-size: var(--rm-ui-font-small); }
.rm-inline-custom input { box-sizing: border-box; width: 32px; height: 28px; padding: 2px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); cursor: pointer; }
.rm-inline-effects { display: flex; gap: 8px; } .rm-inline-effects button { width: 36px; font-size: 20px; }
.rm-inline-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0; font-size: var(--rm-ui-font-small); }
.rm-inline-control select { width: min(190px, 100%); min-width: 0; }
.rm-inline-control select, .rm-inline-size input[type=number] { box-sizing: border-box; min-height: var(--rm-control-compact); padding: 2px 4px; border: 1px solid var(--rm-divider); border-radius: 4px; background: var(--rm-bg); color: inherit; font: inherit; }
.rm-inline-size { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .rm-inline-size input[type=range] { min-width: 0; width: 100%; flex: 1; accent-color: var(--rm-accent); } .rm-inline-size input[type=number] { width: 64px; } .rm-inline-size > span { min-width: 18px; }
.rm-inline-clear { width: 100%; }
@media (pointer: coarse) {
.rm-selection-toolbar > button { width: 44px; height: 44px; }
.rm-inline-swatches { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.rm-inline-effects button, .rm-inline-custom input { min-width: 44px; }
.rm-inline-swatches button, .rm-inline-effects button, .rm-inline-appearance select, .rm-inline-appearance input, .rm-inline-appearance .rm-btn { min-height: 44px; }
.rm-inline-appearance select, .rm-inline-size input[type=number] { font-size: max(16px, var(--rm-ui-font-size)); }
}
.rm-shell { position: relative; }
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { width: 0; border-right-width: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0s, 0s, var(--rm-panel-duration); } }
@media (max-width: 1199px) {
.rm-outline-panel-enter-active,
.rm-outline-panel-leave-active { transition-property: opacity; }
.rm-outline { transition: transform var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-outline-panel-enter-from .rm-outline,
.rm-outline-panel-leave-to .rm-outline { transform: translateX(100%); }
.rm-outline-panel-enter-from,
.rm-outline-panel-leave-to { width: auto; }
}
@media (prefers-reduced-motion: reduce) {
:root { --rm-panel-duration: 0ms; }
}
@media (max-width: 767px), (pointer: coarse) {
input:not([type=checkbox],[type=radio],[type=range]), select, textarea { font-size: max(16px, var(--rm-ui-font-size)); }
}
/* 文件树子层按实际内容高度展开;退出期间不能继续点击或聚焦。 */
.rm-tree-children { display: grid; grid-template-rows: 1fr; }
.rm-tree-children > .rm-tree-list { min-height: 0; overflow: hidden; }
.rm-tree-expand-enter-active, .rm-tree-expand-leave-active { transition: grid-template-rows var(--rm-panel-duration) var(--rm-panel-easing), opacity var(--rm-panel-duration) var(--rm-panel-easing); }
.rm-tree-expand-enter-from, .rm-tree-expand-leave-to { grid-template-rows: 0fr; opacity: 0; }
.rm-tree-expand-leave-active { pointer-events: none; }
.rm-emoji { font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; font-weight: normal; font-style: normal; }
.rm-tree-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
.rm-tree-icon:hover { background: var(--rm-hover); }
@media (pointer: coarse) { .rm-tree-icon { width: 28px; height: 44px; } }
-41
View File
@@ -1,41 +0,0 @@
import { api } from './api'
import { basename, joinPath } from './paths'
import { minimalPaths } from './selection'
import { flush, move, refreshTree, remove, toast, ws } from './workspace'
export async function batchFiles(action: 'move' | 'delete', paths: string[], destination = '') {
const subjects = minimalPaths(paths)
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
const failures: string[] = []
let succeeded = 0
for (const path of subjects) {
try {
const result = action === 'move' ? await move(path, joinPath(destination, basename(path))) : await remove(path)
if (result.ok) succeeded++
else failures.push(`${path}:${result.error}`)
} catch (cause) { failures.push(`${path}:${cause instanceof Error ? cause.message : String(cause)}`) }
}
await refreshTree().catch((cause) => toast('刷新目录失败:' + String(cause)))
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? ';' + failures.join(';') : ''}`)
}
export async function exportFiles(paths: string[]) {
const subjects = minimalPaths(paths)
const tabs = ws.tabs.filter((tab) => subjects.some((path) => tab.path === path || tab.path.startsWith(path + '/')))
try {
await Promise.all(tabs.map((tab) => flush(tab.path)))
if (tabs.some((tab) => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
const response = await api.exportFiles(subjects)
const blob = await response.blob()
const disposition = 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 url = URL.createObjectURL(blob)
const link = Object.assign(document.createElement('a'), { href: url, download: filename })
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (cause) { toast('导出失败:' + (cause instanceof Error ? cause.message : String(cause))) }
}
-46
View File
@@ -1,46 +0,0 @@
// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步
export interface VaultEvent {
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
path?: string
from?: string
rev?: string
}
export function connectEvents(handlers: { onEvents: (evs: VaultEvent[]) => void; onResync: () => void }): () => void {
let ws: WebSocket | undefined
let delay = 1000
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
const connect = () => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/api/v1/events`)
ws.onopen = () => {
delay = 1000
// 初始化读取到首次订阅之间也存在空窗,每次建立连接都补同步。
handlers.onResync()
}
ws.onmessage = (e) => handlers.onEvents(JSON.parse(e.data))
ws.onclose = () => {
if (stopped) return
timer = setTimeout(connect, delay)
delay = Math.min(delay * 2, 30000)
}
}
connect()
// 页面从后台恢复时立即重连,不等退避计时
const onVisible = () => {
if (document.visibilityState === 'visible' && ws?.readyState === WebSocket.CLOSED) {
clearTimeout(timer)
connect()
}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
stopped = true
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisible)
ws?.close()
}
}
-21
View File
@@ -1,21 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
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('字体列表格式无效')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
const fonts = parseFontCatalog([{ id: 'custom-' + 'a'.repeat(64), name: "bad');}body{color:red}", format: 'woff2' }])
applyFontCatalog(fonts)
const style = document.querySelector('#rm-custom-fonts')!
expect(style.textContent).not.toContain('body{color:red}')
const text = style.firstChild
applyFontCatalog(fonts)
expect(style.firstChild).toBe(text)
applyFontCatalog([])
})
})
-48
View File
@@ -1,48 +0,0 @@
import type { Gemoji } from 'gemoji'
import type { IconName } from './icons'
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: '信息',
}
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: '🏳️' },
]
// 常见目录用途的中文检索词;其余仍可按 Emoji 和原有英文名称搜索。
const keywords: Record<string, string> = {
'📂': '文件夹 目录 打开', '📁': '文件夹 目录', '📄': '文档 笔记 文章', '📝': '笔记 编辑 记录',
'📃': '文档 文章', '📋': '清单 剪贴板', '📚': '书籍 知识 学习', '📖': '阅读 书籍', '📕': '书籍 红书',
'📗': '书籍 绿书', '📘': '书籍 蓝书', '📙': '书籍 橙书', '📓': '笔记本', '📒': '账本 笔记本',
'📔': '笔记本 日记', '📅': '日历 日期', '🗓️': '日历 计划', '✅': '完成 任务 对勾', '☑️': '勾选 任务',
'⭐': '星星 收藏', '🌟': '星星 重要', '❤️': '爱心 喜欢', '🔥': '火 热门', '💡': '灯泡 灵感 想法',
'📌': '固定 图钉', '🔗': '链接', '🔒': '锁 安全', '🔑': '钥匙 密钥', '⚙️': '设置 齿轮',
'🛠️': '工具 开发', '💻': '电脑 编程 代码', '🖥️': '电脑 显示器', '📱': '手机', '🖼️': '图片 相册',
'🎨': '艺术 设计 调色板', '🎵': '音乐', '🎬': '电影 视频', '📷': '相机 摄影', '🎮': '游戏',
'🏠': '房子 主页 家', '🏡': '家 房子', '🚀': '火箭 项目 发布', '💼': '工作 公文包', '🎯': '目标',
'🌍': '地球 世界', '🌏': '地球 亚洲', '🌱': '幼苗 成长', '🌿': '植物 叶子 自然', '🌸': '花 樱花',
'☀️': '太阳 晴天', '🌙': '月亮 夜晚', '🐱': '猫 动物', '🐶': '狗 动物', '🍎': '苹果 食物',
'☕': '咖啡 饮料', '😀': '笑脸 开心 表情', '😊': '微笑 开心 表情', '🤔': '思考 想法', '👍': '赞 好',
'⚠️': '警告 注意', '❗': '感叹号 重要', '❓': '问号 问题', '💰': '钱 财务', '🎓': '毕业 学习',
}
export function emojiLabel(emoji: Gemoji): string {
return keywords[emoji.emoji] ?? 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)
}
-373
View File
@@ -1,373 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EditorView } from '@codemirror/view'
import { insertImageFiles } from '../editor/paste'
import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, createNoteAt, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
let view: EditorView | undefined
beforeEach(() => { vi.spyOn(drafts, 'move').mockResolvedValue(undefined) })
afterEach(() => {
stopWorkspace()
attachView(null)
view?.destroy()
view = undefined
vi.restoreAllMocks()
})
describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const saved = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { saved.set(path, content); return 'r2' })
await startWorkspace()
const v = await edit('from/note.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
expect(drafts.move).toHaveBeenCalledWith('from', 'to')
expect(editorStateOf('from/note.md')).toBeUndefined()
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
await flush('to/note.md')
expect(saved.get('to/note.md')).toBe('尚未保存的输入')
expect(saved.has('from/note.md')).toBe(false)
await closeTab('to/note.md')
})
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'fonts').mockResolvedValue([])
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const read = vi.spyOn(api, 'readFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
await startWorkspace()
await openNote('sync.md')
const handlers = vi.mocked(connectEvents).mock.lastCall![0]
const observed: string[] = []
let resyncs = 0
const changed = () => observed.push(editorStateOf('sync.md')!.doc.toString())
const resync = () => { resyncs++ }
pluginEvents.addEventListener('changed', changed)
pluginEvents.addEventListener('resync', resync)
try {
let finish!: (value: { content: string; rev: string }) => void
read.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r2' }])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(observed).toEqual([])
finish({ content: '新正文', rev: 'r2' })
await vi.waitFor(() => expect(observed).toEqual(['新正文']))
read.mockRejectedValueOnce(new ApiError(500, '正文读取失败'))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r3' }])
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('正文读取失败'))).toBe(true))
expect(observed).toEqual(['新正文'])
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
handlers.onResync()
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('目录读取失败'))).toBe(true))
expect(resyncs).toBe(0)
read.mockResolvedValue({ content: '恢复后的正文', rev: 'r3' })
handlers.onResync()
await vi.waitFor(() => expect(resyncs).toBe(1))
expect(editorStateOf('sync.md')!.doc.toString()).toBe('恢复后的正文')
} finally {
pluginEvents.removeEventListener('changed', changed)
pluginEvents.removeEventListener('resync', resync)
await closeTab('sync.md')
}
})
})
async function edit(path: string) {
vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
ws.entries.push({ path, dir: false, size: 0, mtime: 0 })
await openNote(path)
view = new EditorView({ parent: document.body, state: editorStateOf(path)! })
attachView(view)
return view
}
describe('笔记打开与创建', () => {
it('从图片库新建或打开已有笔记时显示笔记视图', async () => {
vi.spyOn(api, 'writeFile').mockResolvedValue('r1')
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
ws.images = ''
ws.view = 'graph'
expect(await createNoteAt('gallery-create.md', '正文')).toBe(true)
expect(ws.images).toBeNull()
expect(ws.view).toBe('notes')
expect(ws.active).toBe('gallery-create.md')
ws.images = ''
await openNote('gallery-create.md')
expect(ws.images).toBeNull()
expect(editorStateOf('gallery-create.md')?.doc.toString()).toBe('正文')
await closeTab('gallery-create.md')
})
it.each([true, false])('创建期间打开同一路径,创建成功=%s,不产生重复或错误标签', async (succeeds) => {
const path = `pending-create-${succeeds}.md`
let finish!: (rev: string) => void
let fail!: (error: Error) => void
vi.spyOn(api, 'writeFile').mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject }))
vi.spyOn(api, 'readFile').mockRejectedValue(new ApiError(404, '文件不存在'))
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const creating = createNoteAt(path, '创建内容')
const opening = openNote(path)
if (succeeds) finish('r1')
else fail(new ApiError(500, '创建写入失败'))
expect(await creating).toBe(succeeds)
await opening
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(succeeds ? 1 : 0)
if (succeeds) {
expect(ws.tabs.find(tab => tab.path === path)?.status).toBe('ready')
expect(editorStateOf(path)?.doc.toString()).toBe('创建内容')
await closeTab(path)
} else {
expect(ws.toasts.some(toast => toast.text.includes('创建写入失败'))).toBe(true)
}
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
})
})
describe('保存与关闭', () => {
it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => {
const v = await edit('conflict.md')
vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(412, '版本冲突', { rev: 'r2' }))
const saved = new Map<string, Draft>()
const put = vi.spyOn(drafts, 'put').mockImplementation(async (draft) => { saved.set(draft.path, draft); return draft.path })
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突前' } })
await flush('conflict.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突后新增' } })
put.mockRejectedValueOnce(new Error('存储失败'))
expect(await closeTab('conflict.md')).toBe(false)
expect(ws.tabs.some((tab) => tab.path === 'conflict.md')).toBe(true)
expect(await closeTab('conflict.md')).toBe(true)
expect(saved.get('conflict.md')?.content).toBe('冲突后新增')
expect(editorStateOf('conflict.md')).toBeUndefined()
})
it('冲突保存期间的新输入继续保存,不能标成已保存', async () => {
const v = await edit('race.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const contents: string[] = []
let finish!: (rev: string) => void
vi.spyOn(api, 'writeFile').mockImplementation(async (_path, content) => {
contents.push(content)
return contents.length === 1 ? new Promise<string>((resolve) => { finish = resolve }) : 'r3'
})
const tab = ws.tabs.find((tab) => tab.path === 'race.md')!
Object.assign(tab, { status: 'conflict', conflictRev: 'r2' })
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '请求发出时' } })
const saving = keepLocalVersion('race.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '等待期间又输入' } })
finish('r2-new')
await saving
await flush('race.md')
expect(contents).toEqual(['请求发出时', '等待期间又输入'])
expect(tab.dirty).toBe(false)
await closeTab('race.md')
})
it('网页图片导入期间切换标签,转换内容仍保存到原笔记', async () => {
const v = await edit('网页.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const contents = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
let finish!: (link: string) => void
vi.spyOn(api, 'importImage').mockImplementation(() => new Promise<string>((resolve) => { finish = resolve }))
const data = new DataTransfer()
data.setData('text/plain', '网页标题')
data.setData('text/html', '<h1>网页标题</h1><img src="https://example.com/a.png">')
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(await closeTab('网页.md')).toBe(false)
await openNote('other.md')
storeViewState('网页.md', v.state)
v.setState(editorStateOf('other.md')!)
finish('网页.assets/hash.png')
await vi.waitFor(() => expect(editorStateOf('网页.md')!.doc.toString()).toContain('网页.assets/hash.png'))
await flush('网页.md')
expect(contents.get('网页.md')).toContain('# 网页标题')
expect(contents.get('网页.md')).toContain('网页.assets/hash.png')
expect(v.state.doc.toString()).toBe('原文')
await closeTab('网页.md')
await closeTab('other.md')
})
it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => {
const v = await edit('image.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const contents = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
let finish!: (link: string) => void
vi.spyOn(api, 'upload').mockImplementation(() => new Promise<string>((resolve) => { finish = resolve }))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
expect(await closeTab('image.md')).toBe(false)
await openNote('other.md')
v.setState(editorStateOf('other.md')!)
finish('image.assets/ok.png')
await vi.waitFor(() => expect(editorStateOf('image.md')!.doc.toString()).toContain('image.assets/ok.png'))
await flush('image.md')
expect(contents.get('image.md')).toBe('![](image.assets/ok.png)原文')
expect(v.state.doc.toString()).toBe('原文')
await closeTab('image.md')
await closeTab('other.md')
})
it('图片上传期间不能移动笔记或其父目录', async () => {
const v = await edit('upload/note.md')
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
vi.spyOn(api, 'writeFile').mockResolvedValue('r2')
const moving = vi.spyOn(api, 'move').mockResolvedValue(new Response())
let finish!: (link: string) => void
vi.spyOn(api, 'upload').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pending-move')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
await move('upload/note.md', 'renamed.md')
await move('upload', 'renamed-folder')
expect(moving).not.toHaveBeenCalled()
expect(ws.active).toBe('upload/note.md')
await refreshTree()
finish('note.assets/hash.png')
await vi.waitFor(() => expect(v.state.doc.toString()).toContain('note.assets/hash.png'))
await closeTab('upload/note.md')
})
it('移动期间拒绝新的图片和网页粘贴,保留原选区内容', async () => {
const v = await edit('moving.md')
v.dispatch({ selection: { anchor: 0, head: v.state.doc.length } })
let finish!: (response: Response) => void
vi.spyOn(api, 'move').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'moved.md', dir: false, size: 0, mtime: 0 }])
const upload = vi.spyOn(api, 'upload')
const importing = vi.spyOn(api, 'importImage')
const moving = move('moving.md', 'moved.md')
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])).toBe(true)
const data = new DataTransfer()
data.setData('text/html', '<h1>不应插入</h1>')
data.setData('text/plain', '不应插入')
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
expect(v.state.doc.toString()).toBe('原文')
expect(v.state.selection.main.from).toBe(0)
expect(v.state.selection.main.to).toBe(2)
expect(upload).not.toHaveBeenCalled()
expect(importing).not.toHaveBeenCalled()
finish(new Response())
await moving
await refreshTree()
await closeTab('moved.md')
})
it('草稿迁移失败时不移动文件,服务端失败时恢复草稿路径', async () => {
await edit('draft-move.md')
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }])
const moving = vi.spyOn(api, 'move').mockRejectedValue(new ApiError(500, '移动失败'))
vi.mocked(drafts.move).mockRejectedValueOnce(new Error('草稿不可写'))
await move('draft-move.md', 'draft-moved.md')
expect(moving).not.toHaveBeenCalled()
expect(ws.active).toBe('draft-move.md')
await move('draft-move.md', 'draft-moved.md')
expect(ws.active).toBe('draft-move.md')
expect(drafts.move).toHaveBeenLastCalledWith('draft-moved.md', 'draft-move.md')
await refreshTree()
await closeTab('draft-move.md')
})
})
describe('导入', () => {
it('上传期间阻止刷新,成功后刷新目录,并保留真实的成功结果', async () => {
ws.tabs = []
const result = { paths: ['导入'], notes: 1, assets: 0, warnings: [] }
let finish!: (value: typeof result) => void
vi.spyOn(api, 'importNotes').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: '导入', dir: true, size: 0, mtime: 0 }])
const importing = importNotes('', '导入', [new File(['正文'], 'test.md')])
expect(readyToReload()).toBe(false)
finish(result)
expect(await importing).toEqual(result)
expect(ws.entries.some((e) => e.path === '导入')).toBe(true)
expect(readyToReload()).toBe(true)
vi.mocked(api.importNotes).mockResolvedValueOnce(result)
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
expect(await importNotes('', '导入', [])).toEqual(result)
expect(ws.toasts.some((t) => t.text.includes('导入已完成,目录刷新失败'))).toBe(true)
})
it('失败保留错误并释放操作占用', async () => {
ws.tabs = []
vi.spyOn(api, 'importNotes').mockRejectedValue(new ApiError(409, '目标已存在'))
await expect(importNotes('', '导入', [])).rejects.toMatchObject({ status: 409, message: '目标已存在' })
expect(readyToReload()).toBe(true)
})
})
describe('图片管理', () => {
it('替换期间阻止刷新,完成后更新列表;刷新失败不伪装成替换失败', async () => {
ws.tabs = []
let finish!: (value: Response) => void
vi.spyOn(api, 'replaceImage').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
const replacing = replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(readyToReload()).toBe(false)
finish(new Response())
await replacing
expect(ws.entries.find((e) => e.path === 'assets/a.png')?.mtime).toBe(2)
expect(readyToReload()).toBe(true)
vi.mocked(api.replaceImage).mockResolvedValueOnce(new Response())
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '读取失败'))
await replaceImage('assets/a.png', new File(['image'], 'a.png'))
expect(ws.toasts.some((t) => t.text.includes('图片已替换,但刷新目录失败'))).toBe(true)
})
it('恢复图片只打开预览,不创建 Markdown 标签', async () => {
ws.tabs = []
ws.active = null
vi.spyOn(api, 'restore').mockResolvedValue({ id: 'trash-image', path: 'assets/a.png', dir: false, deletedAt: 1 })
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'assets/a.png', dir: false, size: 2, mtime: 2 }])
await restore('trash-image')
expect(ws.image).toBe('assets/a.png')
expect(ws.tabs).toEqual([])
expect(ws.active).toBeNull()
})
})
describe('文件操作的保存前提', () => {
it.each(['move', 'delete'] as const)('%s 保存失败时返回失败并保留原文档', async (action) => {
const path = `保存失败-${action}.md`
const v = await edit(path)
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const saving = vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(403, '禁止写入'))
vi.spyOn(api, 'tree').mockResolvedValue([{ path, dir: false, size: 0, mtime: 0 }])
v.dispatch({ changes: { from: 0, insert: '未保存' } })
const result = action === 'move' ? await move(path, '目标.md') : await remove(path)
expect(result.ok).toBe(false)
expect(ws.active).toBe(path)
expect(editorStateOf(path)?.doc.toString()).toBe('未保存原文')
saving.mockResolvedValue('r2')
await flush(path)
await refreshTree()
await closeTab(path)
})
})
+16
View File
@@ -0,0 +1,16 @@
import { requestJSON } from '@/http/axios'
export interface LoginResult { token: string, expiresAt: string }
export interface Identity { permissions: string[], expiresAt: string }
export interface Captcha { captchaId: string, imageDataUrl: string, expiresAt: string }
export interface ResourceTicket { url: string, expiresAt: string }
export type TicketRequest = { kind: 'images' | 'fonts' | 'events' } | { kind: 'plugin', pluginId: string, revision: string }
export const authApi = {
state: () => requestJSON<{ setUp: boolean }>('GET', '/auth/state', { auth: false, errorOwner: 'caller' }),
captcha: (replaceId?: string) => requestJSON<Captcha>('POST', '/auth/captcha', { auth: false, json: { replaceId }, errorOwner: 'caller' }),
setup: (password: string) => requestJSON<LoginResult>('POST', '/auth/setup', { auth: false, json: { password }, errorOwner: 'caller' }),
login: (password: string, captchaId: string, captchaCode: string) => requestJSON<LoginResult>('POST', '/auth/login', { auth: false, json: { password, captchaId, captchaCode }, errorOwner: 'caller' }),
me: () => requestJSON<Identity>('GET', '/auth/me', { errorOwner: 'caller' }),
logout: () => requestJSON<null>('POST', '/auth/logout'),
ticket: (data: TicketRequest) => requestJSON<ResourceTicket>('POST', '/auth/tickets', { json: data }),
}
+40
View File
@@ -0,0 +1,40 @@
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
import { authApi } from './index'
let version = -1
let imageRoot = ''
let fontRoot = ''
let pending: Promise<void> | undefined
export async function prepareResources() {
const user = useUserStore(pinia)
const current = user.getSessionVersion()
if (version !== current) {
version = current; imageRoot = ''; fontRoot = ''; pending = undefined
}
if (imageRoot && fontRoot) return
if (!pending) {
const request = Promise.all([authApi.ticket({ kind: 'images' }), authApi.ticket({ kind: 'fonts' })]).then(([images, fonts]) => {
user.assertSession(current)
imageRoot = images.url
fontRoot = fonts.url
})
pending = request
void request.finally(() => {
if (pending === request) pending = undefined
}).catch(() => { /* 由等待初始化的调用方处理失败。 */ })
}
await pending
user.assertSession(current)
}
export function imageResourceRoot(): string {
if (!imageRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('图片资源认证尚未就绪')
return imageRoot
}
export function fontResourceRoot(): string {
if (!fontRoot || version !== useUserStore(pinia).getSessionVersion()) throw new Error('字体资源认证尚未就绪')
return fontRoot
}
export async function pluginResourceURL(pluginId: string, revision: string): Promise<string> {
return `${(await authApi.ticket({ kind: 'plugin', pluginId, revision })).url}main.js`
}
+15
View File
@@ -0,0 +1,15 @@
import type { InstalledPlugin } from './type'
import { requestJSON } from '@/http/axios'
export const pluginsApi = {
plugins: async () => (await requestJSON<{ plugins: InstalledPlugin[] }>('GET', '/plugins', { errorOwner: 'caller' })).plugins,
installPlugin: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<InstalledPlugin>('POST', '/plugins', { errorOwner: 'caller', body })
},
togglePlugin: (id: string, enabled: boolean) => requestJSON<InstalledPlugin>('PATCH', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller', json: { enabled } }),
uninstallPlugin: (id: string) => requestJSON('DELETE', `/plugins/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
pluginData: (id: string) => requestJSON<{ data: unknown }>('GET', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller' }),
savePluginData: (id: string, data: unknown) => requestJSON('PUT', `/plugins/${encodeURIComponent(id)}/data`, { errorOwner: 'caller', json: { data } }),
}
+8
View File
@@ -0,0 +1,8 @@
export interface InstalledPlugin {
id: string
name: string
version: string
apiVersion: 1
revision: string
enabled: boolean
}
+16
View File
@@ -0,0 +1,16 @@
import type { CustomFont, Theme } from './type'
import { requestJSON } from '@/http/axios'
export const preferencesApi = {
settings: () => requestJSON<Record<string, unknown>>('GET', '/settings', { errorOwner: 'caller' }),
fonts: async () => (await requestJSON<{ fonts: CustomFont[] }>('GET', '/fonts', { errorOwner: 'caller' })).fonts,
importFont: (file: File) => {
const body = new FormData()
body.append('file', file)
return requestJSON<CustomFont>('POST', '/fonts', { errorOwner: 'caller', body })
},
deleteFont: (id: string) => requestJSON('DELETE', `/fonts/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
themes: async () => (await requestJSON<{ themes: Theme[] }>('GET', '/themes', { errorOwner: 'caller' })).themes,
theme: (id: string) => requestJSON<Theme & { css: string }>('GET', `/themes/${encodeURIComponent(id)}`, { errorOwner: 'caller' }),
patchSettings: (patch: Record<string, unknown>) => requestJSON<Record<string, unknown>>('PATCH', '/settings', { errorOwner: 'caller', json: patch }),
}
+11
View File
@@ -0,0 +1,11 @@
export interface Theme {
id: string
name: string
version: string
}
export interface CustomFont {
id: string
name: string
format: 'ttf' | 'otf' | 'woff' | 'woff2'
}
+9
View File
@@ -0,0 +1,9 @@
import type { TrashItem, TrashPurgeResult } from './type'
import { requestJSON } from '@/http/axios'
export const trashApi = {
clearTrash: () => requestJSON<TrashPurgeResult>('DELETE', '/trash', { errorOwner: 'caller' }),
trash: async () => (await requestJSON<{ items: TrashItem[] }>('GET', '/trash', { errorOwner: 'caller' })).items,
restore: (id: string) => requestJSON<TrashItem>('POST', `/trash/${id}/restore`, { errorOwner: 'caller' }),
purge: (id: string) => requestJSON('DELETE', `/trash/${id}`, { errorOwner: 'caller' }),
}
+11
View File
@@ -0,0 +1,11 @@
export interface TrashItem {
id: string
path: string
dir: boolean
deletedAt: number
}
export interface TrashPurgeResult {
deleted: string[]
failed: { id: string, error: string }[]
}
+16
View File
@@ -0,0 +1,16 @@
import type { Entry } from './type'
import { encodePath, requestJSON, requestRaw } from '@/http/axios'
import { pinia } from '@/pinia'
import { useUserStore } from '@/pinia/stores/user'
export const vaultApi = {
tree: async () => (await requestJSON<{ entries: Entry[] }>('GET', '/tree', { errorOwner: 'caller' })).entries,
async readFile(path: string): Promise<{ content: string, rev: string }> {
const user = useUserStore(pinia)
const version = user.getSessionVersion()
const res = await requestRaw('GET', `/files/${encodePath(path)}`, { errorOwner: 'caller' })
const content = await res.text()
user.assertSession(version)
return { content, rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
},
}
+11
View File
@@ -0,0 +1,11 @@
import type { IconName } from '@@/utils/icons'
export type TreeIcon = { kind: 'emoji', value: string } | { kind: 'builtin', value: IconName }
export interface Entry {
path: string
dir: boolean
size: number
mtime: number
icon?: TreeIcon
}
@@ -22,10 +22,12 @@ export function sanitizeAppearance(raw: Record<string, unknown>): Appearance {
return {
colorMode: raw.colorMode === 'light' || raw.colorMode === 'dark' ? raw.colorMode : 'system',
theme: typeof raw.theme === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(raw.theme) ? raw.theme : 'default',
cssSnippets: Array.isArray(raw.cssSnippets) ? raw.cssSnippets.filter((s): s is CssSnippet =>
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string' &&
typeof s.css === 'string' && typeof s.enabled === 'boolean',
).map((s) => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled })) : [],
cssSnippets: Array.isArray(raw.cssSnippets)
? raw.cssSnippets.filter((s): s is CssSnippet =>
s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.name === 'string'
&& typeof s.css === 'string' && typeof s.enabled === 'boolean',
).map(s => ({ id: s.id, name: s.name, css: s.css, enabled: s.enabled }))
: [],
}
}
@@ -55,13 +57,15 @@ function render() {
root.dataset.rmTheme = appearancePaused ? 'default' : current.theme
style('rm-theme', appearancePaused ? '' : themeCSS)
// 独立样式元素避免某个片段的未闭合语法吞掉其他片段。
document.querySelectorAll('style[data-rm-snippet]').forEach((el) => el.remove())
if (!appearancePaused) for (const snippet of current.cssSnippets) {
if (!snippet.enabled) continue
const el = document.createElement('style')
el.dataset.rmSnippet = snippet.id
el.textContent = snippet.css
document.head.append(el)
document.querySelectorAll('style[data-rm-snippet]').forEach(el => el.remove())
if (!appearancePaused) {
for (const snippet of current.cssSnippets) {
if (!snippet.enabled) continue
const el = document.createElement('style')
el.dataset.rmSnippet = snippet.id
el.textContent = snippet.css
document.head.append(el)
}
}
const bg = getComputedStyle(root).getPropertyValue('--rm-bg').trim()
for (const meta of document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')) {
+656
View File
@@ -0,0 +1,656 @@
@import './fonts.css';
@import 'highlight.js/styles/atom-one-dark.css';
@import './maple-font.css';
/* 基础主题。--rm-* 变量继续作为主题接口,字体在本地加载。 */
:root {
color-scheme: light;
--rm-sidebar-width: 288px;
--rm-font-text: 'Maple Mono Normal NL NF CN', monospace;
--rm-font-heading: var(--rm-font-editor, var(--rm-font-text));
--rm-font-mono: 'Maple Mono Normal NL NF CN', monospace;
--rm-font-size: 16px;
/* 界面排版独立于正文;辅助文字随界面缩放,但不小于 13px。 */
--rm-ui-font-size: 14px;
--rm-ui-font-small: max(13px, calc(var(--rm-ui-font-size) - 2px));
--rm-ui-font-caption: max(13px, calc(var(--rm-ui-font-size) - 3px));
--rm-ui-font-heading: calc(var(--rm-ui-font-size) + 4px);
--rm-ui-font-title: calc(var(--rm-ui-font-size) + 8px);
--rm-ui-line-height: 1.5;
--rm-icon-size: max(16px, calc(var(--rm-ui-font-size) + 2px));
--rm-control-size: max(32px, calc(var(--rm-ui-font-size) + 18px));
--rm-control-compact: max(28px, calc(var(--rm-ui-font-size) + 14px));
--rm-space-1: 4px;
--rm-space-2: 8px;
--rm-space-3: 12px;
--rm-space-4: 16px;
--rm-space-6: 24px;
--rm-panel-duration: 220ms;
--rm-panel-easing: cubic-bezier(0.2, 0, 0, 1);
--rm-header-height: calc(var(--rm-ui-font-size) + 48px);
--rm-line-height: 1.75;
--rm-content-width: 760px;
--rm-glass-opacity: 0.75;
--rm-bg: #faf8ff;
--rm-sidebar-bg: #f2f3ff;
--rm-text: #131b2e;
--rm-text-muted: #777587;
--rm-divider: rgba(119, 117, 135, 0.14);
--rm-accent: #4f46e5;
--rm-accent-strong: #3525cd;
--rm-selection: rgba(79, 70, 229, 0.16);
--rm-hover: rgba(199, 196, 216, 0.14);
--rm-active: rgba(255, 255, 255, 0.85);
--rm-danger: #ba1a1a;
--rm-warn-bg: #fff8e6;
--rm-warn-text: #8a5a00;
--rm-shadow: 0 12px 40px -12px rgba(19, 27, 46, 0.18), 0 2px 8px rgba(19, 27, 46, 0.03);
--rm-inline-code-bg: #eaedff;
--rm-inline-code-text: #3525cd;
--rm-code-bg: #282c34;
--rm-code-text: #abb2bf;
--rm-code-font-size: 14px;
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
}
:root:where([data-rm-color-mode='dark']) {
color-scheme: dark;
--rm-bg: #1e2022;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
--rm-hover: rgba(255, 255, 255, 0.06);
--rm-active: rgba(66, 211, 146, 0.16);
--rm-warn-bg: #2f2716;
--rm-warn-text: #f0c060;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-rm-color-mode])) {
color-scheme: dark;
--rm-bg: #1e2022;
--rm-text: rgba(255, 255, 255, 0.87);
--rm-text-muted: rgba(235, 235, 235, 0.6);
--rm-divider: rgba(84, 84, 84, 0.48);
--rm-accent: #42d392;
--rm-accent-strong: #42b883;
--rm-inline-code-bg: #2f2f2f;
--rm-inline-code-text: #f19a58;
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
--rm-sidebar-bg: #191b1d;
--rm-hover: rgba(255, 255, 255, 0.06);
--rm-active: rgba(66, 211, 146, 0.16);
--rm-warn-bg: #2f2716;
--rm-warn-text: #f0c060;
--rm-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
}
html,
body {
margin: 0;
background: var(--rm-bg);
color: var(--rm-text);
font-family: var(--rm-font-text);
}
body {
font-size: var(--rm-ui-font-size);
line-height: var(--rm-ui-line-height);
}
button,
input,
select,
textarea {
font: inherit;
}
.rm-icon {
flex: none;
width: var(--rm-icon-size);
height: var(--rm-icon-size);
}
@media (pointer: coarse), (max-width: 767px) {
:root {
--rm-control-size: max(44px, calc(var(--rm-ui-font-size) + 24px));
--rm-control-compact: max(44px, calc(var(--rm-ui-font-size) + 16px));
}
input:not([type='checkbox'], [type='radio'], [type='range']),
select,
textarea {
font-size: max(16px, var(--rm-ui-font-size));
}
}
.cm-editor {
font-size: var(--rm-font-size);
background: var(--rm-bg);
color: var(--rm-text);
}
.cm-editor.cm-focused {
outline: none;
}
.cm-editor .cm-scroller {
font-family: var(--rm-font-editor, var(--rm-font-text));
line-height: var(--rm-line-height);
}
.cm-editor .cm-content {
max-width: var(--rm-content-width);
margin: 0 auto;
padding: 32px 24px 40vh;
caret-color: var(--rm-accent);
}
.cm-editor .cm-content.cm-rm-source {
font-family: var(--rm-font-mono);
}
.cm-editor .cm-cursor {
border-left: 2px solid var(--rm-accent);
}
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground {
background: var(--rm-selection) !important;
}
/* 标题 */
.cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) {
line-height: 1.4;
padding-top: 1rem;
padding-bottom: 0.5rem;
}
.cm-line.cm-rm-h1 {
font-size: 2.2em;
line-height: 1.3;
}
.cm-line.cm-rm-h2 {
font-size: 1.75em;
line-height: 1.225;
border-bottom: 1px solid var(--rm-divider);
}
.cm-line.cm-rm-h3 {
font-size: 1.4em;
line-height: 1.43;
}
.cm-line.cm-rm-h4 {
font-size: 1.2em;
}
.cm-line.cm-rm-h5,
.cm-line.cm-rm-h6 {
font-size: 1em;
}
/* Markdown 标记(激活行显示源码时) */
.cm-rm-markup,
.cm-rm-url {
color: var(--rm-text-muted);
}
.cm-rm-link,
.cm-rm-link .cm-rm-url {
color: var(--rm-accent-strong);
text-decoration: none;
cursor: text;
}
/* 行内代码 */
.cm-rm-inline-code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.15em 0.4em;
border-radius: 4px;
}
/* 代码块
* 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */
.cm-line.cm-rm-code {
position: relative;
font-family: var(--rm-font-mono);
font-size: var(--rm-code-font-size);
line-height: 1.5;
color: var(--rm-code-text);
padding: 0 24px;
tab-size: 4;
}
.cm-line.cm-rm-code::before {
content: '';
position: absolute;
inset: 0;
background: var(--rm-code-bg);
z-index: -10;
pointer-events: none;
}
.cm-line.cm-rm-code .cm-rm-mono {
font-family: inherit;
}
.cm-line.cm-rm-code-begin {
padding-top: 10px;
min-height: var(--rm-control-compact);
}
.cm-line.cm-rm-code-end {
padding-bottom: 10px;
}
.cm-line.cm-rm-code-begin::before {
border-radius: 8px 8px 0 0;
}
.cm-line.cm-rm-code-end::before {
border-radius: 0 0 8px 8px;
}
.cm-line.cm-rm-code-begin.cm-rm-code-end::before {
border-radius: 8px;
}
/* 围栏展开前后保持同一行高,语言标签与复制按钮不参与行高计算,避免代码内容跳动。 */
.cm-rm-code-header {
display: inline-flex;
align-items: center;
gap: 6px;
vertical-align: middle;
user-select: none;
}
.cm-line.cm-rm-code-begin .cm-rm-code-header {
position: absolute;
top: 6px;
right: 12px;
}
.cm-rm-code-lang {
color: var(--rm-text-muted);
font-size: var(--rm-ui-font-small);
line-height: 1;
}
.cm-rm-code-copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rm-control-compact);
height: var(--rm-control-compact);
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
}
.cm-rm-code-copy:hover {
color: var(--rm-accent-strong);
background: var(--rm-hover);
}
.cm-rm-code-copy.cm-rm-copied {
color: #42d392;
}
.cm-rm-code .cm-rm-markup {
color: var(--rm-text-muted);
}
/* 引用 */
.cm-line.cm-rm-quote {
border-left: 3px solid var(--rm-accent);
padding: 5px 15px;
background: color-mix(in srgb, var(--rm-accent) 4%, transparent);
}
/* 列表 */
.cm-rm-bullet {
color: var(--rm-text-muted);
display: inline-block;
width: 1ch;
}
.cm-rm-task {
margin: 0 0.4em 0 0;
vertical-align: -0.1em;
accent-color: var(--rm-accent);
cursor: pointer;
}
/* 分隔线 */
.cm-rm-hr {
display: inline-block;
width: 100%;
border-top: 1px solid var(--rm-divider);
vertical-align: middle;
}
/* 图片 */
.cm-rm-image {
display: block;
}
.cm-rm-image img {
max-width: 100%;
border-radius: 4px;
}
.cm-rm-image-error {
color: var(--rm-text-muted);
font-style: italic;
}
/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */
.cm-line.cm-rm-table {
font-family: var(--rm-font-mono);
font-size: 0.9em;
}
.cm-rm-table-widget {
padding: 2px 0 4px;
width: fit-content;
max-width: 100%;
}
.cm-rm-table-scroll {
overflow-x: auto;
}
.cm-rm-table-widget table {
border-collapse: collapse;
font-size: 0.95em;
line-height: 1.5;
}
.cm-rm-table-widget th,
.cm-rm-table-widget td {
border: 1px solid var(--rm-divider);
padding: 6px 12px;
min-width: 3em;
cursor: text;
outline: none;
}
.cm-rm-table-widget th {
font-weight: 600;
text-align: left;
background: var(--rm-inline-code-bg);
}
.cm-rm-table-widget [contenteditable] {
box-shadow: inset 0 0 0 2px var(--rm-accent);
font-family: var(--rm-font-mono);
font-size: 0.9em;
}
.cm-rm-table-widget code {
font-family: var(--rm-font-mono);
font-size: 0.875em;
background: var(--rm-inline-code-bg);
color: var(--rm-inline-code-text);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.cm-rm-table-widget a {
color: var(--rm-accent-strong);
}
/* 工具栏占据表格上方的固定空间,只切换可见性,不遮挡表格也不引起跳动 */
.cm-rm-table-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s;
}
.cm-rm-table-widget:hover .cm-rm-table-toolbar,
.cm-rm-table-widget:focus-within .cm-rm-table-toolbar {
visibility: visible;
opacity: 1;
}
.cm-rm-table-tools {
max-width: 100%;
flex-wrap: wrap;
display: inline-flex;
align-items: center;
padding: 2px;
border: 1px solid var(--rm-divider);
border-radius: 8px;
background: var(--rm-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.cm-rm-table-tools button {
min-width: var(--rm-control-compact);
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--rm-control-compact);
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--rm-text-muted);
font: var(--rm-ui-font-small)/1 var(--rm-font-text);
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
}
.cm-rm-table-tools button:hover {
color: var(--rm-accent-strong);
background: var(--rm-selection);
}
.cm-rm-table-tools button.cm-rm-danger:hover {
color: #e5484d;
background: rgba(229, 72, 77, 0.1);
}
.cm-rm-table-tools svg {
flex: none;
width: var(--rm-ui-font-size);
height: var(--rm-ui-font-size);
}
.cm-rm-code-copy svg {
width: var(--rm-icon-size);
height: var(--rm-icon-size);
}
.cm-rm-table-tools .cm-rm-sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--rm-divider);
}
.cm-rm-image {
cursor: pointer;
}
@media (max-width: 640px) {
.cm-editor .cm-content {
padding: 16px 16px 40vh;
}
/* 触屏没有悬停,工具栏常显,按钮加大触控热区 */
.cm-rm-table-toolbar {
visibility: visible;
opacity: 1;
}
.cm-line.cm-rm-code {
padding-left: 12px;
padding-right: 12px;
}
}
/* ---- Typora 扩展语法 ---- */
.cm-rm-highlight,
.cm-rm-table-widget mark {
background: var(--rm-highlight-bg);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.cm-rm-sup {
vertical-align: super;
font-size: 0.75em;
line-height: 0;
}
.cm-rm-sub {
vertical-align: sub;
font-size: 0.75em;
line-height: 0;
}
.cm-rm-placeholder {
color: var(--rm-text-muted);
font-style: italic;
}
.cm-rm-math {
cursor: pointer;
}
.cm-rm-math-block {
display: block;
padding: 8px 0;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
cursor: pointer;
}
.cm-rm-math-block .katex-display {
margin: 0;
}
.cm-line.cm-rm-math-source {
font-family: var(--rm-font-mono);
font-size: 0.9em;
color: var(--rm-text-muted);
}
/* 编辑时显示在源码下方的预览 */
.cm-rm-preview {
margin: 4px 0 8px;
padding: 8px 12px;
border: 1px dashed var(--rm-divider);
border-radius: 6px;
cursor: default;
}
.cm-rm-code-render {
display: block;
padding: 8px 0;
overflow-x: auto;
cursor: pointer;
}
.cm-rm-mermaid {
display: block;
padding: 8px 0;
text-align: center;
overflow-x: auto;
cursor: pointer;
color: var(--rm-text-muted);
}
.cm-rm-mermaid svg {
max-width: 100%;
height: auto;
}
.cm-rm-render-error {
margin: 0;
padding: 8px 12px;
border-radius: 6px;
background: rgba(229, 72, 77, 0.08);
color: #e5484d;
font-family: var(--rm-font-mono);
font-size: var(--rm-ui-font-small);
text-align: left;
white-space: pre-wrap;
}
.cm-rm-emoji {
cursor: text;
}
.cm-rm-footnote-ref {
color: var(--rm-accent-strong);
font-size: 0.75em;
cursor: pointer;
padding: 0 1px;
}
.cm-line.cm-rm-footnote {
font-size: 0.9em;
color: var(--rm-text-muted);
}
.cm-rm-footnote-label {
color: var(--rm-accent-strong);
}
.cm-rm-toc {
display: block;
padding: 8px 12px;
border-left: 3px solid var(--rm-divider);
cursor: pointer;
}
.cm-rm-toc-item {
display: block;
color: var(--rm-accent-strong);
line-height: 1.8;
cursor: pointer;
text-decoration: none;
}
.cm-rm-toc-item:hover {
text-decoration: underline;
}
.cm-line.cm-rm-frontmatter {
font-family: var(--rm-font-mono);
font-size: 0.85em;
color: var(--rm-text-muted);
background: var(--rm-inline-code-bg);
padding: 0 12px;
}
.cm-line.cm-rm-frontmatter-begin {
border-radius: 6px 6px 0 0;
padding-top: 4px;
}
.cm-line.cm-rm-frontmatter-end {
border-radius: 0 0 6px 6px;
padding-bottom: 4px;
}
.cm-line:is(.cm-rm-h1, .cm-rm-h2, .cm-rm-h3, .cm-rm-h4, .cm-rm-h5, .cm-rm-h6) {
font-family: var(--rm-font-heading);
font-weight: 600;
}
.cm-line.cm-rm-h1 {
font-size: 2.125em;
line-height: 1.4;
}
.cm-line.cm-rm-h2 {
font-size: 1.5em;
line-height: 1.5;
}
.cm-rm-underline {
text-decoration: underline;
}
.cm-editor .cm-content {
padding-top: 16px;
}
.cm-rm-code-lang {
font-family: var(--rm-font-mono);
}
.cm-line.cm-rm-code-begin::before {
border-top: 1px solid var(--rm-divider);
}
.cm-line.cm-rm-code-end::before {
border-bottom: 1px solid var(--rm-divider);
}
@media (max-width: 767px) {
.cm-line.cm-rm-h1 {
font-size: 1.75em;
}
}
/* opacity 保留排版和字体请求;等待期间不显示备用字体,也覆盖 Teleport 到 body 的菜单。 */
:root:is([data-rm-fonts='loading'], [data-rm-fonts='error']) body > :not(.rm-font-error) {
opacity: 0;
pointer-events: none;
}
.rm-font-error {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--rm-text);
font:
16px/1.5 system-ui,
sans-serif;
}
.rm-font-error button {
padding: 8px 16px;
border: 1px solid var(--rm-divider);
border-radius: 6px;
background: var(--rm-bg);
color: inherit;
cursor: pointer;
}
@@ -1,16 +1,29 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { watchFontLoading } from './fontLoading'
import { watchFontLoading } from './font-loading'
class Fonts extends EventTarget {
status = 'loading'
faces: { status: string }[] = []
resolve!: () => void
ready = this.pending()
pending() { return new Promise<void>(resolve => { this.resolve = resolve }) }
[Symbol.iterator]() { return this.faces[Symbol.iterator]() }
finish() { this.status = 'loaded'; this.resolve() }
load() { this.status = 'loading'; this.ready = this.pending(); this.dispatchEvent(new Event('loading')) }
pending() {
return new Promise<void>((resolve) => {
this.resolve = resolve
})
}
[Symbol.iterator]() {
return this.faces[Symbol.iterator]()
}
finish() {
this.status = 'loaded'; this.resolve()
}
load() {
this.status = 'loading'; this.ready = this.pending(); this.dispatchEvent(new Event('loading'))
}
}
let fonts: Fonts
let frame: FrameRequestCallback
@@ -19,7 +32,9 @@ beforeEach(() => {
document.body.innerHTML = '<div id="app">正文</div>'
fonts = new Fonts()
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frame = callback; return 1 })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frame = callback; return 1
})
watchFontLoading()
})
afterEach(() => {
File renamed without changes.
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
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('字体列表格式无效')
}
})
it('字体名称只供界面显示,不进入CSS;设置同步不重复替换相同字体声明', () => {
const fonts = parseFontCatalog([{ id: `custom-${'a'.repeat(64)}`, name: 'bad\');}body{color:red}', format: 'woff2' }])
applyFontCatalog(fonts)
const style = document.querySelector('#rm-custom-fonts')!
expect(style.textContent).not.toContain('body{color:red}')
const text = style.firstChild
applyFontCatalog(fonts)
expect(style.firstChild).toBe(text)
applyFontCatalog([])
})
})
vi.mock('@@/apis/auth/resources', () => ({
prepareResources: async () => {},
imageResourceRoot: () => '/api/v1/resources/test-images/images/',
fontResourceRoot: () => '/api/v1/resources/test-fonts/fonts/',
pluginResourceURL: async (id: string, revision: string) => `/api/v1/resources/test-plugin/plugin/plugins/${id}/assets/${revision}/main.js`,
}))
@@ -1,14 +1,18 @@
import type { CustomFont } from '@@/apis/preferences/type'
import { fontResourceRoot, prepareResources } from '@@/apis/auth/resources'
import { preferencesApi } from '@@/apis/preferences'
import { shallowRef } from 'vue'
import { api, type CustomFont } from './api'
export const customFonts = shallowRef<CustomFont[]>([])
export function applyFontCatalog(fonts: CustomFont[]) {
customFonts.value = fonts
let style = document.getElementById('rm-custom-fonts')
if (!style) { style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style) }
if (!style) {
style = document.createElement('style'); style.id = 'rm-custom-fonts'; document.head.append(style)
}
// 名称仅用于界面;CSS 标识和资源路径只由服务端内容哈希构成。
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('/api/v1/fonts/${font.id}/file'); font-display: block; }`).join('\n')
const css = fonts.map(font => `@font-face { font-family: '${font.id}'; src: url('${fontResourceRoot()}${font.id}/file'); font-display: block; }`).join('\n')
if (style.textContent !== css) style.textContent = css
}
@@ -18,5 +22,6 @@ export function parseFontCatalog(value: unknown): CustomFont[] {
}
export async function loadFonts() {
applyFontCatalog(parseFontCatalog(await api.fonts()))
await prepareResources()
applyFontCatalog(parseFontCatalog(await preferencesApi.fonts()))
}
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff. Show more