release: v1.2.6 — 新 UI 体验优化与存储标识规则
No files matched your search
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.6] - 2026-09-01
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
|
||||||
|
- 图库筛选增加存储后端下拉
|
||||||
|
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
|
||||||
|
- 存储后端卡片:存储标识与类型同一行左对齐排列
|
||||||
|
- README 与文档站截图、导航说明同步新界面
|
||||||
|
|
||||||
## [1.2.5] - 2026-08-31
|
## [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)
|
**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
|
## Features
|
||||||
|
|
||||||
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
|
- **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-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
|
- **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
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -31,20 +31,20 @@
|
|||||||
|
|
||||||
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
|
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
|
||||||
|
|
||||||
| 首页 | 存储 |
|
| API | 图库 |
|
||||||
| :--: | :--: |
|
| :--: | :--: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| 统计 | 设置 |
|
| 存储 | 设置 |
|
||||||
| :--: | :--: |
|
| :--: | :--: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
|
||||||
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
|
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
|
||||||
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
|
||||||
- **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布
|
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
|
||||||
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
|
||||||
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
- **Docker 零配置**:首次访问 Web 引导创建管理员
|
||||||
|
|
||||||
|
|||||||
@@ -218,17 +218,17 @@ function onImageLoad(event: Event) {
|
|||||||
#body
|
#body
|
||||||
>
|
>
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
|
<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">
|
<div class="preview-checkerboard flex items-center justify-center overflow-hidden rounded-xl border border-default p-4">
|
||||||
<img
|
<img
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
:alt="image.originalName"
|
:alt="image.originalName"
|
||||||
class="max-h-[min(60vh,24rem)] w-full object-contain"
|
class="max-h-[min(60vh,24rem)] w-full object-contain"
|
||||||
@load="onImageLoad"
|
@load="onImageLoad"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-5">
|
<div class="flex min-w-0 flex-col gap-5">
|
||||||
<dl class="divide-y divide-default text-sm">
|
<dl class="divide-y divide-default text-sm">
|
||||||
<div class="flex gap-4 py-2.5 first:pt-0">
|
<div class="flex gap-4 py-2.5 first:pt-0">
|
||||||
<dt class="w-20 shrink-0 text-muted">
|
<dt class="w-20 shrink-0 text-muted">
|
||||||
{{ t('image.previewOriginalName') }}
|
{{ t('image.previewOriginalName') }}
|
||||||
@@ -330,40 +330,40 @@ function onImageLoad(event: Event) {
|
|||||||
/>
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="border-t border-default pt-4">
|
<div class="border-t border-default pt-4">
|
||||||
<p class="mb-2.5 text-sm font-medium">
|
<p class="mb-2.5 text-sm font-medium">
|
||||||
{{ t('image.previewLinks') }}
|
{{ t('image.previewLinks') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
||||||
<UButton
|
<UButton
|
||||||
v-for="item in embedFormatItems"
|
v-for="item in embedFormatItems"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="flex-1 justify-center"
|
class="flex-1 justify-center"
|
||||||
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
|
:variant="embedFormat === item.value ? 'solid' : 'ghost'"
|
||||||
:color="embedFormat === item.value ? 'primary' : 'neutral'"
|
:color="embedFormat === item.value ? 'primary' : 'neutral'"
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
@click="selectEmbedFormat(item.value)"
|
@click="selectEmbedFormat(item.value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<UInput
|
<UInput
|
||||||
:model-value="previewValue"
|
:model-value="previewValue"
|
||||||
readonly
|
readonly
|
||||||
size="sm"
|
size="sm"
|
||||||
class="min-w-0 flex-1 font-mono text-xs"
|
class="min-w-0 flex-1 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
:label="t('common.copy')"
|
:label="t('common.copy')"
|
||||||
:value="previewValue"
|
:value="previewValue"
|
||||||
:success-title="copySuccessTitle"
|
:success-title="copySuccessTitle"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
|
|||||||
@@ -125,124 +125,124 @@ const navButtonClass = 'inline-flex items-center justify-center rounded-md borde
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end sm:gap-3">
|
||||||
<USelect
|
<USelect
|
||||||
v-if="pageSize !== undefined"
|
v-if="pageSize !== undefined"
|
||||||
:model-value="pageSize"
|
:model-value="pageSize"
|
||||||
:items="pageSizeItems"
|
:items="pageSizeItems"
|
||||||
value-key="value"
|
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"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-14"
|
class="hidden w-[7.5rem] shrink-0 sm:block"
|
||||||
:aria-label="t('pagination.pagePlaceholder')"
|
:aria-label="t('pagination.pageSize')"
|
||||||
:disabled="loading"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
|
storageBackend: string
|
||||||
|
storageBackendItems: Array<{ label: string, value: string }>
|
||||||
uploadSource: string
|
uploadSource: string
|
||||||
uploadSourceItems: Array<{ label: string, value: string }>
|
uploadSourceItems: Array<{ label: string, value: string }>
|
||||||
viewMode: 'grid' | 'list'
|
viewMode: 'grid' | 'list'
|
||||||
@@ -13,6 +15,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:searchQuery': [value: string]
|
'update:searchQuery': [value: string]
|
||||||
|
'update:storageBackend': [value: string]
|
||||||
'update:uploadSource': [value: string]
|
'update:uploadSource': [value: string]
|
||||||
'update:viewMode': [mode: 'grid' | 'list']
|
'update:viewMode': [mode: 'grid' | 'list']
|
||||||
'search': []
|
'search': []
|
||||||
@@ -49,6 +52,16 @@ function viewButtonClass(active: boolean) {
|
|||||||
@update:model-value="emit('update:searchQuery', $event)"
|
@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
|
<USelect
|
||||||
:model-value="uploadSource"
|
:model-value="uploadSource"
|
||||||
:items="uploadSourceItems"
|
:items="uploadSourceItems"
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ function formatTimeShort(value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionColor(action: LogAction) {
|
function actionBadgeClass(action: LogAction) {
|
||||||
return action === 'upload' ? 'success' : 'error'
|
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) {
|
function actionIcon(action: LogAction) {
|
||||||
@@ -335,24 +338,16 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
||||||
<UBadge
|
<span :class="actionBadgeClass(row.action)">
|
||||||
:color="actionColor(row.action)"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="actionIcon(row.action)"
|
:name="actionIcon(row.action)"
|
||||||
class="mr-1 size-3"
|
class="size-3 shrink-0"
|
||||||
/>
|
/>
|
||||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||||
</UBadge>
|
</span>
|
||||||
<UBadge
|
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary whitespace-nowrap">
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ sourceLabel(row.source) }}
|
{{ sourceLabel(row.source) }}
|
||||||
</UBadge>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="text-xs text-muted"
|
class="text-xs text-muted"
|
||||||
@@ -412,32 +407,32 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden overflow-x-auto sm:block">
|
<div class="hidden min-w-0 sm:block">
|
||||||
<table class="min-w-full text-left text-sm">
|
<table class="w-full table-fixed text-left text-sm">
|
||||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||||
<tr>
|
<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') }}
|
{{ t('logs.colTime') }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="px-4 py-3 font-medium"
|
class="w-20 px-4 py-3 font-medium"
|
||||||
>
|
>
|
||||||
{{ t('logs.colUser') }}
|
{{ t('logs.colUser') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium">
|
<th class="w-20 whitespace-nowrap px-4 py-3 font-medium">
|
||||||
{{ t('logs.colAction') }}
|
{{ t('logs.colAction') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium">
|
<th class="w-16 whitespace-nowrap px-4 py-3 font-medium">
|
||||||
{{ t('logs.colSource') }}
|
{{ t('logs.colSource') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium">
|
<th class="w-24 px-4 py-3 font-medium">
|
||||||
{{ t('logs.colStorage') }}
|
{{ t('logs.colStorage') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="hidden px-4 py-3 font-medium md:table-cell">
|
<th class="hidden px-4 py-3 font-medium md:table-cell">
|
||||||
{{ t('logs.colFile') }}
|
{{ t('logs.colFile') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 font-medium">
|
<th class="w-20 px-4 py-3 font-medium">
|
||||||
{{ t('logs.colSize') }}
|
{{ t('logs.colSize') }}
|
||||||
</th>
|
</th>
|
||||||
<th class="hidden px-4 py-3 font-medium xl:table-cell">
|
<th class="hidden px-4 py-3 font-medium xl:table-cell">
|
||||||
@@ -451,41 +446,33 @@ onMounted(async () => {
|
|||||||
:key="row.id"
|
:key="row.id"
|
||||||
class="hover:bg-muted/20"
|
class="hover:bg-muted/20"
|
||||||
>
|
>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
|
<td class="px-4 py-3 text-xs text-muted sm:px-6">
|
||||||
{{ formatTime(row.createdAt) }}
|
<span class="block truncate">{{ formatTime(row.createdAt) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
class="px-4 py-3 text-xs"
|
class="px-4 py-3 text-xs"
|
||||||
>
|
>
|
||||||
{{ row.username ?? '—' }}
|
<span class="block truncate">{{ row.username ?? '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
<UBadge
|
<span :class="actionBadgeClass(row.action)">
|
||||||
:color="actionColor(row.action)"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="actionIcon(row.action)"
|
:name="actionIcon(row.action)"
|
||||||
class="mr-1 size-3"
|
class="size-3 shrink-0"
|
||||||
/>
|
/>
|
||||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||||
</UBadge>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
<UBadge
|
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ sourceLabel(row.source) }}
|
{{ sourceLabel(row.source) }}
|
||||||
</UBadge>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-xs">
|
<td class="px-4 py-3 text-xs">
|
||||||
<span
|
<span
|
||||||
v-if="row.storage"
|
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"
|
:title="row.storage.name"
|
||||||
>
|
>
|
||||||
<UIcon
|
<UIcon
|
||||||
@@ -500,7 +487,7 @@ onMounted(async () => {
|
|||||||
>—</span>
|
>—</span>
|
||||||
</td>
|
</td>
|
||||||
<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"
|
:title="row.originalName"
|
||||||
>
|
>
|
||||||
{{ row.originalName }}
|
{{ row.originalName }}
|
||||||
@@ -508,21 +495,21 @@ onMounted(async () => {
|
|||||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||||
{{ formatFileSize(row.size) }}
|
{{ formatFileSize(row.size) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-4 py-3 xl:table-cell">
|
<td class="hidden max-w-0 px-4 py-3 xl:table-cell">
|
||||||
<div class="flex max-w-[16rem] items-center gap-1">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
<a
|
<a
|
||||||
v-if="row.action === 'upload'"
|
v-if="row.action === 'upload'"
|
||||||
:href="imageUrl(row.key)"
|
:href="imageUrl(row.key)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
:title="row.key"
|
||||||
>
|
>
|
||||||
{{ row.key }}
|
{{ row.key }}
|
||||||
</a>
|
</a>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="truncate font-mono text-xs text-dimmed"
|
class="min-w-0 truncate font-mono text-xs text-dimmed"
|
||||||
:title="row.key"
|
:title="row.key"
|
||||||
>
|
>
|
||||||
{{ row.key }}
|
{{ row.key }}
|
||||||
@@ -530,6 +517,7 @@ onMounted(async () => {
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
icon-only
|
icon-only
|
||||||
|
class="shrink-0"
|
||||||
:label="t('common.copy')"
|
:label="t('common.copy')"
|
||||||
:value="row.key"
|
:value="row.key"
|
||||||
:success-title="t('copy.copiedUrl')"
|
:success-title="t('copy.copiedUrl')"
|
||||||
|
|||||||
@@ -45,104 +45,104 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SettingsSection :title="t('preferences.processing')">
|
<SettingsSection :title="t('preferences.processing')">
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsToggleRow
|
<SettingsToggleRow
|
||||||
v-model="compressEnabled"
|
v-model="compressEnabled"
|
||||||
:title="t('preferences.clientCompress')"
|
:title="t('preferences.clientCompress')"
|
||||||
:hint="t('preferences.clientCompressHint')"
|
: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
|
</div>
|
||||||
v-if="userSettings"
|
<div
|
||||||
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
|
v-else
|
||||||
>
|
class="flex justify-center py-4"
|
||||||
<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">
|
<UIcon
|
||||||
<span>{{ t('preferences.autoDeletePrefix') }}</span>
|
name="i-lucide-loader-circle"
|
||||||
<UInput
|
class="size-5 animate-spin text-muted"
|
||||||
v-model.number="userAutoDeleteDaysDraft"
|
/>
|
||||||
type="number"
|
</div>
|
||||||
min="1"
|
<div class="px-4 py-4 sm:px-5">
|
||||||
max="3650"
|
<div class="flex items-start justify-between gap-4">
|
||||||
size="xs"
|
<div class="min-w-0 flex-1">
|
||||||
class="w-14 tabular-nums"
|
<p class="text-sm font-medium text-highlighted">
|
||||||
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
|
{{ t('preferences.webpQuality') }}
|
||||||
/>
|
|
||||||
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<USwitch
|
|
||||||
v-model="userAutoDeleteEnabled"
|
|
||||||
class="mt-0.5 shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<input
|
||||||
v-else
|
type="range"
|
||||||
class="flex justify-center py-4"
|
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
|
</div>
|
||||||
name="i-lucide-loader-circle"
|
</SettingsGroup>
|
||||||
class="size-5 animate-spin text-muted"
|
</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>
|
||||||
<div class="px-4 py-4 sm:px-5">
|
</div>
|
||||||
<div class="flex items-start justify-between gap-4">
|
</SettingsGroup>
|
||||||
<div class="min-w-0 flex-1">
|
</SettingsSection>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -98,10 +98,10 @@ function canDelete() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-2 text-xs text-muted">
|
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<span class="shrink-0">{{ t('storage.storageId') }}:</span>
|
<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
|
<CopyButton
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
icon-only
|
icon-only
|
||||||
@@ -113,9 +113,9 @@ function canDelete() {
|
|||||||
:success-title="t('copy.copied')"
|
:success-title="t('copy.copied')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<span>
|
||||||
{{ t('storage.storageType') }}: {{ typeLabel }}
|
{{ t('storage.storageType') }}: {{ typeLabel }}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2 border-t border-default pt-4">
|
<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 {
|
export interface StorageFormPayload {
|
||||||
name: string
|
name: string
|
||||||
|
provider: StorageProvider
|
||||||
config: {
|
config: {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
region: string
|
region: string
|
||||||
@@ -158,6 +159,7 @@ watch(provider, (value) => {
|
|||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
name: name.value.trim() || providerLabel(provider.value),
|
name: name.value.trim() || providerLabel(provider.value),
|
||||||
|
provider: provider.value,
|
||||||
config: {
|
config: {
|
||||||
endpoint: endpoint.value.trim(),
|
endpoint: endpoint.value.trim(),
|
||||||
region: region.value.trim() || 'auto',
|
region: region.value.trim() || 'auto',
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const {
|
|||||||
loading: galleryLoading,
|
loading: galleryLoading,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
activeSearch,
|
activeSearch,
|
||||||
refreshList,
|
|
||||||
fetchList,
|
fetchList,
|
||||||
fetchSearch,
|
fetchSearch,
|
||||||
fetchTotal,
|
fetchTotal,
|
||||||
@@ -41,6 +40,9 @@ const {
|
|||||||
goToPage,
|
goToPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
removeItems,
|
removeItems,
|
||||||
|
activeStorageBackend,
|
||||||
|
setActiveStorageBackend,
|
||||||
|
storageBackendOptions,
|
||||||
activeUploadSource,
|
activeUploadSource,
|
||||||
setActiveUploadSource,
|
setActiveUploadSource,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
@@ -66,6 +68,7 @@ const previewImage = ref<ImageItem | null>(null)
|
|||||||
const viewMode = ref<'grid' | 'list'>('grid')
|
const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
|
|
||||||
const currentUploadSource = ref('all')
|
const currentUploadSource = ref('all')
|
||||||
|
const currentStorageBackend = ref('all')
|
||||||
|
|
||||||
const uploadSourceItems = computed(() => [
|
const uploadSourceItems = computed(() => [
|
||||||
{ label: t('stats.filterSourceAll'), value: 'all' },
|
{ label: t('stats.filterSourceAll'), value: 'all' },
|
||||||
@@ -73,6 +76,14 @@ const uploadSourceItems = computed(() => [
|
|||||||
{ label: t('stats.sourceApi'), value: 'api' }
|
{ 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)
|
const selectedCount = computed(() => selectedKeys.value.size)
|
||||||
|
|
||||||
function onWindowScroll() {
|
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() {
|
async function reloadGallery() {
|
||||||
if (activeSearch.value) {
|
if (activeSearch.value) {
|
||||||
await fetchSearch(galleryPage.value)
|
await fetchSearch(galleryPage.value)
|
||||||
@@ -134,6 +134,7 @@ async function refreshAll() {
|
|||||||
loadStorageBackendOptions()
|
loadStorageBackendOptions()
|
||||||
])
|
])
|
||||||
currentUploadSource.value = activeUploadSource.value
|
currentUploadSource.value = activeUploadSource.value
|
||||||
|
currentStorageBackend.value = activeStorageBackend.value
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleAuthError(error)
|
handleAuthError(error)
|
||||||
if (isAuthenticated.value) {
|
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) {
|
async function handleUploadSourceChange(uploadSource: string) {
|
||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return
|
||||||
currentUploadSource.value = uploadSource
|
currentUploadSource.value = uploadSource
|
||||||
@@ -159,6 +171,7 @@ async function handleResetFilters() {
|
|||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return
|
||||||
selectedKeys.value = new Set()
|
selectedKeys.value = new Set()
|
||||||
currentUploadSource.value = 'all'
|
currentUploadSource.value = 'all'
|
||||||
|
currentStorageBackend.value = 'all'
|
||||||
try {
|
try {
|
||||||
await resetFilters()
|
await resetFilters()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -381,11 +394,14 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
|
|
||||||
<GalleryFilterBar
|
<GalleryFilterBar
|
||||||
v-model:search-query="searchQuery"
|
v-model:search-query="searchQuery"
|
||||||
|
:storage-backend="currentStorageBackend"
|
||||||
|
:storage-backend-items="storageBackendItems"
|
||||||
:upload-source="currentUploadSource"
|
:upload-source="currentUploadSource"
|
||||||
:upload-source-items="uploadSourceItems"
|
:upload-source-items="uploadSourceItems"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:selected-count="selectedCount"
|
:selected-count="selectedCount"
|
||||||
:loading="galleryLoading"
|
:loading="galleryLoading"
|
||||||
|
@update:storage-backend="handleStorageBackendChange"
|
||||||
@update:upload-source="handleUploadSourceChange"
|
@update:upload-source="handleUploadSourceChange"
|
||||||
@update:view-mode="viewMode = $event"
|
@update:view-mode="viewMode = $event"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function buildPatchBody(payload: StorageFormPayload) {
|
|||||||
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
|
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
|
provider: payload.provider,
|
||||||
servingMode: payload.servingMode,
|
servingMode: payload.servingMode,
|
||||||
publicUrl: payload.publicUrl,
|
publicUrl: payload.publicUrl,
|
||||||
quotaBytes,
|
quotaBytes,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# API
|
# 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
|
## 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
|
## [1.2.5] — 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Environment variables
|
# 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
|
## Priority overview
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ See `docker-compose.yml` in the repository.
|
|||||||
1. Open your instance URL (Docker default `http://<host>:6892`).
|
1. Open your instance URL (Docker default `http://<host>:6892`).
|
||||||
2. Complete `/setup` with admin username and password.
|
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).
|
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
|
## 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
|
## 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 | Gallery |
|
||||||
| :--------------: | :---: |
|
| :-: | :-----: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| API | Stats & gallery |
|
|
||||||
| :-: | :-------------: |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
| Storage | Settings |
|
| Storage | Settings |
|
||||||
| :-----: | :------: |
|
| :-----: | :-------: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Activity log |
|
| Activity log |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Storage
|
# 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).
|
3. Enter connection details (bucket, endpoint, keys).
|
||||||
4. **Set as default** so new uploads use it.
|
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
|
### Cloudflare R2
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -52,7 +54,7 @@ Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs;
|
|||||||
## Usage & gallery filter
|
## Usage & gallery filter
|
||||||
|
|
||||||
- Per-backend usage on the Storage page
|
- 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
|
## Backup
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ PicHost uses role-based access control (RBAC): **admin** and **user**. Enforceme
|
|||||||
| Allow registration | ✓ | — |
|
| Allow registration | ✓ | — |
|
||||||
| Global API token | ✓ | — |
|
| Global API token | ✓ | — |
|
||||||
| Personal API token | ✓ | ✓ |
|
| Personal API token | ✓ | ✓ |
|
||||||
| Stats (incl. user count) | ✓ | partial |
|
| Gallery stats overview (incl. user count) | ✓ | partial |
|
||||||
| Activity log | ✓ | — |
|
| 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
|
## Auth scenarios
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ Regular users see **API**, **Stats** in the nav; user menu: **Change password**,
|
|||||||
|
|
||||||
## Upload preferences & auto-delete
|
## 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
|
- **Auto-delete**: global (admin) and per-user policies; affects **new uploads after enable** only
|
||||||
|
|
||||||
## API tokens
|
## API tokens
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# API
|
# 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.2.5] — 2026-08-31
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# 环境变量
|
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。管理员在 **设置与偏好**(`/settings`)左侧切换 **基础设置**、**域名与路径**、**访问控制**、**操作日志**;多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
|
||||||
|
|
||||||
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
|
|
||||||
|
|
||||||
## 优先级总览
|
## 优先级总览
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ docker compose up -d
|
|||||||
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
|
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
|
||||||
2. 在 `/setup` 填写管理员用户名与密码。
|
2. 在 `/setup` 填写管理员用户名与密码。
|
||||||
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
|
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 等)。
|
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
|
||||||
4. 保存后可 **设默认**,新上传将写入该后端。
|
4. 保存后可 **设默认**,新上传将写入该后端。
|
||||||
|
|
||||||
|
新建对象存储后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`(创建后不可修改,图片索引会引用该 ID)。
|
||||||
|
|
||||||
### Cloudflare R2 要点
|
### Cloudflare R2 要点
|
||||||
|
|
||||||
| 项 | 值 |
|
| 项 | 值 |
|
||||||
@@ -54,7 +56,7 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
|
|||||||
## 用量与筛选
|
## 用量与筛选
|
||||||
|
|
||||||
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
|
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
|
||||||
- 图库可按存储后端筛选(管理员)
|
- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换
|
||||||
|
|
||||||
## 备份
|
## 备份
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
|||||||
| 开放注册开关 | ✓ | — |
|
| 开放注册开关 | ✓ | — |
|
||||||
| 全局 API Token 管理 | ✓ | — |
|
| 全局 API Token 管理 | ✓ | — |
|
||||||
| 个人 API Token | ✓ | ✓ |
|
| 个人 API Token | ✓ | ✓ |
|
||||||
| 统计页(含注册用户数) | ✓ | 部分 |
|
| 图库统计概览(含注册用户数) | ✓ | 部分 |
|
||||||
| 操作日志 | ✓ | — |
|
| 操作日志(设置 → 操作日志) | ✓ | — |
|
||||||
|
|
||||||
普通用户顶栏:**API**、**统计**;用户菜单:**修改密码**、**退出登录**。
|
普通用户顶栏:**API**、**图库**;用户菜单:**修改密码**、**退出登录**。
|
||||||
|
|
||||||
## 鉴权场景
|
## 鉴权场景
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
|
|||||||
|
|
||||||
## 上传偏好与自动删除
|
## 上传偏好与自动删除
|
||||||
|
|
||||||
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接等,按用户保存
|
- **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好
|
||||||
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
|
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
|
||||||
|
|
||||||
## API Token 说明
|
## 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",
|
"name": "pichost",
|
||||||
"version": "1.2.5",
|
"version": "1.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.2.5",
|
"version": "1.2.6",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"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.",
|
"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": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ServingMode } from '../../../utils/storage/types'
|
|||||||
|
|
||||||
interface StorageCreateBody {
|
interface StorageCreateBody {
|
||||||
name?: string
|
name?: string
|
||||||
|
provider?: string
|
||||||
config?: {
|
config?: {
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
region?: string
|
region?: string
|
||||||
@@ -70,6 +71,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const id = insertStorageBackend({
|
const id = insertStorageBackend({
|
||||||
name,
|
name,
|
||||||
|
provider: body.provider?.trim(),
|
||||||
config,
|
config,
|
||||||
secrets: { accessKeyId, secretAccessKey },
|
secrets: { accessKeyId, secretAccessKey },
|
||||||
servingMode: body.servingMode,
|
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
|
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)
|
const bytes = new Uint8Array(4)
|
||||||
crypto.getRandomValues(bytes)
|
crypto.getRandomValues(bytes)
|
||||||
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||||
return `s3-${suffix}`
|
return `${prefix}-${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertStorageBackend(input: {
|
export function insertStorageBackend(input: {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
|
provider?: string
|
||||||
config: S3BackendConfig
|
config: S3BackendConfig
|
||||||
secrets: { accessKeyId: string, secretAccessKey: string }
|
secrets: { accessKeyId: string, secretAccessKey: string }
|
||||||
servingMode?: ServingMode
|
servingMode?: ServingMode
|
||||||
@@ -387,7 +410,8 @@ export function insertStorageBackend(input: {
|
|||||||
isDefault?: boolean
|
isDefault?: boolean
|
||||||
}): string {
|
}): string {
|
||||||
ensureDefaultBackends()
|
ensureDefaultBackends()
|
||||||
const id = input.id ?? generateStorageBackendId()
|
const idPrefix = resolveObjectStorageIdPrefix(input.config, input.provider)
|
||||||
|
const id = input.id ?? generateStorageBackendId(idPrefix)
|
||||||
const sortOrder = getNextSortOrder()
|
const sortOrder = getNextSortOrder()
|
||||||
|
|
||||||
getDb().prepare(`
|
getDb().prepare(`
|
||||||
|
|||||||