release: v1.2.5 — 图库页、图片详情弹层与列表体验优化

This commit is contained in:
O96u committed 2026-08-31 16:05:53 +08:00
1 parent 81dcc2c15e
commit 270af4e707
22 files changed
+587 -52

No files matched your search

+16
View File
@@ -4,6 +4,22 @@
## [Unreleased]
## [1.2.5] - 2026-08-31
### 新增
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
- 图库列表支持全选、反选;单张删除移至详情弹层
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
### 改进
- README 补充技术栈说明
### 移除
- `/stats` 路由(不再兼容跳转)
## [1.2.4] - 2026-08-31
### 修复
+14
View File
@@ -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
+14
View File
@@ -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
+1 -1
View File
@@ -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' })
+14 -15
View File
@@ -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"
+2 -4
View File
@@ -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
+256
View File
@@ -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>
+84 -3
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -42,6 +42,8 @@ export interface ImageItem {
name: string
type: 'local' | 's3'
}
/** 上传来源:网页或 API(来自活动日志) */
uploadSource?: 'web' | 'api'
}
export interface UploadErrorItem {
+24
View File
@@ -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`
}
+16
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
},
+2 -2
View File
@@ -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
View File
@@ -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": {
+3 -2
View File
@@ -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))
+3 -2
View File
@@ -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))
+28
View File
@@ -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
+8 -8
View File
@@ -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(
+11 -8
View File
@@ -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'
}
}
}