release: v1.2.6 — 新 UI 体验优化与存储标识规则
No files matched your search
@@ -4,6 +4,20 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.6] - 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
|
||||
- 图库筛选增加存储后端下拉
|
||||
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
|
||||
|
||||
### 改进
|
||||
|
||||
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
|
||||
- 存储后端卡片:存储标识与类型同一行左对齐排列
|
||||
- README 与文档站截图、导航说明同步新界面
|
||||
|
||||
## [1.2.5] - 2026-08-31
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -31,20 +31,20 @@
|
||||
|
||||
**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
|
||||
|
||||
| Home | Storage |
|
||||
| API | Gallery |
|
||||
| :--: | :-----: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Stats | Settings |
|
||||
| :---: | :-------: |
|
||||
|  |  |
|
||||
| Storage | Settings |
|
||||
| :-----: | :-------: |
|
||||
|  |  |
|
||||
|
||||
## Features
|
||||
|
||||
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
|
||||
- **Multi-user** — login with slider captcha, optional registration; users see only their images
|
||||
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
|
||||
- **Gallery & stats** — browse, search, batch delete; upload trends and source breakdown
|
||||
- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown
|
||||
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
|
||||
- **Zero-config Docker** — first-run web wizard, no secrets upfront
|
||||
|
||||
|
||||
@@ -31,20 +31,20 @@
|
||||
|
||||
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
|
||||
|
||||
| 首页 | 存储 |
|
||||
| API | 图库 |
|
||||
| :--: | :--: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| 统计 | 设置 |
|
||||
| 存储 | 设置 |
|
||||
| :--: | :--: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## 特性
|
||||
|
||||
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
||||
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
|
||||
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
||||
- **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布
|
||||
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
|
||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
||||
|
||||
|
||||
@@ -218,17 +218,17 @@ function onImageLoad(event: Event) {
|
||||
#body
|
||||
>
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
|
||||
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.originalName"
|
||||
class="max-h-[min(60vh,24rem)] w-full object-contain"
|
||||
@load="onImageLoad"
|
||||
>
|
||||
</div>
|
||||
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.originalName"
|
||||
class="max-h-[min(60vh,24rem)] w-full object-contain"
|
||||
@load="onImageLoad"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<dl class="divide-y divide-default text-sm">
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<dl class="divide-y divide-default text-sm">
|
||||
<div class="flex gap-4 py-2.5 first:pt-0">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewOriginalName') }}
|
||||
@@ -330,40 +330,40 @@ function onImageLoad(event: Event) {
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</dl>
|
||||
|
||||
<div class="border-t border-default pt-4">
|
||||
<p class="mb-2.5 text-sm font-medium">
|
||||
{{ t('image.previewLinks') }}
|
||||
</p>
|
||||
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in embedFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="embedFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="selectEmbedFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="sm"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
:label="t('common.copy')"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t border-default pt-4">
|
||||
<p class="mb-2.5 text-sm font-medium">
|
||||
{{ t('image.previewLinks') }}
|
||||
</p>
|
||||
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in embedFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="embedFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="selectEmbedFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="sm"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
:label="t('common.copy')"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
|
||||
@@ -125,124 +125,124 @@ const navButtonClass = 'inline-flex items-center justify-center rounded-md borde
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
|
||||
<USelect
|
||||
v-if="pageSize !== undefined"
|
||||
:model-value="pageSize"
|
||||
:items="pageSizeItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="hidden w-[7.5rem] shrink-0 sm:block"
|
||||
:aria-label="t('pagination.pageSize')"
|
||||
:disabled="loading"
|
||||
@update:model-value="onPageSizeChange"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-center gap-1 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
|
||||
:aria-label="t('pagination.first')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevrons-left"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'size-9 sm:size-8']"
|
||||
:aria-label="t('pagination.prev')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(page - 1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-left"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="!showMobilePageNumbers"
|
||||
class="min-w-[5.5rem] px-1 text-center text-sm text-muted sm:hidden"
|
||||
>
|
||||
{{ t('pagination.pageStatus', { page, totalPages }) }}
|
||||
</p>
|
||||
|
||||
<template
|
||||
v-for="(target, index) in visiblePages"
|
||||
:key="`${target}-${index}`"
|
||||
>
|
||||
<span
|
||||
v-if="target === 'ellipsis'"
|
||||
class="inline-flex size-9 items-center justify-center text-sm text-muted sm:size-8"
|
||||
:class="showMobilePageNumbers ? '' : 'hidden sm:inline-flex'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md border text-sm transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
:class="[
|
||||
pageButtonClass(target),
|
||||
showMobilePageNumbers ? 'size-9 sm:size-8' : 'hidden size-8 sm:inline-flex'
|
||||
]"
|
||||
:aria-label="t('pagination.pageNumber', { page: target })"
|
||||
:aria-current="target === page ? 'page' : undefined"
|
||||
:disabled="loading"
|
||||
@click="goTo(target)"
|
||||
>
|
||||
{{ target }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'size-9 sm:size-8']"
|
||||
:aria-label="t('pagination.next')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(page + 1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
|
||||
:aria-label="t('pagination.last')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(totalPages)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevrons-right"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="hidden items-center gap-1.5 text-sm text-muted sm:flex"
|
||||
@submit.prevent="jumpToPage"
|
||||
>
|
||||
<span>{{ t('pagination.goToPrefix') }}</span>
|
||||
<UInput
|
||||
v-model="jumpPageInput"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
<USelect
|
||||
v-if="pageSize !== undefined"
|
||||
:model-value="pageSize"
|
||||
:items="pageSizeItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="w-14"
|
||||
:aria-label="t('pagination.pagePlaceholder')"
|
||||
class="hidden w-[7.5rem] shrink-0 sm:block"
|
||||
:aria-label="t('pagination.pageSize')"
|
||||
:disabled="loading"
|
||||
@blur="jumpToPage"
|
||||
@update:model-value="onPageSizeChange"
|
||||
/>
|
||||
<span v-if="t('pagination.goToSuffix')">{{ t('pagination.goToSuffix') }}</span>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center justify-center gap-1 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
|
||||
:aria-label="t('pagination.first')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevrons-left"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'size-9 sm:size-8']"
|
||||
:aria-label="t('pagination.prev')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(page - 1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-left"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="!showMobilePageNumbers"
|
||||
class="min-w-[5.5rem] px-1 text-center text-sm text-muted sm:hidden"
|
||||
>
|
||||
{{ t('pagination.pageStatus', { page, totalPages }) }}
|
||||
</p>
|
||||
|
||||
<template
|
||||
v-for="(target, index) in visiblePages"
|
||||
:key="`${target}-${index}`"
|
||||
>
|
||||
<span
|
||||
v-if="target === 'ellipsis'"
|
||||
class="inline-flex size-9 items-center justify-center text-sm text-muted sm:size-8"
|
||||
:class="showMobilePageNumbers ? '' : 'hidden sm:inline-flex'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md border text-sm transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
:class="[
|
||||
pageButtonClass(target),
|
||||
showMobilePageNumbers ? 'size-9 sm:size-8' : 'hidden size-8 sm:inline-flex'
|
||||
]"
|
||||
:aria-label="t('pagination.pageNumber', { page: target })"
|
||||
:aria-current="target === page ? 'page' : undefined"
|
||||
:disabled="loading"
|
||||
@click="goTo(target)"
|
||||
>
|
||||
{{ target }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'size-9 sm:size-8']"
|
||||
:aria-label="t('pagination.next')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(page + 1)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[navButtonClass, 'hidden size-8 sm:inline-flex']"
|
||||
:aria-label="t('pagination.last')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(totalPages)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevrons-right"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="hidden items-center gap-1.5 text-sm text-muted sm:flex"
|
||||
@submit.prevent="jumpToPage"
|
||||
>
|
||||
<span>{{ t('pagination.goToPrefix') }}</span>
|
||||
<UInput
|
||||
v-model="jumpPageInput"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
size="sm"
|
||||
class="w-14"
|
||||
:aria-label="t('pagination.pagePlaceholder')"
|
||||
:disabled="loading"
|
||||
@blur="jumpToPage"
|
||||
/>
|
||||
<span v-if="t('pagination.goToSuffix')">{{ t('pagination.goToSuffix') }}</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
searchQuery: string
|
||||
storageBackend: string
|
||||
storageBackendItems: Array<{ label: string, value: string }>
|
||||
uploadSource: string
|
||||
uploadSourceItems: Array<{ label: string, value: string }>
|
||||
viewMode: 'grid' | 'list'
|
||||
@@ -13,6 +15,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:storageBackend': [value: string]
|
||||
'update:uploadSource': [value: string]
|
||||
'update:viewMode': [mode: 'grid' | 'list']
|
||||
'search': []
|
||||
@@ -49,6 +52,16 @@ function viewButtonClass(active: boolean) {
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
:model-value="storageBackend"
|
||||
:items="storageBackendItems"
|
||||
class="w-28 shrink-0"
|
||||
size="sm"
|
||||
:aria-label="t('stats.storage')"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:storageBackend', $event)"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
:model-value="uploadSource"
|
||||
:items="uploadSourceItems"
|
||||
|
||||
@@ -73,8 +73,11 @@ function formatTimeShort(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function actionColor(action: LogAction) {
|
||||
return action === 'upload' ? 'success' : 'error'
|
||||
function actionBadgeClass(action: LogAction) {
|
||||
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
|
||||
return action === 'upload'
|
||||
? `${base} bg-success/10 text-success`
|
||||
: `${base} bg-error/10 text-error`
|
||||
}
|
||||
|
||||
function actionIcon(action: LogAction) {
|
||||
@@ -335,24 +338,16 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
||||
<UBadge
|
||||
:color="actionColor(row.action)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="mr-1 size-3"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary whitespace-nowrap">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</UBadge>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin"
|
||||
class="text-xs text-muted"
|
||||
@@ -412,32 +407,32 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden overflow-x-auto sm:block">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<div class="hidden min-w-0 sm:block">
|
||||
<table class="w-full table-fixed text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium sm:px-6">
|
||||
<th class="w-36 px-4 py-3 font-medium sm:px-6">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 font-medium"
|
||||
class="w-20 px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
<th class="w-20 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
<th class="w-16 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
<th class="w-24 px-4 py-3 font-medium">
|
||||
{{ t('logs.colStorage') }}
|
||||
</th>
|
||||
<th class="hidden px-4 py-3 font-medium md:table-cell">
|
||||
{{ t('logs.colFile') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
<th class="w-20 px-4 py-3 font-medium">
|
||||
{{ t('logs.colSize') }}
|
||||
</th>
|
||||
<th class="hidden px-4 py-3 font-medium xl:table-cell">
|
||||
@@ -451,41 +446,33 @@ onMounted(async () => {
|
||||
:key="row.id"
|
||||
class="hover:bg-muted/20"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
|
||||
{{ formatTime(row.createdAt) }}
|
||||
<td class="px-4 py-3 text-xs text-muted sm:px-6">
|
||||
<span class="block truncate">{{ formatTime(row.createdAt) }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 text-xs"
|
||||
>
|
||||
{{ row.username ?? '—' }}
|
||||
<span class="block truncate">{{ row.username ?? '—' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge
|
||||
:color="actionColor(row.action)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="mr-1 size-3"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</UBadge>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</UBadge>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
v-if="row.storage"
|
||||
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
:title="row.storage.name"
|
||||
>
|
||||
<UIcon
|
||||
@@ -500,7 +487,7 @@ onMounted(async () => {
|
||||
>—</span>
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
|
||||
class="hidden max-w-0 truncate px-4 py-3 md:table-cell"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
@@ -508,21 +495,21 @@ onMounted(async () => {
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 xl:table-cell">
|
||||
<div class="flex max-w-[16rem] items-center gap-1">
|
||||
<td class="hidden max-w-0 px-4 py-3 xl:table-cell">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<a
|
||||
v-if="row.action === 'upload'"
|
||||
:href="imageUrl(row.key)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate font-mono text-xs text-primary hover:underline"
|
||||
class="min-w-0 truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="truncate font-mono text-xs text-dimmed"
|
||||
class="min-w-0 truncate font-mono text-xs text-dimmed"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
@@ -530,6 +517,7 @@ onMounted(async () => {
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
class="shrink-0"
|
||||
:label="t('common.copy')"
|
||||
:value="row.key"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
|
||||
@@ -45,104 +45,104 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<SettingsSection :title="t('preferences.processing')">
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="compressEnabled"
|
||||
:title="t('preferences.clientCompress')"
|
||||
:hint="t('preferences.clientCompressHint')"
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="compressEnabled"
|
||||
:title="t('preferences.clientCompress')"
|
||||
:hint="t('preferences.clientCompressHint')"
|
||||
/>
|
||||
<div
|
||||
v-if="userSettings"
|
||||
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
|
||||
<span>{{ t('preferences.autoDeletePrefix') }}</span>
|
||||
<UInput
|
||||
v-model.number="userAutoDeleteDaysDraft"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
size="xs"
|
||||
class="w-14 tabular-nums"
|
||||
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
|
||||
/>
|
||||
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.autoDeleteHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<USwitch
|
||||
v-model="userAutoDeleteEnabled"
|
||||
class="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-if="userSettings"
|
||||
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
|
||||
<span>{{ t('preferences.autoDeletePrefix') }}</span>
|
||||
<UInput
|
||||
v-model.number="userAutoDeleteDaysDraft"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
size="xs"
|
||||
class="w-14 tabular-nums"
|
||||
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
|
||||
/>
|
||||
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex justify-center py-4"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-5 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.autoDeleteHint') }}
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ t('preferences.current', { n: clientWebpQuality }) }}</p>
|
||||
<p class="mt-0.5">
|
||||
{{ clientQualityLevel }}
|
||||
</p>
|
||||
</div>
|
||||
<USwitch
|
||||
v-model="userAutoDeleteEnabled"
|
||||
class="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex justify-center py-4"
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-5 animate-spin text-muted"
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.uploadConvenience')">
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="autoCopyMarkdown"
|
||||
:title="t('preferences.autoCopy')"
|
||||
:hint="t('preferences.autoCopyHint')"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.defaultCopyFormat') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex w-full max-w-xs gap-1 rounded-lg border border-default p-0.5 sm:w-auto"
|
||||
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||
>
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center sm:min-w-[4.5rem]"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="setCopyFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ t('preferences.current', { n: clientWebpQuality }) }}</p>
|
||||
<p class="mt-0.5">
|
||||
{{ clientQualityLevel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.uploadConvenience')">
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="autoCopyMarkdown"
|
||||
:title="t('preferences.autoCopy')"
|
||||
:hint="t('preferences.autoCopyHint')"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.defaultCopyFormat') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex w-full max-w-xs gap-1 rounded-lg border border-default p-0.5 sm:w-auto"
|
||||
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||
>
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center sm:min-w-[4.5rem]"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="setCopyFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -98,10 +98,10 @@ function canDelete() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2 text-xs text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0">{{ t('storage.storageId') }}:</span>
|
||||
<code class="min-w-0 flex-1 truncate font-mono text-dimmed">{{ backend.id }}</code>
|
||||
<code class="min-w-0 truncate font-mono text-dimmed">{{ backend.id }}</code>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
@@ -113,9 +113,9 @@ function canDelete() {
|
||||
:success-title="t('copy.copied')"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<span>
|
||||
{{ t('storage.storageType') }}: {{ typeLabel }}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2 border-t border-default pt-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
|
||||
|
||||
export interface StorageFormPayload {
|
||||
name: string
|
||||
provider: StorageProvider
|
||||
config: {
|
||||
endpoint: string
|
||||
region: string
|
||||
@@ -158,6 +159,7 @@ watch(provider, (value) => {
|
||||
function handleSubmit() {
|
||||
emit('submit', {
|
||||
name: name.value.trim() || providerLabel(provider.value),
|
||||
provider: provider.value,
|
||||
config: {
|
||||
endpoint: endpoint.value.trim(),
|
||||
region: region.value.trim() || 'auto',
|
||||
|
||||
@@ -33,7 +33,6 @@ const {
|
||||
loading: galleryLoading,
|
||||
searchQuery,
|
||||
activeSearch,
|
||||
refreshList,
|
||||
fetchList,
|
||||
fetchSearch,
|
||||
fetchTotal,
|
||||
@@ -41,6 +40,9 @@ const {
|
||||
goToPage,
|
||||
setPageSize,
|
||||
removeItems,
|
||||
activeStorageBackend,
|
||||
setActiveStorageBackend,
|
||||
storageBackendOptions,
|
||||
activeUploadSource,
|
||||
setActiveUploadSource,
|
||||
resetFilters,
|
||||
@@ -66,6 +68,7 @@ const previewImage = ref<ImageItem | null>(null)
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
|
||||
const currentUploadSource = ref('all')
|
||||
const currentStorageBackend = ref('all')
|
||||
|
||||
const uploadSourceItems = computed(() => [
|
||||
{ label: t('stats.filterSourceAll'), value: 'all' },
|
||||
@@ -73,6 +76,14 @@ const uploadSourceItems = computed(() => [
|
||||
{ label: t('stats.sourceApi'), value: 'api' }
|
||||
])
|
||||
|
||||
const storageBackendItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
...storageBackendOptions.value.map(backend => ({
|
||||
label: backend.name,
|
||||
value: backend.id
|
||||
}))
|
||||
])
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.size)
|
||||
|
||||
function onWindowScroll() {
|
||||
@@ -103,17 +114,6 @@ async function refreshStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGallery() {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await Promise.all([refreshList(), loadStorageBackendOptions()])
|
||||
currentUploadSource.value = activeUploadSource.value
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadGallery() {
|
||||
if (activeSearch.value) {
|
||||
await fetchSearch(galleryPage.value)
|
||||
@@ -134,6 +134,7 @@ async function refreshAll() {
|
||||
loadStorageBackendOptions()
|
||||
])
|
||||
currentUploadSource.value = activeUploadSource.value
|
||||
currentStorageBackend.value = activeStorageBackend.value
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
@@ -144,6 +145,17 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStorageBackendChange(backendId: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
currentStorageBackend.value = backendId
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await setActiveStorageBackend(backendId)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadSourceChange(uploadSource: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
currentUploadSource.value = uploadSource
|
||||
@@ -159,6 +171,7 @@ async function handleResetFilters() {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedKeys.value = new Set()
|
||||
currentUploadSource.value = 'all'
|
||||
currentStorageBackend.value = 'all'
|
||||
try {
|
||||
await resetFilters()
|
||||
} catch (error: unknown) {
|
||||
@@ -381,11 +394,14 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
|
||||
<GalleryFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
:storage-backend="currentStorageBackend"
|
||||
:storage-backend-items="storageBackendItems"
|
||||
:upload-source="currentUploadSource"
|
||||
:upload-source-items="uploadSourceItems"
|
||||
:view-mode="viewMode"
|
||||
:selected-count="selectedCount"
|
||||
:loading="galleryLoading"
|
||||
@update:storage-backend="handleStorageBackendChange"
|
||||
@update:upload-source="handleUploadSourceChange"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@search="handleSearch"
|
||||
|
||||
@@ -48,6 +48,7 @@ function buildPatchBody(payload: StorageFormPayload) {
|
||||
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
|
||||
const body: Record<string, unknown> = {
|
||||
name: payload.name,
|
||||
provider: payload.provider,
|
||||
servingMode: payload.servingMode,
|
||||
publicUrl: payload.publicUrl,
|
||||
quotaBytes,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API
|
||||
|
||||
PicHost exposes REST endpoints and a Twikoo-compatible upload. After deployment, open the in-app **API** page for your token and copyable cURL snippets.
|
||||
PicHost exposes REST endpoints and a Twikoo-compatible upload. After sign-in, open **API** in the nav: a sidebar lists endpoints and token controls, the center shows parameters and copyable cURL, and the right panel supports **live debugging**.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@ Version history and notable changes. The full log is also in the repo root [CHAN
|
||||
|
||||
---
|
||||
|
||||
## [1.2.6] — 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Admin UI**: redesigned Settings, API, Gallery, and Storage pages; API page includes live debugging
|
||||
- **Gallery filter**: filter by storage backend
|
||||
- **Storage IDs**: new R2 / COS / OSS / S3 backends get `r2-*`, `cos-*`, `oss-*`, or `s3-*` prefixes
|
||||
|
||||
### Improved
|
||||
|
||||
- Activity log table styling and path truncation
|
||||
- Storage backend card metadata layout
|
||||
- Docs site and README screenshots updated
|
||||
|
||||
## [1.2.5] — 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Environment variables
|
||||
|
||||
Configure PicHost via environment variables (Docker / `.env`) or the in-app **Settings** page. Most values prefer **SQLite** when saved; some env vars override or lock UI editing.
|
||||
Configure PicHost via environment variables (Docker / `.env`) or the in-app **Settings** page. Admins use **Settings** (`/settings`) with sidebar sections **Basic**, **Domains & paths**, **Access control**, and **Activity log**. Most values prefer **SQLite** when saved; some env vars override or lock UI editing.
|
||||
|
||||
## Priority overview
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ See `docker-compose.yml` in the repository.
|
||||
1. Open your instance URL (Docker default `http://<host>:6892`).
|
||||
2. Complete `/setup` with admin username and password.
|
||||
3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md).
|
||||
4. Upload from the home page; admins can open **Storage**, **Settings**, and **API**.
|
||||
4. Upload from the home page; use the top nav for **Gallery**, **Storage** (admin), **Settings**, and **API**.
|
||||
|
||||
## Sign in
|
||||
|
||||
Management pages (upload, stats, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
|
||||
Management pages (upload, gallery, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -12,18 +12,14 @@ Welcome to the PicHost user guide. This site is the single source of truth; the
|
||||
|
||||
## Screenshots
|
||||
|
||||
**main** has no public demo. Below are v1.2.0 product screenshots.
|
||||
**main** has no public demo. Below are current product screenshots (v1.2.5+).
|
||||
|
||||
| First-time setup | Login |
|
||||
| :--------------: | :---: |
|
||||
|  |  |
|
||||
|
||||
| API | Stats & gallery |
|
||||
| :-: | :-------------: |
|
||||
|  |  |
|
||||
| API | Gallery |
|
||||
| :-: | :-----: |
|
||||
|  |  |
|
||||
|
||||
| Storage | Settings |
|
||||
| :-----: | :------: |
|
||||
| :-----: | :-------: |
|
||||
|  |  |
|
||||
|
||||
| Activity log |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Storage
|
||||
|
||||
PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend.
|
||||
PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend. The page shows a **storage overview** (total, used, remaining, usage rate, capacity chart) plus per-backend cards and supported backend types.
|
||||
|
||||

|
||||
|
||||
@@ -25,6 +25,8 @@ All **keys** start with `images/`. The upload API does **not** accept a `folder`
|
||||
3. Enter connection details (bucket, endpoint, keys).
|
||||
4. **Set as default** so new uploads use it.
|
||||
|
||||
New object-storage backends get an auto-generated **storage ID** prefix: `local` for disk; `r2-*` for R2; `cos-*` for COS; `oss-*` for OSS; `s3-*` for AWS S3 and custom S3-compatible endpoints (immutable after creation; the image index references this ID).
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
| Field | Value |
|
||||
@@ -52,7 +54,7 @@ Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs;
|
||||
## Usage & gallery filter
|
||||
|
||||
- Per-backend usage on the Storage page
|
||||
- Gallery filter by backend (admin)
|
||||
- Gallery filters by **storage backend** and **upload source**; grid/list view toggle
|
||||
|
||||
## Backup
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ PicHost uses role-based access control (RBAC): **admin** and **user**. Enforceme
|
||||
| Allow registration | ✓ | — |
|
||||
| Global API token | ✓ | — |
|
||||
| Personal API token | ✓ | ✓ |
|
||||
| Stats (incl. user count) | ✓ | partial |
|
||||
| Activity log | ✓ | — |
|
||||
| Gallery stats overview (incl. user count) | ✓ | partial |
|
||||
| Activity log (Settings → Activity log) | ✓ | — |
|
||||
|
||||
Regular users see **API**, **Stats** in the nav; user menu: **Change password**, **Sign out**.
|
||||
Regular users see **API**, **Gallery** in the nav; user menu: **Change password**, **Sign out**.
|
||||
|
||||
## Auth scenarios
|
||||
|
||||
@@ -39,7 +39,7 @@ Regular users see **API**, **Stats** in the nav; user menu: **Change password**,
|
||||
|
||||
## Upload preferences & auto-delete
|
||||
|
||||
- **Upload preferences** (home card flip): client compression, auto-copy links, per user
|
||||
- **Upload preferences**: admins configure client compression and auto-copy in **Settings → Basic**; regular users can flip the home upload card for personal preferences
|
||||
- **Auto-delete**: global (admin) and per-user policies; affects **new uploads after enable** only
|
||||
|
||||
## API tokens
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API
|
||||
|
||||
PicHost 提供 REST 接口与 Twikoo 兼容端点。部署后登录 **API** 页可查看当前 Token 与可复制 cURL 示例。
|
||||
PicHost 提供 REST 接口与 Twikoo 兼容端点。登录后打开顶栏 **API** 页:左侧为接口目录与 Token 管理,中间为参数说明与 cURL 示例,右侧可 **在线调试** 发送请求。
|
||||
|
||||
## 鉴权
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
|
||||
|
||||
---
|
||||
|
||||
## [1.2.6] — 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
- **管理界面**:设置、API、图库、存储页 UI 重构;API 页支持在线调试
|
||||
- **图库筛选**:按存储后端筛选
|
||||
- **存储标识**:新建 R2 / COS / OSS / S3 后端按类型生成 `r2-*`、`cos-*`、`oss-*`、`s3-*` 前缀
|
||||
|
||||
### 改进
|
||||
|
||||
- 操作日志表格样式与路径截断
|
||||
- 存储后端卡片信息行布局
|
||||
- 文档站与 README 截图更新
|
||||
|
||||
## [1.2.5] — 2026-08-31
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# 环境变量
|
||||
|
||||
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
|
||||
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。管理员在 **设置与偏好**(`/settings`)左侧切换 **基础设置**、**域名与路径**、**访问控制**、**操作日志**;多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
|
||||
|
||||
## 优先级总览
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ docker compose up -d
|
||||
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
|
||||
2. 在 `/setup` 填写管理员用户名与密码。
|
||||
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
|
||||
4. 登录后可在首页上传图片;管理员可进入 **存储**、**设置**、**API** 页。
|
||||
4. 登录后可在首页上传图片;顶栏可进入 **图库**、**存储**(管理员)、**设置**、**API**。
|
||||
|
||||
## 登录
|
||||
|
||||
访问上传、统计、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
|
||||
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -12,19 +12,15 @@
|
||||
|
||||
## 截图预览
|
||||
|
||||
**main** 分支暂无公网演示,以下为 v1.2.0 产品界面。
|
||||
**main** 分支暂无公网演示,以下为当前产品界面(v1.2.5+)。
|
||||
|
||||
| 首次设置 | 登录 |
|
||||
| :------: | :--: |
|
||||
|  |  |
|
||||
| API | 图库 |
|
||||
| :-: | :-: |
|
||||
|  |  |
|
||||
|
||||
| API | 统计与图库 |
|
||||
| :-: | :--------: |
|
||||
|  |  |
|
||||
|
||||
| 存储管理 | 系统设置 |
|
||||
| :------: | :------: |
|
||||
|  |  |
|
||||
| 存储管理 | 设置与偏好 |
|
||||
| :------: | :--------: |
|
||||
|  |  |
|
||||
|
||||
| 操作日志 |
|
||||
| :------: |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 存储
|
||||
|
||||
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。
|
||||
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
|
||||
|
||||

|
||||
|
||||
@@ -25,6 +25,8 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
||||
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
|
||||
4. 保存后可 **设默认**,新上传将写入该后端。
|
||||
|
||||
新建对象存储后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`(创建后不可修改,图片索引会引用该 ID)。
|
||||
|
||||
### Cloudflare R2 要点
|
||||
|
||||
| 项 | 值 |
|
||||
@@ -54,7 +56,7 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
||||
## 用量与筛选
|
||||
|
||||
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
|
||||
- 图库可按存储后端筛选(管理员)
|
||||
- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换
|
||||
|
||||
## 备份
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
||||
| 开放注册开关 | ✓ | — |
|
||||
| 全局 API Token 管理 | ✓ | — |
|
||||
| 个人 API Token | ✓ | ✓ |
|
||||
| 统计页(含注册用户数) | ✓ | 部分 |
|
||||
| 操作日志 | ✓ | — |
|
||||
| 图库统计概览(含注册用户数) | ✓ | 部分 |
|
||||
| 操作日志(设置 → 操作日志) | ✓ | — |
|
||||
|
||||
普通用户顶栏:**API**、**统计**;用户菜单:**修改密码**、**退出登录**。
|
||||
普通用户顶栏:**API**、**图库**;用户菜单:**修改密码**、**退出登录**。
|
||||
|
||||
## 鉴权场景
|
||||
|
||||
@@ -39,7 +39,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
||||
|
||||
## 上传偏好与自动删除
|
||||
|
||||
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接等,按用户保存
|
||||
- **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好
|
||||
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
|
||||
|
||||
## API Token 说明
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 70 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pichost",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pichost",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"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": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ServingMode } from '../../../utils/storage/types'
|
||||
|
||||
interface StorageCreateBody {
|
||||
name?: string
|
||||
provider?: string
|
||||
config?: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
@@ -70,6 +71,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const id = insertStorageBackend({
|
||||
name,
|
||||
provider: body.provider?.trim(),
|
||||
config,
|
||||
secrets: { accessKeyId, secretAccessKey },
|
||||
servingMode: body.servingMode,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateStorageBackendId,
|
||||
resolveObjectStorageIdPrefix
|
||||
} from './storage-backends'
|
||||
|
||||
describe('resolveObjectStorageIdPrefix', () => {
|
||||
it('uses explicit provider when given', () => {
|
||||
expect(resolveObjectStorageIdPrefix(
|
||||
{ endpoint: 'https://example.com' },
|
||||
'r2'
|
||||
)).toBe('r2')
|
||||
expect(resolveObjectStorageIdPrefix(
|
||||
{ endpoint: 'https://example.com' },
|
||||
'aws'
|
||||
)).toBe('s3')
|
||||
})
|
||||
|
||||
it('detects provider from endpoint', () => {
|
||||
expect(resolveObjectStorageIdPrefix({
|
||||
endpoint: 'https://abc123.r2.cloudflarestorage.com'
|
||||
})).toBe('r2')
|
||||
expect(resolveObjectStorageIdPrefix({
|
||||
endpoint: 'https://cos.ap-guangzhou.myqcloud.com'
|
||||
})).toBe('cos')
|
||||
expect(resolveObjectStorageIdPrefix({
|
||||
endpoint: 'https://oss-cn-hangzhou.aliyuncs.com'
|
||||
})).toBe('oss')
|
||||
expect(resolveObjectStorageIdPrefix({
|
||||
endpoint: 'https://s3.us-east-1.amazonaws.com'
|
||||
})).toBe('s3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateStorageBackendId', () => {
|
||||
it('uses provider-specific prefix', () => {
|
||||
expect(generateStorageBackendId('r2')).toMatch(/^r2-[0-9a-f]{8}$/)
|
||||
expect(generateStorageBackendId('s3')).toMatch(/^s3-[0-9a-f]{8}$/)
|
||||
})
|
||||
})
|
||||
@@ -368,16 +368,39 @@ function getNextSortOrder(): number {
|
||||
return row.max_order + 1
|
||||
}
|
||||
|
||||
export function generateStorageBackendId(): string {
|
||||
export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3'
|
||||
|
||||
/** 根据表单预设或 endpoint 推断新建后端的 id 前缀 */
|
||||
export function resolveObjectStorageIdPrefix(
|
||||
config: Pick<S3BackendConfig, 'endpoint'>,
|
||||
provider?: string
|
||||
): ObjectStorageIdPrefix {
|
||||
const normalized = provider?.trim().toLowerCase()
|
||||
if (normalized === 'r2' || normalized === 'cos' || normalized === 'oss') {
|
||||
return normalized
|
||||
}
|
||||
if (normalized === 'aws' || normalized === 's3') {
|
||||
return 's3'
|
||||
}
|
||||
|
||||
const endpoint = config.endpoint.trim().toLowerCase()
|
||||
if (endpoint.includes('r2.cloudflarestorage.com')) return 'r2'
|
||||
if (endpoint.includes('myqcloud.com')) return 'cos'
|
||||
if (endpoint.includes('aliyuncs.com')) return 'oss'
|
||||
return 's3'
|
||||
}
|
||||
|
||||
export function generateStorageBackendId(prefix: ObjectStorageIdPrefix = 's3'): string {
|
||||
const bytes = new Uint8Array(4)
|
||||
crypto.getRandomValues(bytes)
|
||||
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||
return `s3-${suffix}`
|
||||
return `${prefix}-${suffix}`
|
||||
}
|
||||
|
||||
export function insertStorageBackend(input: {
|
||||
id?: string
|
||||
name: string
|
||||
provider?: string
|
||||
config: S3BackendConfig
|
||||
secrets: { accessKeyId: string, secretAccessKey: string }
|
||||
servingMode?: ServingMode
|
||||
@@ -387,7 +410,8 @@ export function insertStorageBackend(input: {
|
||||
isDefault?: boolean
|
||||
}): string {
|
||||
ensureDefaultBackends()
|
||||
const id = input.id ?? generateStorageBackendId()
|
||||
const idPrefix = resolveObjectStorageIdPrefix(input.config, input.provider)
|
||||
const id = input.id ?? generateStorageBackendId(idPrefix)
|
||||
const sortOrder = getNextSortOrder()
|
||||
|
||||
getDb().prepare(`
|
||||
|
||||