-
-
-
- deletingKeys: Set
selectable?: boolean
showKey?: boolean
showStorage?: boolean
@@ -21,7 +20,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
'update:selectedKeys': [value: Set]
- '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)"
/>
+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
(null)
+const naturalHeight = ref(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
+}
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
-
+ {{ row.label }}
+
+ -
+
+
+ {{ row.value }}
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+ {{ t('image.previewLinks') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/stats.vue b/app/pages/gallery.vue
similarity index 85%
rename from app/pages/stats.vue
rename to app/pages/gallery.vue
index 454f8de..892a81c 100644
--- a/app/pages/stats.vue
+++ b/app/pages/gallery.vue
@@ -1,4 +1,6 @@
@@ -178,14 +194,20 @@ async function handleDelete(key: string) {
{}"
- @delete="handleDelete"
+ @preview="openPreview"
/>
+
+
diff --git a/app/types/image.ts b/app/types/image.ts
index da7dd12..19aba80 100644
--- a/app/types/image.ts
+++ b/app/types/image.ts
@@ -42,6 +42,8 @@ export interface ImageItem {
name: string
type: 'local' | 's3'
}
+ /** 上传来源:网页或 API(来自活动日志) */
+ uploadSource?: 'web' | 'api'
}
export interface UploadErrorItem {
diff --git a/app/utils/image-display.ts b/app/utils/image-display.ts
new file mode 100644
index 0000000..e7e45fc
--- /dev/null
+++ b/app/utils/image-display.ts
@@ -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`
+}
diff --git a/docs-site/en/guide/changelog.md b/docs-site/en/guide/changelog.md
index 218d6c3..fbcd59b 100644
--- a/docs-site/en/guide/changelog.md
+++ b/docs-site/en/guide/changelog.md
@@ -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
diff --git a/docs-site/guide/changelog.md b/docs-site/guide/changelog.md
index 2e24c57..3d84f14 100644
--- a/docs-site/guide/changelog.md
+++ b/docs-site/guide/changelog.md
@@ -4,6 +4,22 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
---
+## [1.2.5] — 2026-08-31
+
+### 新增
+
+- **图库页**:导航「统计」改为「图库」(`/gallery`);点击图片打开详情弹层(大图、尺寸、上传方式、存储路径、链接复制)
+- **列表操作**:全选 / 反选;单张删除移至详情弹层
+- **上传来源**:图库 API 返回网页 / API 上传方式(来自活动日志)
+
+### 改进
+
+- README 补充技术栈章节
+
+### 移除
+
+- `/stats` 路由(不再保留兼容跳转)
+
## [1.2.4] — 2026-08-31
### 修复
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index ccafef8..58072df 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -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}"
},
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index 92d4a5f..335a242 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -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}"
},
diff --git a/package-lock.json b/package-lock.json
index efb74bb..c70e23f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index 09e9075..6e648c4 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/server/api/images/index.get.ts b/server/api/images/index.get.ts
index 5a2d956..91de18d 100644
--- a/server/api/images/index.get.ts
+++ b/server/api/images/index.get.ts
@@ -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))
diff --git a/server/api/images/search.get.ts b/server/api/images/search.get.ts
index 8f1f5ba..6db617f 100644
--- a/server/api/images/search.get.ts
+++ b/server/api/images/search.get.ts
@@ -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))
diff --git a/server/utils/db.ts b/server/utils/db.ts
index 0b83779..128a3fe 100644
--- a/server/utils/db.ts
+++ b/server/utils/db.ts
@@ -445,6 +445,34 @@ export function insertActivityLog(input: ActivityLogInput): void {
}
}
+/** 按图片 key 查最近一次上传记录的来源(网页 / API) */
+export function getUploadSourcesForKeys(keys: string[]): Map
{
+ const result = new Map()
+ 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
diff --git a/server/utils/image-response.ts b/server/utils/image-response.ts
index de92c25..8502426 100644
--- a/server/utils/image-response.ts
+++ b/server/utils/image-response.ts
@@ -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
backendMap?: Map
+ sourceMap?: Map
}
): 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(
diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts
index cddbb67..494be77 100644
--- a/server/utils/process-image-upload.ts
+++ b/server/utils/process-image-upload.ts
@@ -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'
+ }
}
}