Manage folder images in a gallery and hide asset directories

This commit is contained in:
eezd committed 2026-10-21 16:05:01 +08:00
1 parent fbce545d98
commit f473ec64b6
10 files changed
+106 -4

No files matched your search

+1
View File
@@ -105,6 +105,7 @@ async function trash() {
<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>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Icon from './Icon.vue'
import { basename, imageInFolder } from './paths'
import { imageUrl, ws } from './workspace'
const props = defineProps<{ dir: string }>()
const dialog = ref<HTMLElement>()
const query = ref('')
const images = computed(() => ws.entries.filter((entry) => !entry.dir && imageInFolder(entry.path, props.dir) && entry.path.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())).sort((a, b) => a.path.localeCompare(b.path, 'zh-CN', { numeric: true })))
const exists = computed(() => props.dir === '' || ws.entries.some((entry) => entry.dir && entry.path === props.dir))
onMounted(() => dialog.value?.focus())
</script>
<template>
<div class="rm-dialog-backdrop" @click.self="ws.images = null" @keydown.esc.stop.prevent="ws.images = null">
<section ref="dialog" class="rm-dialog rm-gallery" tabindex="-1" role="dialog" aria-label="文件夹图片管理">
<div class="rm-dialog-head">
<h2>{{ dir ? basename(dir) : '根目录' }} · 图片管理</h2>
<button class="rm-icon-btn" title="关闭" @click="ws.images = null"><Icon name="close" /></button>
</div>
<div class="rm-gallery-tools"><input v-model="query" type="search" aria-label="搜索图片" placeholder="按名称或附件目录筛选…" /><span>{{ images.length }} 张图片</span></div>
<p class="rm-share-note">展示当前目录及直属 assets、*.assets 中的图片,不包含下级普通文件夹。点击图片可预览、改名、移动、替换或删除。</p>
<p v-if="!exists" class="rm-tree-empty" role="alert">文件夹已不存在</p>
<div v-else-if="images.length" class="rm-gallery-grid">
<button v-for="image in images" :key="image.path" class="rm-gallery-item" :title="image.path" @click="ws.image = image.path">
<img :src="imageUrl(image.path)" :alt="basename(image.path)" loading="lazy" />
<strong>{{ basename(image.path) }}</strong>
<span>{{ dir ? image.path.slice(dir.length + 1) : image.path }}</span>
<span>{{ (image.size / 1024).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) }} KB</span>
</button>
</div>
<p v-else class="rm-tree-empty">{{ query ? '没有符合筛选条件的图片' : '此目录没有图片' }}</p>
</section>
</div>
</template>
<style scoped>
.rm-gallery { width: min(960px, calc(100vw - 24px)); max-width: 960px; }
.rm-gallery-tools { display: flex; align-items: center; gap: 12px; padding: 0 20px; font-size: 13px; color: var(--rm-text-muted); }
.rm-gallery-tools input { flex: 1; min-width: 0; padding: 8px; border-radius: 6px; border: 1px solid var(--rm-divider); color: var(--rm-text); background: var(--rm-bg); }
.rm-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 12px 20px 20px; overflow-y: auto; }
.rm-gallery-item { display: flex; flex-direction: column; align-items: stretch; text-align: left; gap: 5px; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-bg); color: var(--rm-text); cursor: pointer; min-width: 0; }
.rm-gallery-item:hover { border-color: var(--rm-accent); background: var(--rm-hover); }
.rm-gallery-item img { width: 100%; height: 110px; object-fit: contain; border-radius: 4px; }
.rm-gallery-item strong, .rm-gallery-item span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.rm-gallery-item span { color: var(--rm-text-muted); }
</style>
+11 -1
View File
@@ -11,6 +11,12 @@ import PluginMount from './PluginMount.vue'
const emit = defineEmits<{ logout: [] }>()
const rootDrop = ref(false)
const rootMenu = ref<{ x: number; y: number } | null>(null)
function rootContext(event: MouseEvent) {
if (event.target instanceof Element && event.target.closest('.rm-tree-row')) return
event.preventDefault()
rootMenu.value = { x: Math.max(4, Math.min(event.clientX, innerWidth - 170)), y: Math.max(4, Math.min(event.clientY, innerHeight - 50)) }
}
const selectedView = ref('')
const currentView = computed(() => sidebarViews.get(selectedView.value))
watch(() => ws.searchOpen, () => { selectedView.value = '' })
@@ -47,12 +53,16 @@ function onRootDrop(e: DragEvent) {
</nav>
<PluginMount v-if="currentView" :key="selectedView" :view="currentView" />
<SearchPanel v-else-if="ws.searchOpen" />
<div v-else class="rm-tree" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
<div v-else class="rm-tree" @contextmenu="rootContext" :class="{ drop: rootDrop }" @dragover="onRootDragOver" @dragleave="rootDrop = false" @drop="onRootDrop">
<ul class="rm-tree-list">
<TreeNode v-for="node in tree" :key="node.path" :node="node" :depth="0" />
</ul>
<p v-if="!tree.length" class="rm-tree-empty">还没有笔记</p>
</div>
<template v-if="rootMenu">
<div class="rm-menu-backdrop" @click="rootMenu = null" @contextmenu.prevent="rootMenu = null" />
<div class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }"><button @click="ws.images = ''; rootMenu = null"><Icon name="image" />图片管理</button></div>
</template>
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" title="设置(Ctrl+,)" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
+3 -1
View File
@@ -73,13 +73,14 @@ function commitRename() {
if (target && target !== props.node.path) void move(props.node.path, target)
}
function runMenu(action: 'note' | 'folder' | 'import' | 'rename' | 'move' | 'share' | 'delete') {
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete') {
menuOpen.value = false
const path = props.node.path
if (action === 'note' || action === 'folder') {
expanded.add(path)
void (action === 'note' ? createNote(path) : createFolder(path))
} else if (action === 'import') ws.importing = path
else if (action === 'images') ws.images = path
else if (action === 'rename') ws.renaming = path
else if (action === 'share') ws.sharing = path
else if (action === 'move') ws.moving = path
@@ -152,6 +153,7 @@ function onDrop(e: DragEvent) {
<template v-if="node.dir && !assetDir">
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
<button @click="runMenu('images')"><Icon name="image" />图片管理</button>
<button @click="runMenu('import')"><Icon name="import" />导入</button>
</template>
<button v-if="image" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
+2
View File
@@ -7,6 +7,7 @@ import Icon from './Icon.vue'
import MobileToolbar from './MobileToolbar.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'
@@ -123,6 +124,7 @@ onBeforeUnmount(() => {
<SettingsDialog v-if="ws.settings" />
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
<ImageGallery v-if="ws.images !== null" :dir="ws.images" />
<ImageDialog v-if="ws.image" :path="ws.image" />
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
</div>
+2
View File
@@ -544,3 +544,5 @@ button {
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.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; width: max-content; }
+18
View File
@@ -0,0 +1,18 @@
import { expect, it } from 'vitest'
import { hiddenAssetPath, imageInFolder } from './paths'
it('图片管理仅包含当前目录和直属附件子树', () => {
expect(imageInFolder('项目/a.png', '项目')).toBe(true)
expect(imageInFolder('项目/笔记.assets/嵌套/a.png', '项目')).toBe(true)
expect(imageInFolder('项目/assets/a.png', '项目')).toBe(true)
expect(imageInFolder('项目/子目录/assets/a.png', '项目')).toBe(false)
expect(imageInFolder('项目二/a.png', '项目')).toBe(false)
expect(imageInFolder('项目/a.md', '项目')).toBe(false)
expect(imageInFolder('assets/a.png', '')).toBe(true)
})
it('导航隐藏附件目录的完整子树而不误伤相似名称', () => {
expect(hiddenAssetPath('项目/assets/a.png')).toBe(true)
expect(hiddenAssetPath('项目/a.assets')).toBe(true)
expect(hiddenAssetPath('项目/myassets/a.png')).toBe(false)
})
+13
View File
@@ -46,3 +46,16 @@ export function resolveAssetUrl(notePath: string, src: string): string {
}
return '/api/v1/raw/' + encodePath(parts.join('/'))
}
// 导航隐藏普通共享附件与笔记附件,不改变这些目录的读写契约。
export function hiddenAssetPath(path: string): boolean {
return path.split('/').some((part) => part === 'assets' || part.endsWith('.assets'))
}
export function imageInFolder(path: string, dir: string): boolean {
if (!isImage(path) || (dir && !path.startsWith(dir + '/'))) return false
const relative = dir ? path.slice(dir.length + 1) : path
if (!relative.includes('/')) return true
const first = relative.split('/')[0]
return first === 'assets' || first.endsWith('.assets')
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { computed, reactive, watch } from 'vue'
import type { Entry } from './api'
import { basename, dirname } from './paths'
import { basename, dirname, hiddenAssetPath } from './paths'
import { ws } from './workspace'
export interface TreeNode {
@@ -29,7 +29,7 @@ function build(entries: Entry[]): TreeNode[] {
return root
}
export const tree = computed(() => build(ws.entries))
export const tree = computed(() => build(ws.entries.filter((entry) => !hiddenAssetPath(entry.path))))
const EXPANDED_KEY = 'rawmark.expanded'
export const expanded = reactive(new Set<string>(JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? '[]') as string[]))
+6
View File
@@ -68,6 +68,7 @@ export const ws = reactive({
importing: null as string | null,
// 图片预览与管理,独立于笔记标签和草稿
image: null as string | null,
images: null as string | null,
// 电脑上侧栏折叠
sidebarCollapsed: layout.sidebarCollapsed ?? false,
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
@@ -755,6 +756,7 @@ export async function move(from: string, to: string) {
remapOpenNotes(from, to)
remapRecent(from, to)
if (ws.image) ws.image = remapPath(ws.image, from, to) ?? ws.image
if (ws.images) ws.images = remapPath(ws.images, from, to) ?? ws.images
await api.move(from, to)
} catch (err) {
if (remapped) {
@@ -762,6 +764,7 @@ export async function move(from: string, to: string) {
remapOpenNotes(to, from)
remapRecent(to, from)
if (ws.image) ws.image = remapPath(ws.image, to, from) ?? ws.image
if (ws.images) ws.images = remapPath(ws.images, to, from) ?? ws.images
}
if (draftsMoved) {
try { await drafts.move(to, from) }
@@ -865,6 +868,7 @@ async function handleEvents(evs: VaultEvent[]) {
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (ws.image) ws.image = remapPath(ws.image, ev.from!, ev.path!) ?? ws.image
if (ws.images) ws.images = remapPath(ws.images, ev.from!, ev.path!) ?? ws.images
const paused = affected(ev.from!).map(slot)
paused.forEach((s) => s.paused++)
try {
@@ -1061,6 +1065,7 @@ function registerBuiltinCommands() {
{ id: 'view.outline', title: '显示/隐藏大纲', keys: ['Mod+Shift+O'], when: hasNote, run: () => (ws.outlineOpen = !ws.outlineOpen) },
{ id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') },
{ id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) },
{ id: 'app.images', title: '管理根目录图片', run: () => (ws.images = '') },
{ id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') },
{ id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') },
]) {
@@ -1096,6 +1101,7 @@ export function openSearch() {
export function stopWorkspace() {
ws.image = null
ws.images = null
stopPlugins()
disconnect?.()
disconnect = undefined