release: v1.2.6 — 新 UI 体验优化与存储标识规则

This commit is contained in:
O96u committed 2026-09-01 21:31:02 +08:00
1 parent ac65c1eb9d
commit 8408d01197
38 files changed
+491 -369

No files matched your search

+14
View File
@@ -4,6 +4,20 @@
## [Unreleased]
## [1.2.6] - 2026-09-01
### 新增
- 设置、API、图库、存储页 UI 重构(侧栏导航、统计卡片、在线调试等)
- 图库筛选增加存储后端下拉
- 新建对象存储后端按类型生成存储标识:`r2-*`、`cos-*`、`oss-*`、`s3-*`
### 改进
- 操作日志表格:操作/来源标签同行展示,路径列省略号截断、无横向滚动条
- 存储后端卡片:存储标识与类型同一行左对齐排列
- README 与文档站截图、导航说明同步新界面
## [1.2.5] - 2026-08-31
### 新增
+6 -6
View File
@@ -31,20 +31,20 @@
**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
| Home | Storage |
| API | Gallery |
| :--: | :-----: |
| ![Home](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| ![API](docs/screenshots/api.png) | ![Gallery](docs/screenshots/gallery.png) |
| Stats | Settings |
| :---: | :-------: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings-en.png) |
| Storage | Settings |
| :-----: | :-------: |
| ![Storage](docs/screenshots/storage.png) | ![Settings](docs/screenshots/settings-en.png) |
## 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
+5 -5
View File
@@ -31,20 +31,20 @@
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
| 首页 | 存储 |
| API | 图库 |
| :--: | :--: |
| ![首页](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| ![API](docs/screenshots/api.png) | ![图库](docs/screenshots/gallery.png) |
| 统计 | 设置 |
| 存储 | 设置 |
| :--: | :--: |
| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) |
| ![存储](docs/screenshots/storage.png) | ![设置](docs/screenshots/settings.png) |
## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **Docker 零配置**:首次访问 Web 引导创建管理员
+40 -40
View File
@@ -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
+115 -115
View File
@@ -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"
+35 -47
View File
@@ -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',
+28 -12
View File
@@ -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"
+1
View File
@@ -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 -1
View File
@@ -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
+14
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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**.
![Login](/screenshots/login.png)
+5 -9
View File
@@ -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 |
| :--------------: | :---: |
| ![Setup](/screenshots/setup.png) | ![Login](/screenshots/login.png) |
| API | Stats & gallery |
| :-: | :-------------: |
| ![API](/screenshots/api.png) | ![Stats & gallery](/screenshots/stats.png) |
| API | Gallery |
| :-: | :-----: |
| ![API](/screenshots/api.png) | ![Gallery](/screenshots/gallery.png) |
| Storage | Settings |
| :-----: | :------: |
| :-----: | :-------: |
| ![Storage](/screenshots/storage.png) | ![Settings](/screenshots/settings-en.png) |
| Activity log |
+4 -2
View File
@@ -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.
![Storage management](/screenshots/storage.png)
@@ -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
+4 -4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# API
PicHost 提供 REST 接口与 Twikoo 兼容端点。部署后登录 **API** 页可查看当前 Token 与可复制 cURL 示例。
PicHost 提供 REST 接口与 Twikoo 兼容端点。登录后打开顶栏 **API** 页:左侧为接口目录与 Token 管理,中间为参数说明与 cURL 示例,右侧可 **在线调试** 发送请求。
## 鉴权
+14
View File
@@ -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 -3
View File
@@ -1,6 +1,4 @@
# 环境变量
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。管理员在 **设置与偏好**(`/settings`)左侧切换 **基础设置**、**域名与路径**、**访问控制**、**操作日志**;多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
## 优先级总览
+2 -2
View File
@@ -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**。
## 登录
访问上传、统计、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
![登录页](/screenshots/login.png)
+7 -11
View File
@@ -12,19 +12,15 @@
## 截图预览
**main** 分支暂无公网演示,以下为 v1.2.0 产品界面。
**main** 分支暂无公网演示,以下为当前产品界面(v1.2.5+)。
| 首次设置 | 登录 |
| :------: | :--: |
| ![首次设置](/screenshots/setup.png) | ![登录](/screenshots/login.png) |
| API | 图库 |
| :-: | :-: |
| ![API](/screenshots/api.png) | ![图库](/screenshots/gallery.png) |
| API | 统计与图库 |
| :-: | :--------: |
| ![API](/screenshots/api.png) | ![统计与图库](/screenshots/stats.png) |
| 存储管理 | 系统设置 |
| :------: | :------: |
| ![存储管理](/screenshots/storage.png) | ![系统设置](/screenshots/settings.png) |
| 存储管理 | 设置与偏好 |
| :------: | :--------: |
| ![存储管理](/screenshots/storage.png) | ![设置与偏好](/screenshots/settings.png) |
| 操作日志 |
| :------: |
+4 -2
View File
@@ -1,6 +1,6 @@
# 存储
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。
![存储管理](/screenshots/storage.png)
@@ -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)
- 图库可按存储后端筛选(管理员)
- 图库可按 **存储后端**、**上传来源** 筛选,支持网格/列表视图切换
## 备份
+4 -4
View File
@@ -14,10 +14,10 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 开放注册开关 | ✓ | — |
| 全局 API Token 管理 | ✓ | — |
| 个人 API Token | ✓ | ✓ |
| 统计页(含注册用户数) | ✓ | 部分 |
| 操作日志 | ✓ | — |
| 图库统计概览(含注册用户数) | ✓ | 部分 |
| 操作日志(设置 → 操作日志) | ✓ | — |
普通用户顶栏:**API**、**统计**;用户菜单:**修改密码**、**退出登录**。
普通用户顶栏:**API**、**图库**;用户菜单:**修改密码**、**退出登录**。
## 鉴权场景
@@ -39,7 +39,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
## 上传偏好与自动删除
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接等,按用户保存
- **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
## API Token 说明
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+2
View File
@@ -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,
+40
View File
@@ -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}$/)
})
})
+27 -3
View File
@@ -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(`