Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270af4e707 | ||
|
|
81dcc2c15e |
No files matched your search
@@ -4,6 +4,28 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.5] - 2026-08-31
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 图库页(`/gallery`)替代原「统计」导航;图片点击放大详情弹层(尺寸、上传方式、存储路径、链接复制)
|
||||||
|
- 图库列表支持全选、反选;单张删除移至详情弹层
|
||||||
|
- 图片列表 API 返回上传来源(网页 / API,来自活动日志)
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- README 补充技术栈说明
|
||||||
|
|
||||||
|
### 移除
|
||||||
|
|
||||||
|
- `/stats` 路由(不再兼容跳转)
|
||||||
|
|
||||||
|
## [1.2.4] - 2026-08-31
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
|
||||||
|
|
||||||
## [1.2.3] - 2026-08-30
|
## [1.2.3] - 2026-08-30
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
|
<a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
|
||||||
<a href="#screenshots">Screenshots</a> ·
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
<a href="#features">Features</a> ·
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#tech-stack">Tech Stack</a> ·
|
||||||
<a href="#quick-start">Quick Start</a> ·
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
<a href="README.md">简体中文</a>
|
<a href="README.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -47,6 +48,19 @@
|
|||||||
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
||||||
- **Zero-config Docker** — first-run web wizard, no secrets upfront
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="https://o96u.github.io/PicHost/">文档</a> ·
|
<a href="https://o96u.github.io/PicHost/">文档</a> ·
|
||||||
<a href="#界面预览">预览</a> ·
|
<a href="#界面预览">预览</a> ·
|
||||||
<a href="#特性">特性</a> ·
|
<a href="#特性">特性</a> ·
|
||||||
|
<a href="#技术栈">技术栈</a> ·
|
||||||
<a href="#快速开始">快速开始</a> ·
|
<a href="#快速开始">快速开始</a> ·
|
||||||
<a href="README.en.md">English</a>
|
<a href="README.en.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -47,6 +48,19 @@
|
|||||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||||
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
- **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
|
```bash
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const mobileUserOpen = ref(false)
|
|||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
{ 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) {
|
if (isAdmin.value) {
|
||||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
image: ImageItem
|
image: ImageItem
|
||||||
selected: boolean
|
selected: boolean
|
||||||
deleting?: boolean
|
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
showKey?: boolean
|
showKey?: boolean
|
||||||
showStorage?: boolean
|
showStorage?: boolean
|
||||||
@@ -19,7 +18,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selected': [value: boolean]
|
'update:selected': [value: boolean]
|
||||||
'delete': []
|
'preview': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
@@ -118,14 +117,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
|
<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
|
<img
|
||||||
v-if="!imageError"
|
v-if="!imageError"
|
||||||
:key="`${image.key}-${retryCount}`"
|
:key="`${image.key}-${retryCount}`"
|
||||||
:src="previewSrc"
|
:src="previewSrc"
|
||||||
:alt="image.originalName"
|
:alt="image.originalName"
|
||||||
loading="lazy"
|
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"
|
@error="onPreviewError"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -137,26 +144,17 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
|||||||
<div
|
<div
|
||||||
v-if="selectable"
|
v-if="selectable"
|
||||||
class="absolute top-2 left-2"
|
class="absolute top-2 left-2"
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
:model-value="selected"
|
:model-value="selected"
|
||||||
@update:model-value="toggleSelected"
|
@update:model-value="toggleSelected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="image.owner"
|
v-if="image.owner"
|
||||||
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
|
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
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"
|
v-if="showStorage && image.storage"
|
||||||
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
|
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
|
||||||
:class="image.owner ? 'right-2' : 'left-2'"
|
:class="image.owner ? 'right-2' : 'left-2'"
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
items: ImageItem[]
|
items: ImageItem[]
|
||||||
selectedKeys: Set<string>
|
selectedKeys: Set<string>
|
||||||
deletingKeys: Set<string>
|
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
showKey?: boolean
|
showKey?: boolean
|
||||||
showStorage?: boolean
|
showStorage?: boolean
|
||||||
@@ -21,7 +20,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedKeys': [value: Set<string>]
|
'update:selectedKeys': [value: Set<string>]
|
||||||
'delete': [key: string]
|
'preview': [image: ImageItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -47,13 +46,12 @@ function updateSelection(key: string, selected: boolean) {
|
|||||||
:key="image.key"
|
:key="image.key"
|
||||||
:image="image"
|
:image="image"
|
||||||
:selected="selectedKeys.has(image.key)"
|
:selected="selectedKeys.has(image.key)"
|
||||||
:deleting="deletingKeys.has(image.key)"
|
|
||||||
:selectable="selectable"
|
:selectable="selectable"
|
||||||
:show-key="showKey"
|
:show-key="showKey"
|
||||||
:show-storage="showStorage"
|
:show-storage="showStorage"
|
||||||
:compact="compact"
|
:compact="compact"
|
||||||
@update:selected="updateSelection(image.key, $event)"
|
@update:selected="updateSelection(image.key, $event)"
|
||||||
@delete="emit('delete', image.key)"
|
@preview="emit('preview', image)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<script setup lang="ts">
|
||||||
|
import type { ImageItem } from '~/types/image'
|
||||||
|
|
||||||
interface StatsResponse {
|
interface StatsResponse {
|
||||||
uploadToday: number
|
uploadToday: number
|
||||||
uploadMonth: number
|
uploadMonth: number
|
||||||
@@ -53,6 +55,8 @@ const deleteTargetKeys = ref<string[]>([])
|
|||||||
const batchDeleting = ref(false)
|
const batchDeleting = ref(false)
|
||||||
const showScrollTop = ref(false)
|
const showScrollTop = ref(false)
|
||||||
const currentStorage = ref('all')
|
const currentStorage = ref('all')
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewImage = ref<ImageItem | null>(null)
|
||||||
|
|
||||||
const storageSelectItems = computed(() => [
|
const storageSelectItems = computed(() => [
|
||||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||||
@@ -203,6 +207,8 @@ async function clearSearch() {
|
|||||||
|
|
||||||
function requestDelete(key: string) {
|
function requestDelete(key: string) {
|
||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return
|
||||||
|
previewOpen.value = false
|
||||||
|
previewImage.value = null
|
||||||
deleteTargetKeys.value = [key]
|
deleteTargetKeys.value = [key]
|
||||||
deleteModalOpen.value = true
|
deleteModalOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,23 @@ function requestBatchDelete() {
|
|||||||
deleteModalOpen.value = true
|
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() {
|
async function confirmDelete() {
|
||||||
const keys = [...deleteTargetKeys.value]
|
const keys = [...deleteTargetKeys.value]
|
||||||
if (!keys.length || !isAuthenticated.value) return
|
if (!keys.length || !isAuthenticated.value) return
|
||||||
@@ -279,6 +302,8 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
async function handleGalleryPageChange(targetPage: number) {
|
async function handleGalleryPageChange(targetPage: number) {
|
||||||
selectedKeys.value = new Set()
|
selectedKeys.value = new Set()
|
||||||
|
previewOpen.value = false
|
||||||
|
previewImage.value = null
|
||||||
try {
|
try {
|
||||||
await goToPage(targetPage)
|
await goToPage(targetPage)
|
||||||
} catch (error: unknown) {
|
} 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 () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('scroll', onWindowScroll, { passive: true })
|
window.addEventListener('scroll', onWindowScroll, { passive: true })
|
||||||
const status = await fetchStatus()
|
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="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-lg font-medium">
|
<h2 class="text-lg font-medium">
|
||||||
{{ t('stats.gallery') }}
|
{{ t('stats.imagesSection') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
v-if="!galleryLoading"
|
v-if="!galleryLoading"
|
||||||
@@ -483,6 +518,45 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
>
|
>
|
||||||
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
|
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
|
||||||
</UButton>
|
</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
|
<UButton
|
||||||
type="button"
|
type="button"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
@@ -516,9 +590,16 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
show-storage
|
show-storage
|
||||||
:items="items"
|
:items="items"
|
||||||
:selected-keys="selectedKeys"
|
:selected-keys="selectedKeys"
|
||||||
:deleting-keys="deletingKeys"
|
|
||||||
@update:selected-keys="selectedKeys = $event"
|
@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
|
<PaginationBar
|
||||||
+24
-2
@@ -15,6 +15,8 @@ const { t } = useI18n()
|
|||||||
const sessionItems = ref<ImageItem[]>([])
|
const sessionItems = ref<ImageItem[]>([])
|
||||||
const deletingKeys = ref<Set<string>>(new Set())
|
const deletingKeys = ref<Set<string>>(new Set())
|
||||||
const emptySelection = ref(new Set<string>())
|
const emptySelection = ref(new Set<string>())
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewImage = ref<ImageItem | null>(null)
|
||||||
|
|
||||||
const showProgress = computed(
|
const showProgress = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -123,6 +125,10 @@ async function handleDelete(key: string) {
|
|||||||
query: { key }
|
query: { key }
|
||||||
})
|
})
|
||||||
sessionItems.value = sessionItems.value.filter(item => item.key !== 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' })
|
toast.add({ title: t('stats.deletedSingle'), color: 'success' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleAuthError(error)
|
handleAuthError(error)
|
||||||
@@ -132,6 +138,16 @@ async function handleDelete(key: string) {
|
|||||||
deletingKeys.value = new Set(deletingKeys.value)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -178,14 +194,20 @@ async function handleDelete(key: string) {
|
|||||||
<ImageGrid
|
<ImageGrid
|
||||||
:items="sessionItems"
|
:items="sessionItems"
|
||||||
:selected-keys="emptySelection"
|
:selected-keys="emptySelection"
|
||||||
:deleting-keys="deletingKeys"
|
|
||||||
:selectable="false"
|
:selectable="false"
|
||||||
:show-key="false"
|
:show-key="false"
|
||||||
:empty-text="t('upload.sessionEmpty')"
|
:empty-text="t('upload.sessionEmpty')"
|
||||||
@update:selected-keys="() => {}"
|
@update:selected-keys="() => {}"
|
||||||
@delete="handleDelete"
|
@preview="openPreview"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ImagePreviewModal
|
||||||
|
v-model:open="previewOpen"
|
||||||
|
:image="previewImage"
|
||||||
|
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||||
|
@delete="requestPreviewDelete"
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -42,6 +42,8 @@ export interface ImageItem {
|
|||||||
name: string
|
name: string
|
||||||
type: 'local' | 's3'
|
type: 'local' | 's3'
|
||||||
}
|
}
|
||||||
|
/** 上传来源:网页或 API(来自活动日志) */
|
||||||
|
uploadSource?: 'web' | 'api'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadErrorItem {
|
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,28 @@ 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
|
||||||
|
|
||||||
|
- **Empty referer whitelist still blocked hotlinks**: with no Referer whitelist configured, direct browser access worked but embedded images on blogs/forums returned 403; protection now runs only when a whitelist is configured
|
||||||
|
|
||||||
## [1.2.3] — 2026-08-30
|
## [1.2.3] — 2026-08-30
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.5] — 2026-08-31
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **图库页**:导航「统计」改为「图库」(`/gallery`);点击图片打开详情弹层(大图、尺寸、上传方式、存储路径、链接复制)
|
||||||
|
- **列表操作**:全选 / 反选;单张删除移至详情弹层
|
||||||
|
- **上传来源**:图库 API 返回网页 / API 上传方式(来自活动日志)
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- README 补充技术栈章节
|
||||||
|
|
||||||
|
### 移除
|
||||||
|
|
||||||
|
- `/stats` 路由(不再保留兼容跳转)
|
||||||
|
|
||||||
|
## [1.2.4] — 2026-08-31
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **访问控制留空仍拦截外链**:未配置 Referer 白名单时,浏览器直开图片正常,但博客/论坛嵌入会因 Referer 校验返回 403 图裂;现仅在配置了白名单时才启用防盗链
|
||||||
|
|
||||||
## [1.2.3] — 2026-08-30
|
## [1.2.3] — 2026-08-30
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+24
-2
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
|
"gallery": "Gallery",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"logs": "Activity log",
|
"logs": "Activity log",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -307,8 +308,8 @@
|
|||||||
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
|
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Statistics",
|
"title": "Gallery",
|
||||||
"subtitle": "Overview and gallery",
|
"subtitle": "Browse, search, and manage uploaded images",
|
||||||
"uploadToday": "Uploaded today",
|
"uploadToday": "Uploaded today",
|
||||||
"uploadMonth": "Uploaded this month",
|
"uploadMonth": "Uploaded this month",
|
||||||
"uploadTotal": "Total uploads",
|
"uploadTotal": "Total uploads",
|
||||||
@@ -323,12 +324,16 @@
|
|||||||
"sourceApi": "API",
|
"sourceApi": "API",
|
||||||
"noData": "No data",
|
"noData": "No data",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
|
"imagesSection": "All images",
|
||||||
"filterStorageAll": "All storage",
|
"filterStorageAll": "All storage",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"searchPlaceholder": "Search filename",
|
"searchPlaceholder": "Search filename",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"selected": "{n} selected",
|
"selected": "{n} selected",
|
||||||
"batchDelete": "Delete selected",
|
"batchDelete": "Delete selected",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"invertSelection": "Invert",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
"scrollTop": "Back to top",
|
"scrollTop": "Back to top",
|
||||||
"loadFailed": "Failed to load statistics",
|
"loadFailed": "Failed to load statistics",
|
||||||
"selectFirst": "Select images first",
|
"selectFirst": "Select images first",
|
||||||
@@ -371,6 +376,23 @@
|
|||||||
"image": {
|
"image": {
|
||||||
"empty": "No images",
|
"empty": "No images",
|
||||||
"previewFailed": "Preview failed",
|
"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}",
|
"uploader": "Uploader: {name}",
|
||||||
"storageBackend": "Storage: {name}"
|
"storageBackend": "Storage: {name}"
|
||||||
},
|
},
|
||||||
|
|||||||
+24
-2
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
|
"gallery": "图库",
|
||||||
"stats": "统计",
|
"stats": "统计",
|
||||||
"logs": "操作日志",
|
"logs": "操作日志",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
@@ -307,8 +308,8 @@
|
|||||||
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
|
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "统计",
|
"title": "图库",
|
||||||
"subtitle": "数据概览与图库记录",
|
"subtitle": "浏览、搜索与管理已上传图片",
|
||||||
"uploadToday": "今日上传",
|
"uploadToday": "今日上传",
|
||||||
"uploadMonth": "本月上传",
|
"uploadMonth": "本月上传",
|
||||||
"uploadTotal": "累计上传",
|
"uploadTotal": "累计上传",
|
||||||
@@ -323,12 +324,16 @@
|
|||||||
"sourceApi": "API",
|
"sourceApi": "API",
|
||||||
"noData": "暂无数据",
|
"noData": "暂无数据",
|
||||||
"gallery": "图库记录",
|
"gallery": "图库记录",
|
||||||
|
"imagesSection": "全部图片",
|
||||||
"filterStorageAll": "全部存储",
|
"filterStorageAll": "全部存储",
|
||||||
"storage": "存储",
|
"storage": "存储",
|
||||||
"searchPlaceholder": "搜索文件名",
|
"searchPlaceholder": "搜索文件名",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"selected": "已选 {n}",
|
"selected": "已选 {n}",
|
||||||
"batchDelete": "批量删除",
|
"batchDelete": "批量删除",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"invertSelection": "反选",
|
||||||
|
"clearSelection": "取消选择",
|
||||||
"scrollTop": "回到顶部",
|
"scrollTop": "回到顶部",
|
||||||
"loadFailed": "加载统计失败",
|
"loadFailed": "加载统计失败",
|
||||||
"selectFirst": "请先选择图片",
|
"selectFirst": "请先选择图片",
|
||||||
@@ -371,6 +376,23 @@
|
|||||||
"image": {
|
"image": {
|
||||||
"empty": "暂无图片",
|
"empty": "暂无图片",
|
||||||
"previewFailed": "预览失败",
|
"previewFailed": "预览失败",
|
||||||
|
"openPreview": "查看大图",
|
||||||
|
"previewTitle": "图片详情",
|
||||||
|
"previewOriginalName": "文件名",
|
||||||
|
"previewUploadedAt": "上传时间",
|
||||||
|
"previewSize": "大小",
|
||||||
|
"previewContentType": "类型",
|
||||||
|
"previewOwner": "上传者",
|
||||||
|
"previewStorage": "存储",
|
||||||
|
"previewKey": "存储路径",
|
||||||
|
"previewExtension": "扩展名",
|
||||||
|
"previewDimensions": "尺寸",
|
||||||
|
"previewDimensionsUnknown": "加载中…",
|
||||||
|
"previewUploadSource": "上传方式",
|
||||||
|
"uploadSourceUnknown": "未知",
|
||||||
|
"previewLinks": "链接",
|
||||||
|
"previewOpen": "新窗口打开",
|
||||||
|
"previewClose": "关闭",
|
||||||
"uploader": "上传者:{name}",
|
"uploader": "上传者:{name}",
|
||||||
"storageBackend": "存储:{name}"
|
"storageBackend": "存储:{name}"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.3",
|
"version": "1.2.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.3",
|
"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.",
|
"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",
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
MAX_LIST_LIMIT
|
MAX_LIST_LIMIT
|
||||||
} from '../../utils/constants'
|
} from '../../utils/constants'
|
||||||
import { createApiError } from '../../utils/api-error'
|
import { createApiError } from '../../utils/api-error'
|
||||||
import { listUserIdUsernameMap } from '../../utils/db'
|
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||||
import { readBackendIdQuery } from '../../utils/image-query'
|
import { readBackendIdQuery } from '../../utils/image-query'
|
||||||
@@ -38,8 +38,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getCurrentUser(event)
|
const user = await getCurrentUser(event)
|
||||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||||
const backendMap = listStorageBackendNameMap()
|
const backendMap = listStorageBackendNameMap()
|
||||||
|
const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key))
|
||||||
const items = listing.items.map(stored =>
|
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))
|
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
MAX_LIST_LIMIT
|
MAX_LIST_LIMIT
|
||||||
} from '../../utils/constants'
|
} from '../../utils/constants'
|
||||||
import { createApiError } from '../../utils/api-error'
|
import { createApiError } from '../../utils/api-error'
|
||||||
import { listUserIdUsernameMap } from '../../utils/db'
|
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||||
import { readBackendIdQuery } from '../../utils/image-query'
|
import { readBackendIdQuery } from '../../utils/image-query'
|
||||||
@@ -47,8 +47,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await getCurrentUser(event)
|
const user = await getCurrentUser(event)
|
||||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||||
const backendMap = listStorageBackendNameMap()
|
const backendMap = listStorageBackendNameMap()
|
||||||
|
const sourceMap = getUploadSourcesForKeys(result.items.map(item => item.key))
|
||||||
const items = result.items.map(stored =>
|
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))
|
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: {
|
export function listActivityLogs(options: {
|
||||||
limit: number
|
limit: number
|
||||||
page?: number
|
page?: number
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import type { ImageItem } from '~/types/image'
|
import type { ImageItem } from '~/types/image'
|
||||||
|
import type { LogSource } from './db'
|
||||||
import type { AllowedMimeType } from './constants'
|
import type { AllowedMimeType } from './constants'
|
||||||
import type { StoredImage } from './storage'
|
import type { StoredImage } from './storage'
|
||||||
import { getImageBaseUrl, isHideFolderInUrl } from './env'
|
import { getImageBaseUrl, isHideFolderInUrl } from './env'
|
||||||
@@ -50,6 +51,7 @@ export function mapStoredImageToItem(
|
|||||||
options?: {
|
options?: {
|
||||||
ownerMap?: Map<number, string>
|
ownerMap?: Map<number, string>
|
||||||
backendMap?: Map<string, { name: string, type: StorageBackendType }>
|
backendMap?: Map<string, { name: string, type: StorageBackendType }>
|
||||||
|
sourceMap?: Map<string, LogSource>
|
||||||
}
|
}
|
||||||
): ImageItem {
|
): ImageItem {
|
||||||
const item = buildImageItem({
|
const item = buildImageItem({
|
||||||
@@ -64,18 +66,16 @@ export function mapStoredImageToItem(
|
|||||||
|
|
||||||
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
|
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
|
||||||
const storage = resolveImageStorage(stored.backendId, options?.backendMap)
|
const storage = resolveImageStorage(stored.backendId, options?.backendMap)
|
||||||
|
const uploadSource = options?.sourceMap?.get(stored.key)
|
||||||
|
|
||||||
if (owner || storage) {
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
...(owner ? { owner } : {}),
|
...(owner ? { owner } : {}),
|
||||||
...(storage ? { storage } : {})
|
...(storage ? { storage } : {}),
|
||||||
|
...(uploadSource ? { uploadSource } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildImageUrl(
|
export function buildImageUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
@@ -169,14 +169,17 @@ export async function processSingleImageUpload(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: buildImageItem({
|
item: {
|
||||||
|
...buildImageItem({
|
||||||
key,
|
key,
|
||||||
event,
|
event,
|
||||||
originalName,
|
originalName,
|
||||||
contentType: compressed.mime,
|
contentType: compressed.mime,
|
||||||
size: compressed.bytes.byteLength,
|
size: compressed.bytes.byteLength,
|
||||||
uploadedAt
|
uploadedAt
|
||||||
})
|
}),
|
||||||
|
uploadSource: input.source ?? 'web'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||||
import { isRefererAllowed } from './serve-image'
|
import type { H3Event } from 'h3'
|
||||||
|
import { isHotlinkBlocked, isRefererAllowed } from './serve-image'
|
||||||
|
import { getSetting, SETTINGS_ALLOWED_REFERER_HOSTS } from './db'
|
||||||
|
|
||||||
|
vi.mock('./db', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./db')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getSetting: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.stubGlobal('getRequestURL', () => new URL('https://pic.example.com'))
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
apiUploadToken: '',
|
||||||
|
siteBaseUrl: '',
|
||||||
|
imageBaseUrl: ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mockEvent(): H3Event {
|
||||||
|
return {
|
||||||
|
node: { req: {}, res: {} },
|
||||||
|
context: {}
|
||||||
|
} as H3Event
|
||||||
|
}
|
||||||
|
|
||||||
describe('isRefererAllowed', () => {
|
describe('isRefererAllowed', () => {
|
||||||
const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
|
const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
|
||||||
@@ -21,3 +46,42 @@ describe('isRefererAllowed', () => {
|
|||||||
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
|
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isHotlinkBlocked', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
delete process.env.ALLOWED_REFERER_HOSTS
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not block external referers when protection is not configured', () => {
|
||||||
|
vi.mocked(getSetting).mockReturnValue(null)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not block external referers when db explicitly cleared', () => {
|
||||||
|
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? '' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://forum.example.com/thread')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks external referers when protection is configured', () => {
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, 'https://evil.example.com/hotlink')).toBe(true)
|
||||||
|
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still allows missing referer when protection is configured', () => {
|
||||||
|
vi.mocked(getSetting).mockImplementation(key =>
|
||||||
|
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
|
||||||
|
)
|
||||||
|
const event = mockEvent()
|
||||||
|
expect(isHotlinkBlocked(event, null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
getAllowedRefererHostsRaw,
|
getAllowedRefererHostsRaw,
|
||||||
getImageBaseUrlConfigured,
|
getImageBaseUrlConfigured,
|
||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl
|
hostnameFromBaseUrl,
|
||||||
|
isRefererConfigured
|
||||||
} from './env'
|
} from './env'
|
||||||
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||||
import { validateImageKey } from './image-key'
|
import { validateImageKey } from './image-key'
|
||||||
@@ -58,6 +59,12 @@ export function isRefererAllowed(referer: string | null, allowedHosts: Set<strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未配置防盗链白名单时不拦截;已配置时校验 Referer(缺失 Referer 仍放行) */
|
||||||
|
export function isHotlinkBlocked(event: H3Event, referer: string | null): boolean {
|
||||||
|
if (!isRefererConfigured(event)) return false
|
||||||
|
return !isRefererAllowed(referer, getAllowedHosts(event))
|
||||||
|
}
|
||||||
|
|
||||||
function parseRangeHeader(
|
function parseRangeHeader(
|
||||||
rangeHeader: string | undefined,
|
rangeHeader: string | undefined,
|
||||||
size: number
|
size: number
|
||||||
@@ -108,7 +115,7 @@ export function requestPathToImageKey(
|
|||||||
|
|
||||||
export async function serveImageByKey(event: H3Event, key: string) {
|
export async function serveImageByKey(event: H3Event, key: string) {
|
||||||
const referer = getHeader(event, 'referer') ?? null
|
const referer = getHeader(event, 'referer') ?? null
|
||||||
if (!isRefererAllowed(referer, getAllowedHosts(event))) {
|
if (isHotlinkBlocked(event, referer)) {
|
||||||
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
|
|||||||
Reference in new issue
Block a user