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 引导创建管理员
|
||||||
|
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||