release: v1.2.5 — 图库页、图片详情弹层与列表体验优化
This commit is contained in:
1 parent
81dcc2c15e
commit
270af4e707
22 files changed
+587
-52
No files matched your search
@@ -4,6 +4,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.5] - 2026-08-31
|
||||
|
||||
### 新增
|
||||
|
||||
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
|
||||
- 图库列表支持全选、反选;单张删除移至详情弹层
|
||||
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
|
||||
|
||||
### 改进
|
||||
|
||||
- README 补充技术栈说明
|
||||
|
||||
### 移除
|
||||
|
||||
- `/stats` 路由(不再兼容跳转)
|
||||
|
||||
## [1.2.4] - 2026-08-31
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#tech-stack">Tech Stack</a> ·
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="README.md">简体中文</a>
|
||||
</p>
|
||||
@@ -47,6 +48,19 @@
|
||||
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
||||
- **Zero-config Docker** — first-run web wizard, no secrets upfront
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technologies |
|
||||
| ----- | ------------ |
|
||||
| Frontend | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
|
||||
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
|
||||
| Data | SQLite · local `data/pichost.db` |
|
||||
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) |
|
||||
| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) |
|
||||
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
|
||||
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
|
||||
| Deploy | Docker · Node.js 22 |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="https://o96u.github.io/PicHost/">文档</a> ·
|
||||
<a href="#界面预览">预览</a> ·
|
||||
<a href="#特性">特性</a> ·
|
||||
<a href="#技术栈">技术栈</a> ·
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="README.en.md">English</a>
|
||||
</p>
|
||||
@@ -47,6 +48,19 @@
|
||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
| ---- | ---- |
|
||||
| 前端 | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
|
||||
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
|
||||
| 数据 | SQLite · 本地 `data/pichost.db` |
|
||||
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
|
||||
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等) |
|
||||
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
|
||||
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
|
||||
| 部署 | Docker · Node.js 22 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
|
||||
@@ -13,7 +13,7 @@ const mobileUserOpen = ref(false)
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
||||
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
|
||||
{ label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' }
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { CopyFormat } from '~/composables/useUploadPreferences'
|
||||
const props = withDefaults(defineProps<{
|
||||
image: ImageItem
|
||||
selected: boolean
|
||||
deleting?: boolean
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
showStorage?: boolean
|
||||
@@ -19,7 +18,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: boolean]
|
||||
'delete': []
|
||||
'preview': []
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
@@ -118,14 +117,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
|
||||
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||
<div
|
||||
class="relative aspect-square shrink-0 overflow-hidden bg-muted"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="t('image.openPreview')"
|
||||
@click="emit('preview')"
|
||||
@keydown.enter="emit('preview')"
|
||||
@keydown.space.prevent="emit('preview')"
|
||||
>
|
||||
<img
|
||||
v-if="!imageError"
|
||||
:key="`${image.key}-${retryCount}`"
|
||||
:src="previewSrc"
|
||||
:alt="image.originalName"
|
||||
loading="lazy"
|
||||
class="absolute inset-0 size-full object-cover object-center"
|
||||
class="absolute inset-0 size-full cursor-zoom-in object-cover object-center"
|
||||
@error="onPreviewError"
|
||||
>
|
||||
<div
|
||||
@@ -137,26 +144,17 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
<div
|
||||
v-if="selectable"
|
||||
class="absolute top-2 left-2"
|
||||
@click.stop
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected"
|
||||
@update:model-value="toggleSelected"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute top-2 right-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
color="error"
|
||||
variant="solid"
|
||||
icon="i-lucide-trash-2"
|
||||
:aria-label="t('common.delete')"
|
||||
:loading="deleting"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="image.owner"
|
||||
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
|
||||
@click.stop
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
|
||||
@@ -173,6 +171,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
v-if="showStorage && image.storage"
|
||||
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
|
||||
:class="image.owner ? 'right-2' : 'left-2'"
|
||||
@click.stop
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ImageItem } from '~/types/image'
|
||||
const props = withDefaults(defineProps<{
|
||||
items: ImageItem[]
|
||||
selectedKeys: Set<string>
|
||||
deletingKeys: Set<string>
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
showStorage?: boolean
|
||||
@@ -21,7 +20,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'delete': [key: string]
|
||||
'preview': [image: ImageItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -47,13 +46,12 @@ function updateSelection(key: string, selected: boolean) {
|
||||
:key="image.key"
|
||||
:image="image"
|
||||
:selected="selectedKeys.has(image.key)"
|
||||
:deleting="deletingKeys.has(image.key)"
|
||||
:selectable="selectable"
|
||||
:show-key="showKey"
|
||||
:show-storage="showStorage"
|
||||
:compact="compact"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@delete="emit('delete', image.key)"
|
||||
@preview="emit('preview', image)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
import type { CopyFormat } from '~/composables/useUploadPreferences'
|
||||
import { formatImageDimensions, getFileExtension } from '~/utils/image-display'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
image: ImageItem | null
|
||||
showStorage?: boolean
|
||||
deleting?: boolean
|
||||
}>(), {
|
||||
showStorage: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'delete': []
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
const { copyFormat } = useUploadPreferences()
|
||||
|
||||
const naturalWidth = ref<number | null>(null)
|
||||
const naturalHeight = ref<number | null>(null)
|
||||
const dimensionsLoaded = ref(false)
|
||||
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
])
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
if (!props.image) return '—'
|
||||
try {
|
||||
return new Date(props.image.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return props.image.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
const previewValue = computed(() => {
|
||||
if (!props.image) return ''
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return props.image.markdown
|
||||
case 'html':
|
||||
return props.image.html
|
||||
default:
|
||||
return props.image.url
|
||||
}
|
||||
})
|
||||
|
||||
const copySuccessTitle = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return t('copy.copiedMarkdown')
|
||||
case 'html':
|
||||
return t('copy.copiedHtml')
|
||||
default:
|
||||
return t('copy.copiedUrl')
|
||||
}
|
||||
})
|
||||
|
||||
const storageIcon = computed(() =>
|
||||
props.image?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
)
|
||||
|
||||
const uploadSourceLabel = computed(() => {
|
||||
if (!props.image?.uploadSource) return t('image.uploadSourceUnknown')
|
||||
return props.image.uploadSource === 'api'
|
||||
? t('stats.sourceApi')
|
||||
: t('stats.sourceWeb')
|
||||
})
|
||||
|
||||
const dimensionsLabel = computed(() => {
|
||||
const formatted = formatImageDimensions(naturalWidth.value, naturalHeight.value)
|
||||
if (formatted) return formatted
|
||||
return dimensionsLoaded.value ? '—' : t('image.previewDimensionsUnknown')
|
||||
})
|
||||
|
||||
const detailRows = computed(() => {
|
||||
if (!props.image) return []
|
||||
|
||||
const image = props.image
|
||||
const rows: Array<{ label: string, value: string, mono?: boolean, storage?: boolean }> = [
|
||||
{ label: t('image.previewOriginalName'), value: image.originalName },
|
||||
{ label: t('image.previewExtension'), value: getFileExtension(image.originalName) },
|
||||
{ label: t('image.previewUploadedAt'), value: uploadedLabel.value },
|
||||
{ label: t('image.previewSize'), value: formatFileSize(image.size) },
|
||||
{ label: t('image.previewContentType'), value: image.contentType },
|
||||
{ label: t('image.previewDimensions'), value: dimensionsLabel.value },
|
||||
{ label: t('image.previewUploadSource'), value: uploadSourceLabel.value }
|
||||
]
|
||||
|
||||
if (image.owner) {
|
||||
rows.push({ label: t('image.previewOwner'), value: image.owner.username })
|
||||
}
|
||||
|
||||
if (props.showStorage && image.storage) {
|
||||
rows.push({ label: t('image.previewStorage'), value: image.storage.name, storage: true })
|
||||
}
|
||||
|
||||
rows.push({ label: t('image.previewKey'), value: image.key, mono: true })
|
||||
|
||||
return rows
|
||||
})
|
||||
|
||||
watch(() => props.image?.key, () => {
|
||||
naturalWidth.value = null
|
||||
naturalHeight.value = null
|
||||
dimensionsLoaded.value = false
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function selectCopyFormat(value: CopyFormat) {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (!props.image?.url) return
|
||||
window.open(props.image.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function requestDelete() {
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
function onImageLoad(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
naturalWidth.value = img.naturalWidth
|
||||
naturalHeight.value = img.naturalHeight
|
||||
dimensionsLoaded.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open && !!image"
|
||||
:title="t('image.previewTitle')"
|
||||
:ui="{ content: 'max-w-5xl' }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template
|
||||
v-if="image"
|
||||
#body
|
||||
>
|
||||
<div class="grid gap-5 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] md:items-stretch">
|
||||
<div class="flex min-h-[14rem] items-center justify-center overflow-hidden rounded-xl border border-default bg-muted/15 p-3 md:min-h-[22rem]">
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.originalName"
|
||||
class="max-h-[min(70vh,32rem)] w-full object-contain"
|
||||
@load="onImageLoad"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-col gap-4">
|
||||
<dl class="grid gap-2.5 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="row in detailRows"
|
||||
:key="row.label"
|
||||
class="min-w-0 rounded-lg border border-default/60 bg-muted/10 px-3 py-2"
|
||||
:class="row.mono ? 'sm:col-span-2' : ''"
|
||||
>
|
||||
<dt class="text-xs text-muted">
|
||||
{{ row.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-0.5 break-all text-sm"
|
||||
:class="row.mono ? 'font-mono text-xs text-dimmed' : 'font-medium'"
|
||||
>
|
||||
<span
|
||||
v-if="row.storage && image.storage"
|
||||
class="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{ row.value }}
|
||||
</span>
|
||||
<template v-else>
|
||||
{{ row.value }}
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-auto space-y-2 rounded-xl border border-default/70 bg-muted/10 p-3">
|
||||
<p class="text-xs font-medium text-muted">
|
||||
{{ t('image.previewLinks') }}
|
||||
</p>
|
||||
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="selectCopyFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="sm"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
:label="t('common.copy')"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-default pt-3">
|
||||
<UButton
|
||||
:label="t('image.previewOpen')"
|
||||
icon="i-lucide-external-link"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="openInNewTab"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="deleting"
|
||||
@click="requestDelete"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('image.previewClose')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
|
||||
interface StatsResponse {
|
||||
uploadToday: number
|
||||
uploadMonth: number
|
||||
@@ -53,6 +55,8 @@ const deleteTargetKeys = ref<string[]>([])
|
||||
const batchDeleting = ref(false)
|
||||
const showScrollTop = ref(false)
|
||||
const currentStorage = ref('all')
|
||||
const previewOpen = ref(false)
|
||||
const previewImage = ref<ImageItem | null>(null)
|
||||
|
||||
const storageSelectItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
@@ -203,6 +207,8 @@ async function clearSearch() {
|
||||
|
||||
function requestDelete(key: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
previewOpen.value = false
|
||||
previewImage.value = null
|
||||
deleteTargetKeys.value = [key]
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
@@ -217,6 +223,23 @@ function requestBatchDelete() {
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function selectAllOnPage() {
|
||||
selectedKeys.value = new Set(items.value.map(item => item.key))
|
||||
}
|
||||
|
||||
function invertSelectionOnPage() {
|
||||
const next = new Set(selectedKeys.value)
|
||||
for (const item of items.value) {
|
||||
if (next.has(item.key)) next.delete(item.key)
|
||||
else next.add(item.key)
|
||||
}
|
||||
selectedKeys.value = next
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedKeys.value = new Set()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const keys = [...deleteTargetKeys.value]
|
||||
if (!keys.length || !isAuthenticated.value) return
|
||||
@@ -279,6 +302,8 @@ async function confirmDelete() {
|
||||
|
||||
async function handleGalleryPageChange(targetPage: number) {
|
||||
selectedKeys.value = new Set()
|
||||
previewOpen.value = false
|
||||
previewImage.value = null
|
||||
try {
|
||||
await goToPage(targetPage)
|
||||
} catch (error: unknown) {
|
||||
@@ -286,6 +311,16 @@ async function handleGalleryPageChange(targetPage: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(image: ImageItem) {
|
||||
previewImage.value = image
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
function requestPreviewDelete() {
|
||||
if (!previewImage.value) return
|
||||
requestDelete(previewImage.value.key)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('scroll', onWindowScroll, { passive: true })
|
||||
const status = await fetchStatus()
|
||||
@@ -422,7 +457,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-medium">
|
||||
{{ t('stats.gallery') }}
|
||||
{{ t('stats.imagesSection') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!galleryLoading"
|
||||
@@ -483,6 +518,45 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-check-check"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:disabled="!items.length || galleryLoading"
|
||||
:aria-label="t('stats.selectAll')"
|
||||
@click="selectAllOnPage"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.selectAll') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-shuffle"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:disabled="!items.length || galleryLoading"
|
||||
:aria-label="t('stats.invertSelection')"
|
||||
@click="invertSelectionOnPage"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.invertSelection') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="selectedCount"
|
||||
type="button"
|
||||
icon="i-lucide-x-circle"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:aria-label="t('stats.clearSelection')"
|
||||
@click="clearSelection"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.clearSelection') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-trash-2"
|
||||
@@ -516,9 +590,16 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
show-storage
|
||||
:items="items"
|
||||
:selected-keys="selectedKeys"
|
||||
:deleting-keys="deletingKeys"
|
||||
@update:selected-keys="selectedKeys = $event"
|
||||
@delete="requestDelete"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
|
||||
<ImagePreviewModal
|
||||
v-model:open="previewOpen"
|
||||
:image="previewImage"
|
||||
show-storage
|
||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||
@delete="requestPreviewDelete"
|
||||
/>
|
||||
|
||||
<PaginationBar
|
||||
+24
-2
@@ -15,6 +15,8 @@ const { t } = useI18n()
|
||||
const sessionItems = ref<ImageItem[]>([])
|
||||
const deletingKeys = ref<Set<string>>(new Set())
|
||||
const emptySelection = ref(new Set<string>())
|
||||
const previewOpen = ref(false)
|
||||
const previewImage = ref<ImageItem | null>(null)
|
||||
|
||||
const showProgress = computed(
|
||||
() =>
|
||||
@@ -123,6 +125,10 @@ async function handleDelete(key: string) {
|
||||
query: { key }
|
||||
})
|
||||
sessionItems.value = sessionItems.value.filter(item => item.key !== key)
|
||||
if (previewImage.value?.key === key) {
|
||||
previewOpen.value = false
|
||||
previewImage.value = null
|
||||
}
|
||||
toast.add({ title: t('stats.deletedSingle'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
@@ -132,6 +138,16 @@ async function handleDelete(key: string) {
|
||||
deletingKeys.value = new Set(deletingKeys.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(image: ImageItem) {
|
||||
previewImage.value = image
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
function requestPreviewDelete() {
|
||||
if (!previewImage.value) return
|
||||
void handleDelete(previewImage.value.key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -178,14 +194,20 @@ async function handleDelete(key: string) {
|
||||
<ImageGrid
|
||||
:items="sessionItems"
|
||||
:selected-keys="emptySelection"
|
||||
:deleting-keys="deletingKeys"
|
||||
:selectable="false"
|
||||
:show-key="false"
|
||||
:empty-text="t('upload.sessionEmpty')"
|
||||
@update:selected-keys="() => {}"
|
||||
@delete="handleDelete"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ImagePreviewModal
|
||||
v-model:open="previewOpen"
|
||||
:image="previewImage"
|
||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||
@delete="requestPreviewDelete"
|
||||
/>
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,6 +42,8 @@ export interface ImageItem {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
}
|
||||
/** 上传来源:网页或 API(来自活动日志) */
|
||||
uploadSource?: 'web' | 'api'
|
||||
}
|
||||
|
||||
export interface UploadErrorItem {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function getFileExtension(filename: string): string {
|
||||
const base = filename.split(/[/\\]/).pop() ?? filename
|
||||
const dot = base.lastIndexOf('.')
|
||||
if (dot <= 0 || dot === base.length - 1) return '—'
|
||||
return base.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function getUrlPathname(url: string): string {
|
||||
try {
|
||||
return decodeURIComponent(new URL(url).pathname.replace(/^\/+/, ''))
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function getStorageKeyTail(key: string): string {
|
||||
const prefix = 'images/'
|
||||
return key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
}
|
||||
|
||||
export function formatImageDimensions(width: number | null, height: number | null): string | null {
|
||||
if (!width || !height) return null
|
||||
return `${width} × ${height} px`
|
||||
}
|
||||
@@ -4,6 +4,22 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
||||
|
||||
---
|
||||
|
||||
## [1.2.5] — 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- **Gallery page**: nav “Stats” renamed to “Gallery” (`/gallery`); click a thumbnail for a detail modal (full image, dimensions, upload source, storage path, link copy)
|
||||
- **List actions**: select all / invert selection; single-image delete moved into the detail modal
|
||||
- **Upload source**: gallery API returns web / API upload source (from activity logs)
|
||||
|
||||
### Improved
|
||||
|
||||
- README tech stack section
|
||||
|
||||
### Removed
|
||||
|
||||
- `/stats` route (no compatibility redirect)
|
||||
|
||||
## [1.2.4] — 2026-08-31
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -4,6 +4,22 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
||||
|
||||
---
|
||||
|
||||
## [1.2.5] — 2026-08-31
|
||||
|
||||
### 新增
|
||||
|
||||
- **图库页**:导航「统计」改为「图库」(`/gallery`);点击图片打开详情弹层(大图、尺寸、上传方式、存储路径、链接复制)
|
||||
- **列表操作**:全选 / 反选;单张删除移至详情弹层
|
||||
- **上传来源**:图库 API 返回网页 / API 上传方式(来自活动日志)
|
||||
|
||||
### 改进
|
||||
|
||||
- README 补充技术栈章节
|
||||
|
||||
### 移除
|
||||
|
||||
- `/stats` 路由(不再保留兼容跳转)
|
||||
|
||||
## [1.2.4] — 2026-08-31
|
||||
|
||||
### 修复
|
||||
|
||||
+24
-2
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"api": "API",
|
||||
"gallery": "Gallery",
|
||||
"stats": "Stats",
|
||||
"logs": "Activity log",
|
||||
"settings": "Settings",
|
||||
@@ -307,8 +308,8 @@
|
||||
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"subtitle": "Overview and gallery",
|
||||
"title": "Gallery",
|
||||
"subtitle": "Browse, search, and manage uploaded images",
|
||||
"uploadToday": "Uploaded today",
|
||||
"uploadMonth": "Uploaded this month",
|
||||
"uploadTotal": "Total uploads",
|
||||
@@ -323,12 +324,16 @@
|
||||
"sourceApi": "API",
|
||||
"noData": "No data",
|
||||
"gallery": "Gallery",
|
||||
"imagesSection": "All images",
|
||||
"filterStorageAll": "All storage",
|
||||
"storage": "Storage",
|
||||
"searchPlaceholder": "Search filename",
|
||||
"clear": "Clear",
|
||||
"selected": "{n} selected",
|
||||
"batchDelete": "Delete selected",
|
||||
"selectAll": "Select all",
|
||||
"invertSelection": "Invert",
|
||||
"clearSelection": "Clear selection",
|
||||
"scrollTop": "Back to top",
|
||||
"loadFailed": "Failed to load statistics",
|
||||
"selectFirst": "Select images first",
|
||||
@@ -371,6 +376,23 @@
|
||||
"image": {
|
||||
"empty": "No images",
|
||||
"previewFailed": "Preview failed",
|
||||
"openPreview": "View full image",
|
||||
"previewTitle": "Image details",
|
||||
"previewOriginalName": "Filename",
|
||||
"previewUploadedAt": "Uploaded",
|
||||
"previewSize": "Size",
|
||||
"previewContentType": "Type",
|
||||
"previewOwner": "Uploader",
|
||||
"previewStorage": "Storage",
|
||||
"previewKey": "Storage path",
|
||||
"previewExtension": "Extension",
|
||||
"previewDimensions": "Dimensions",
|
||||
"previewDimensionsUnknown": "Loading…",
|
||||
"previewUploadSource": "Upload source",
|
||||
"uploadSourceUnknown": "Unknown",
|
||||
"previewLinks": "Links",
|
||||
"previewOpen": "Open in new tab",
|
||||
"previewClose": "Close",
|
||||
"uploader": "Uploader: {name}",
|
||||
"storageBackend": "Storage: {name}"
|
||||
},
|
||||
|
||||
+24
-2
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"api": "API",
|
||||
"gallery": "图库",
|
||||
"stats": "统计",
|
||||
"logs": "操作日志",
|
||||
"settings": "设置",
|
||||
@@ -307,8 +308,8 @@
|
||||
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
|
||||
},
|
||||
"stats": {
|
||||
"title": "统计",
|
||||
"subtitle": "数据概览与图库记录",
|
||||
"title": "图库",
|
||||
"subtitle": "浏览、搜索与管理已上传图片",
|
||||
"uploadToday": "今日上传",
|
||||
"uploadMonth": "本月上传",
|
||||
"uploadTotal": "累计上传",
|
||||
@@ -323,12 +324,16 @@
|
||||
"sourceApi": "API",
|
||||
"noData": "暂无数据",
|
||||
"gallery": "图库记录",
|
||||
"imagesSection": "全部图片",
|
||||
"filterStorageAll": "全部存储",
|
||||
"storage": "存储",
|
||||
"searchPlaceholder": "搜索文件名",
|
||||
"clear": "清除",
|
||||
"selected": "已选 {n}",
|
||||
"batchDelete": "批量删除",
|
||||
"selectAll": "全选",
|
||||
"invertSelection": "反选",
|
||||
"clearSelection": "取消选择",
|
||||
"scrollTop": "回到顶部",
|
||||
"loadFailed": "加载统计失败",
|
||||
"selectFirst": "请先选择图片",
|
||||
@@ -371,6 +376,23 @@
|
||||
"image": {
|
||||
"empty": "暂无图片",
|
||||
"previewFailed": "预览失败",
|
||||
"openPreview": "查看大图",
|
||||
"previewTitle": "图片详情",
|
||||
"previewOriginalName": "文件名",
|
||||
"previewUploadedAt": "上传时间",
|
||||
"previewSize": "大小",
|
||||
"previewContentType": "类型",
|
||||
"previewOwner": "上传者",
|
||||
"previewStorage": "存储",
|
||||
"previewKey": "存储路径",
|
||||
"previewExtension": "扩展名",
|
||||
"previewDimensions": "尺寸",
|
||||
"previewDimensionsUnknown": "加载中…",
|
||||
"previewUploadSource": "上传方式",
|
||||
"uploadSourceUnknown": "未知",
|
||||
"previewLinks": "链接",
|
||||
"previewOpen": "新窗口打开",
|
||||
"previewClose": "关闭",
|
||||
"uploader": "上传者:{name}",
|
||||
"storageBackend": "存储:{name}"
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pichost",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
MAX_LIST_LIMIT
|
||||
} from '../../utils/constants'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { listUserIdUsernameMap } from '../../utils/db'
|
||||
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
@@ -38,8 +38,9 @@ export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
const backendMap = listStorageBackendNameMap()
|
||||
const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key))
|
||||
const items = listing.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
)
|
||||
|
||||
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
MAX_LIST_LIMIT
|
||||
} from '../../utils/constants'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { listUserIdUsernameMap } from '../../utils/db'
|
||||
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
@@ -47,8 +47,9 @@ export default defineEventHandler(async (event) => {
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
const backendMap = listStorageBackendNameMap()
|
||||
const sourceMap = getUploadSourcesForKeys(result.items.map(item => item.key))
|
||||
const items = result.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
)
|
||||
|
||||
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
|
||||
@@ -445,6 +445,34 @@ export function insertActivityLog(input: ActivityLogInput): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按图片 key 查最近一次上传记录的来源(网页 / API) */
|
||||
export function getUploadSourcesForKeys(keys: string[]): Map<string, LogSource> {
|
||||
const result = new Map<string, LogSource>()
|
||||
if (!keys.length) return result
|
||||
|
||||
const placeholders = keys.map(() => '?').join(', ')
|
||||
const rows = getDb().prepare(`
|
||||
SELECT al.key, al.source
|
||||
FROM activity_logs al
|
||||
INNER JOIN (
|
||||
SELECT key, MAX(id) AS max_id
|
||||
FROM activity_logs
|
||||
WHERE action = 'upload' AND key IN (${placeholders})
|
||||
GROUP BY key
|
||||
) latest ON al.id = latest.max_id
|
||||
`).all(...keys) as Array<{ key: string, source: string }>
|
||||
|
||||
for (const row of rows) {
|
||||
const raw = row.source
|
||||
const source = raw === 'twikoo' ? 'api' : raw
|
||||
if (source === 'web' || source === 'api') {
|
||||
result.set(row.key, source)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function listActivityLogs(options: {
|
||||
limit: number
|
||||
page?: number
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import type { ImageItem } from '~/types/image'
|
||||
import type { LogSource } from './db'
|
||||
import type { AllowedMimeType } from './constants'
|
||||
import type { StoredImage } from './storage'
|
||||
import { getImageBaseUrl, isHideFolderInUrl } from './env'
|
||||
@@ -50,6 +51,7 @@ export function mapStoredImageToItem(
|
||||
options?: {
|
||||
ownerMap?: Map<number, string>
|
||||
backendMap?: Map<string, { name: string, type: StorageBackendType }>
|
||||
sourceMap?: Map<string, LogSource>
|
||||
}
|
||||
): ImageItem {
|
||||
const item = buildImageItem({
|
||||
@@ -64,16 +66,14 @@ export function mapStoredImageToItem(
|
||||
|
||||
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
|
||||
const storage = resolveImageStorage(stored.backendId, options?.backendMap)
|
||||
const uploadSource = options?.sourceMap?.get(stored.key)
|
||||
|
||||
if (owner || storage) {
|
||||
return {
|
||||
...item,
|
||||
...(owner ? { owner } : {}),
|
||||
...(storage ? { storage } : {})
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
...(owner ? { owner } : {}),
|
||||
...(storage ? { storage } : {}),
|
||||
...(uploadSource ? { uploadSource } : {})
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
export function buildImageUrl(
|
||||
|
||||
@@ -169,14 +169,17 @@ export async function processSingleImageUpload(
|
||||
})
|
||||
|
||||
return {
|
||||
item: buildImageItem({
|
||||
key,
|
||||
event,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
uploadedAt
|
||||
})
|
||||
item: {
|
||||
...buildImageItem({
|
||||
key,
|
||||
event,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
uploadedAt
|
||||
}),
|
||||
uploadSource: input.source ?? 'web'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user