Files
RawMark/web/src/pages/workspace/components/ImageGallery.vue
T
eezd 6b537c03e0 refactor(web): align resource APIs and forms with the starter conventions
Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors.

Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
2026-10-26 11:53:37 +08:00

649 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Entry } from '@@/apis/tree/type'
import Icon from '@@/components/Icon/index.vue'
import { toast } from '@@/utils/notifications'
import { basename, hiddenAssetPath, imageInFolder, isImage } from '@@/utils/paths'
import { computed, onMounted, ref } from 'vue'
import { metadata, refreshMetadata } from '../metadata'
import { cleanupImages, imageUrl, openReference, 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><el-button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">
返回笔记
</el-button>
</header>
<div class="rm-media-mobile-filters">
<el-select v-model="ws.imagesAll" aria-label="资源范围">
<el-option :value="false" label="当前目录" /><el-option :value="true" label="整个知识库" />
</el-select><el-select v-model="usage" aria-label="引用状态">
<el-option value="all" label="全部图片" /><el-option value="used" :disabled="!certain" label="已被引用" /><el-option value="unused" :disabled="!certain" label="未被引用" />
</el-select><el-button class="rm-btn" :disabled="busy || !certain" @click="proposeCleanup">
清理孤立图片
</el-button>
</div>
<div class="rm-media-workspace">
<aside class="rm-media-filters">
<span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false">
<Icon name="folder" />{{ dir || '根目录' }}
</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">
<el-input v-model="query" class="rm-media-search" type="search" clearable placeholder="搜索图片名称或路径…" aria-label="搜索图片">
<template #prefix>
<Icon name="search" />
</template>
</el-input><el-select v-model="format" aria-label="图片格式">
<el-option value="" label="所有格式" /><el-option v-for="type in types" :key="type" :value="type" :label="type.toUpperCase()" />
</el-select><el-select v-model="sort" aria-label="图片排序">
<el-option value="modified" label="最近修改" /><el-option value="name" label="名称" /><el-option value="size" label="文件大小" />
</el-select><button class="rm-icon-btn" :title="list ? '切换网格' : '切换列表'" @click="list = !list">
<Icon :name="list ? 'grid' : 'outline'" />
</button>
</div>
<div class="rm-media-upload">
<label>上传至 <el-select v-model="uploadDir" aria-label="上传目录"><el-option value="" label="根目录 / assets" /><el-option v-for="folder in directories" :key="folder.path" :value="folder.path" :label="`${folder.path} / assets`" /></el-select></label><input ref="picker" type="file" accept="image/*" multiple hidden @change="upload"><el-button class="rm-btn rm-btn-primary" :disabled="busy" @click="picker?.click()">
<Icon name="plus" />{{ busy ? '处理中…' : '上传图片' }}
</el-button>
</div>
<p v-if="metadata.error || !certain" class="rm-media-warning" role="status">
{{ metadata.error || (metadata.ready ? '部分笔记元数据异常,引用状态不完整;已禁用清理。' : '正在读取引用索引…') }} <el-button class="rm-btn" @click="refreshMetadata()">
刷新状态
</el-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><el-button class="rm-btn rm-btn-primary" @click="ws.image = current.path">
<Icon name="edit" />预览与编辑
</el-button><h3>引用此图片的笔记</h3><button v-for="reference in currentRefs" :key="reference.path" class="rm-media-reference" @click="ws.images = null; openReference(reference.path, reference.line)">
<Icon name="note" /><span>{{ reference.title }}<small>{{ reference.path }}</small></span>
</button><p v-if="certain && !currentRefs.length">
没有笔记引用
</p>
</aside>
</div>
<el-dialog :model-value="cleanupCandidates !== null" :title="`清理候选 · ${cleanupCandidates?.length ?? 0} 张`" width="640px" class="rm-cleanup-dialog" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" :before-close="() => { if (!busy) cleanupCandidates = null }" @close="cleanupCandidates = null">
<p class="rm-share-note">
服务端会再次读取最新笔记;仍有引用的图片将跳过。确认后移入回收站。
</p><ul>
<li v-for="image in cleanupCandidates ?? []" :key="image.path">
<el-checkbox-group v-model="cleanupSelected">
<el-checkbox :value="image.path" :disabled="busy">
{{ image.path }} <small>{{ size(image.size) }}</small>
</el-checkbox>
</el-checkbox-group>
</li>
</ul><p v-if="!cleanupCandidates?.length" class="rm-share-note">
当前范围没有可清理的孤立图片。
</p><div class="rm-cleanup-actions">
<el-button class="rm-btn" :disabled="busy" @click="cleanupCandidates = null">
取消
</el-button><el-button class="rm-btn rm-btn-primary" :disabled="busy || !cleanupSelected.length" @click="clean">
移入回收站({{ cleanupSelected.length }})
</el-button>
</div>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.rm-media-toolbar :deep(.el-select) {
width: 130px;
}
.rm-media-upload :deep(.el-select) {
width: 240px;
max-width: 100%;
}
.rm-media-mobile-filters :deep(.el-select) {
width: 130px;
}
.rm-media-search.el-input {
padding: 0;
border: 0;
}
:deep(.el-checkbox) {
height: auto;
white-space: normal;
}
:deep(.el-checkbox__label) {
overflow-wrap: anywhere;
}
.rm-media-library {
display: flex;
flex-direction: column;
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: 0.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: 0.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>