release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
O96u committed 2026-08-22 14:36:57 +08:00
commit bc4cba7e52
115 files changed
+26508

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { isPasswordValid } from '~/utils/password-strength'
const router = useRouter()
const {
isChecking,
isAuthenticated,
checkSession,
fetchStatus,
changePassword
} = useAuth()
const toast = useToast()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(currentPassword.value && newPassword.value && confirmPassword.value)
&& newPassword.value === confirmPassword.value
&& isPasswordValid(newPassword.value)
)
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await router.replace('/setup')
return
}
if (!isAuthenticated.value) {
await checkSession()
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (newPassword.value !== confirmPassword.value) {
toast.add({ title: '两次输入的新密码不一致', color: 'error' })
return
}
loading.value = true
try {
const result = await changePassword(currentPassword.value, newPassword.value)
if (result.ok) {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
toast.add({ title: '密码已修改', color: 'success' })
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="mx-auto max-w-md overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="border-b border-default px-5 py-4 sm:px-6">
<h1 class="text-lg font-semibold tracking-tight">
修改密码
</h1>
<p class="mt-1 text-sm text-muted">
修改您的登录密码
</p>
</div>
<form
class="space-y-5 p-5 sm:p-6"
@submit.prevent="submit"
>
<div class="space-y-2">
<label
for="current-password"
class="block text-sm font-medium"
>
当前密码
</label>
<UInput
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="space-y-2">
<label
for="new-password"
class="block text-sm font-medium"
>
新密码
</label>
<UInput
id="new-password"
v-model="newPassword"
type="password"
placeholder="设置新密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="newPassword" />
</div>
<div class="space-y-2">
<label
for="confirm-new-password"
class="block text-sm font-medium"
>
确认新密码
</label>
<UInput
id="confirm-new-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="flex gap-2 pt-1">
<UButton
type="submit"
label="保存"
icon="i-lucide-save"
:loading="loading"
:disabled="!canSubmit"
/>
<UButton
label="取消"
variant="ghost"
color="neutral"
to="/"
/>
</div>
</form>
</section>
</AppShell>
</div>
</template>
+518
View File
@@ -0,0 +1,518 @@
<script setup lang="ts">
interface SettingsResponse {
apiUploadToken: string
tokenSource: 'env' | 'db' | 'none'
envTokenOverride: boolean
env: {
webpQuality: number
refererConfigured: boolean
imageBaseUrl: string
appVersion: string
}
}
interface ApiDocItem {
index: number
method: 'GET' | 'POST' | 'DELETE'
path: string
title: string
description: string
curl: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const settings = ref<SettingsResponse | null>(null)
const loading = ref(false)
const regenerating = ref(false)
const confirmRegenerateOpen = ref(false)
const regenerateResultOpen = ref(false)
const newToken = ref('')
const baseUrl = computed(() => {
if (import.meta.client) {
return window.location.origin
}
return settings.value?.env.imageBaseUrl ?? ''
})
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
const hasToken = computed(() => tokenDisplay.value.length > 0)
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value)
const authHeader = computed(() =>
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
)
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value
? '上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。'
: '上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。'
const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"
# 上传到自定义目录 blog/
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
-F "image=@./demo.png"`
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"`
const listDescription = isAdmin.value
? '分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。'
: '分页列出您的图库,limit 默认 20、最大 100。'
const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
# 按目录筛选
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}`
return [
{
index: 1,
method: 'POST',
path: '/api/images/upload',
title: '上传图片',
description: uploadDescription,
curl: uploadCurl
},
{
index: 2,
method: 'GET',
path: '/api/images',
title: '获取图片列表',
description: listDescription,
curl: listCurl
},
{
index: 3,
method: 'GET',
path: '/api/images/search',
title: '搜索图片',
description: '按文件名或路径关键词搜索,参数 q 必填。',
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
${authHeaderFlag.value}`
},
{
index: 4,
method: 'DELETE',
path: '/api/images',
title: '删除图片',
description: '通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。',
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
${authHeaderFlag.value}`
},
{
index: 5,
method: 'POST',
path: '/api/images/batch-delete',
title: '批量删除',
description: '一次删除多张图片,请求体为 keys 数组。',
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
${authHeaderFlag.value} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`
}
]
})
const twikooDoc = computed(() => ({
method: 'POST' as const,
path: '/api/index.php',
title: 'twikoo 兼容上传',
description: '兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。',
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
-F "token=${authHeader.value}" \\
-F "image=@./demo.png"`
}))
function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
switch (source) {
case 'env':
return '来源:环境变量 API_UPLOAD_TOKEN'
case 'db':
return '来源:后台生成(可重新生成)'
default:
return '来源:未配置'
}
}
function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) {
case 'GET':
return 'info' as const
case 'DELETE':
return 'error' as const
default:
return 'primary' as const
}
}
async function loadSettings() {
loading.value = true
try {
const endpoint = isAdmin.value ? '/api/settings' : '/api/user/api-token'
settings.value = await $fetch<SettingsResponse>(endpoint, {
credentials: 'include'
})
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载 API 设置失败', color: 'error' })
}
} finally {
loading.value = false
}
}
function requestRegenerate() {
if (!canRegenerate.value) return
confirmRegenerateOpen.value = true
}
async function confirmRegenerate() {
confirmRegenerateOpen.value = false
regenerating.value = true
try {
const endpoint = isAdmin.value
? '/api/settings/api-token/regenerate'
: '/api/user/api-token/regenerate'
const response = await $fetch<{ apiUploadToken: string }>(
endpoint,
{ method: 'POST', credentials: 'include' }
)
newToken.value = response.apiUploadToken
regenerateResultOpen.value = true
await loadSettings()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({
title: '环境变量已配置 Token,无法在后台重新生成',
color: 'warning'
})
} else {
toast.add({ title: '重新生成 Token 失败', color: 'error' })
}
}
} finally {
regenerating.value = false
}
}
async function copyCurl(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: '已复制 cURL 示例', color: 'success' })
} catch {
toast.add({ title: '复制失败', color: 'error' })
}
}
function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false
}
function closeRegenerateResult() {
regenerateResultOpen.value = false
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
watch(isAuthenticated, async (authed) => {
if (authed) {
await nextTick()
await loadSettings()
} else {
settings.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-key-round"
class="size-5 text-primary"
/>
<h2 class="text-base font-semibold">
API Token
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="settings?.envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Token 由环境变量覆盖"
description="修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。"
/>
<UAlert
v-else-if="!isAdmin"
color="info"
variant="subtle"
icon="i-lucide-info"
title="个人 API Token"
description="此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。"
/>
<UAlert
v-else-if="settings && !hasToken"
color="info"
variant="subtle"
icon="i-lucide-info"
title="尚未配置 Token"
description="点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="hasToken ? tokenDisplay : '(未配置)'"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading"
/>
<div class="flex gap-2">
<CopyButton
label="复制"
:value="tokenDisplay"
/>
<UButton
icon="i-lucide-refresh-cw"
label="重新生成"
color="warning"
variant="soft"
:loading="regenerating"
:disabled="!canRegenerate"
@click="requestRegenerate"
/>
</div>
</div>
<p
v-if="settings"
class="text-xs text-muted"
>
{{ tokenSourceLabel(settings.tokenSource) }}
· 请求头:
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="hasToken && !settings.envTokenOverride">
· 重新生成后旧 Token 立即失效
</template>
</p>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-book-open"
class="size-5 text-primary"
/>
<h2 class="text-base font-semibold">
API 文档
</h2>
</div>
<div class="divide-y divide-default">
<article
v-for="doc in apiDocs"
:key="doc.index"
class="space-y-4 p-5 sm:p-6"
>
<div class="flex gap-3">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{{ doc.index }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
:color="methodBadgeColor(doc.method)"
variant="subtle"
size="sm"
>
{{ doc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ doc.path }}
</code>
<span class="text-sm font-medium">
{{ doc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ doc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
cURL 示例
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
label="复制"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(doc.curl)"
/>
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
</div>
</div>
</article>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-dashed border-default bg-muted/20">
<div class="space-y-4 p-5 sm:p-6">
<div class="flex gap-3">
<UIcon
name="i-lucide-message-square"
class="mt-0.5 size-5 shrink-0 text-muted"
/>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
color="neutral"
variant="subtle"
size="sm"
>
{{ twikooDoc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ twikooDoc.path }}
</code>
<span class="text-sm font-medium">
{{ twikooDoc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ twikooDoc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
cURL 示例
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
label="复制"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(twikooDoc.curl)"
/>
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
</div>
</div>
</div>
</section>
</AppShell>
<UModal
:open="confirmRegenerateOpen"
title="确认重新生成 Token?"
description="旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。"
@update:open="confirmRegenerateOpen = $event"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
label="取消"
color="neutral"
variant="outline"
@click="closeConfirmRegenerate"
/>
<UButton
label="确认重新生成"
color="warning"
:loading="regenerating"
@click="confirmRegenerate"
/>
</div>
</template>
</UModal>
<UModal
:open="regenerateResultOpen"
title="新 Token 已生成"
description="请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。"
@update:open="regenerateResultOpen = $event"
>
<template #body>
<UInput
:model-value="newToken"
readonly
class="w-full font-mono text-sm"
/>
</template>
<template #footer>
<div class="flex w-full flex-wrap justify-end gap-2">
<CopyButton
label="复制 Token"
:value="newToken"
/>
<UButton
label="我已保存"
@click="closeRegenerateResult"
/>
</div>
</template>
</UModal>
</div>
</template>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload()
const {
defaultFolder,
autoCopyMarkdown,
copyFormat,
loadPreferences
} = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
= useAuth()
const toast = useToast()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>())
const showProgress = computed(
() =>
uploading.value
|| progressItems.value.some(
item =>
item.status === 'pending'
|| item.status === 'uploading'
|| item.status === 'error'
)
)
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadPage() {
try {
loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) {
handleAuthError(error)
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
if (!isAuthenticated.value) {
await checkSession()
}
if (isAuthenticated.value) {
await loadPage()
}
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadPage()
} else if (!authed) {
sessionItems.value = []
}
})
function copyFormatLabel() {
switch (copyFormat.value) {
case 'url':
return '直链'
case 'html':
return 'HTML'
default:
return 'Markdown'
}
}
async function copyUploadLink(text: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.add({ title: `已复制 ${copyFormatLabel()}`, color: 'success' })
} catch {
toast.add({ title: '复制失败', color: 'error' })
}
}
async function handleUpload(files: File[]) {
if (!isAuthenticated.value) {
toast.add({ title: '请先登录', color: 'warning' })
return
}
try {
const uploadTarget = isAdmin.value
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value]
if (isAdmin.value) {
const folder = uploadTarget
if (!folderOptions.value.includes(folder)) {
folderOptions.value = [...folderOptions.value, folder]
}
}
if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0]
const text
= copyFormat.value === 'url'
? first.url
: copyFormat.value === 'html'
? first.html
: first.markdown
if (text) await copyUploadLink(text)
}
}
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleDelete(key: string) {
deletingKeys.value.add(key)
try {
await $fetch('/api/images', {
method: 'DELETE',
query: { key }
})
sessionItems.value = sessionItems.value.filter(item => item.key !== key)
toast.add({ title: '已删除', color: 'success' })
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: '删除失败', color: 'error' })
} finally {
deletingKeys.value.delete(key)
deletingKeys.value = new Set(deletingKeys.value)
}
}
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<UploadFlipCard
:disabled="uploading"
@upload="handleUpload"
/>
<UploadResult
v-if="showProgress"
:items="progressItems"
/>
<section
v-if="sessionItems.length"
class="space-y-4"
>
<div class="flex items-center justify-between gap-2">
<h2 class="text-lg font-medium">
本次上传
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
</h2>
</div>
<ImageGrid
:items="sessionItems"
:selected-keys="emptySelection"
:deleting-keys="deletingKeys"
:selectable="false"
:show-key="false"
empty-text="暂无本次上传"
@update:selected-keys="() => {}"
@delete="handleDelete"
/>
</section>
</AppShell>
</div>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength'
const router = useRouter()
const { register, fetchStatus } = useAuth()
const toast = useToast()
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value
&& isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
)
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized) {
await router.replace('/setup')
return
}
if (!status.allowRegistration) {
await router.replace('/')
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' })
return
}
loading.value = true
try {
const result = await register(username.value.trim(), password.value)
if (result.ok) {
toast.add({ title: '注册成功', color: 'success' })
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center">
<img
:src="logoLight"
alt=""
aria-hidden="true"
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt=""
aria-hidden="true"
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
decoding="async"
draggable="false"
>
</div>
<div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
注册账号
</h1>
<p class="text-sm text-muted">
创建 PicHost 用户账号
</p>
</div>
</div>
<form
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
<div class="w-full space-y-2">
<label
for="register-username"
class="block text-sm font-medium text-default"
>
用户名
</label>
<UInput
id="register-username"
v-model="username"
placeholder="3–32 位字母、数字或下划线"
autocomplete="username"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="w-full space-y-2">
<label
for="register-password"
class="block text-sm font-medium text-default"
>
密码
</label>
<UInput
id="register-password"
v-model="password"
type="password"
placeholder="设置登录密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
<div class="w-full space-y-2">
<label
for="register-confirm"
class="block text-sm font-medium text-default"
>
确认密码
</label>
<UInput
id="register-confirm"
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<UButton
type="submit"
label="注册"
icon="i-lucide-user-plus"
size="lg"
block
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
<p class="text-center text-sm text-muted">
已有账号?
<NuxtLink
to="/"
class="text-primary hover:underline"
>
返回登录
</NuxtLink>
</p>
</form>
</div>
</div>
</template>
+359
View File
@@ -0,0 +1,359 @@
<script setup lang="ts">
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
interface SettingsResponse {
apiUploadToken: string
tokenSource: SettingSource
envTokenOverride: boolean
webpQuality: number
webpQualitySource: WebpQualitySource
allowedRefererHosts: string
refererSource: SettingSource
refererEnvFallback: string
imageBaseUrl: string
imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource
allowRegistration: boolean
appVersion: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast()
const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false)
const refererDraft = ref('')
const imageBaseUrlDraft = ref('')
const allowRegistrationDraft = ref(false)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) {
case 'env':
return { label: '.env', color: 'warning' as const }
case 'db':
return { label: '已保存', color: 'success' as const }
case 'default':
return { label: '默认', color: 'neutral' as const }
default:
return { label: '未设置', color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return '环境变量'
case 'db':
return '后台生成'
default:
return '未配置'
}
}
function errorStatus(error: unknown): number {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
return (error as { statusCode: number }).statusCode
}
return 0
}
function applySettings(data: SettingsResponse) {
settings.value = data
refererDraft.value = data.allowedRefererHosts
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
allowRegistrationDraft.value = data.allowRegistration
}
async function patchSettings(body: Record<string, unknown>) {
const data = await $fetch<SettingsResponse>('/api/settings', {
method: 'PATCH',
credentials: 'include',
body
})
applySettings(data)
return data
}
function handlePatchError(error: unknown, fallback: string) {
handleAuthError(error)
if (!isAuthenticated.value) return
if (errorStatus(error) === 409) {
toast.add({ title: '该项已由环境变量覆盖,无法在后台修改', color: 'warning' })
void loadSettings()
return
}
toast.add({ title: fallback, color: 'error' })
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true
try {
await patchSettings({
allowedRefererHosts: refererDraft.value,
imageBaseUrl: imageBaseUrlDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: '服务器配置已保存', color: 'success' })
} catch (error: unknown) {
handlePatchError(error, '保存服务器配置失败')
} finally {
savingServer.value = false
}
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadSettings() {
try {
const data = await $fetch<SettingsResponse>('/api/settings', {
credentials: 'include'
})
applySettings(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
}
}
}
async function loadPage() {
if (!isAdmin.value) {
await navigateTo('/')
return
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders()])
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/')
}
})
watch(isAuthenticated, async (authed) => {
if (authed) {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await nextTick()
await loadPage()
} else {
settings.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<UploadPreferencesPanel embedded />
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-server"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h1 class="text-base font-semibold">
系统设置
</h1>
<p class="mt-0.5 text-xs text-muted">
保存后写入数据库;环境变量优先级更高
</p>
</div>
</div>
<div
v-if="settings"
class="p-5 sm:p-6"
>
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<h3 class="text-sm font-semibold">
上传与账号
</h3>
<div class="space-y-2">
<label class="text-sm">默认上传目录</label>
<p class="text-xs leading-relaxed text-muted">
保存在本机浏览器,仅影响您的上传目标目录
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="w-full"
/>
</div>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5"
/>
<span>
<span class="block text-sm">开放用户注册</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
允许新用户自行注册(普通用户仅可见自己的图片)
</span>
</span>
</label>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
访问控制
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
Referer 防盗链,逗号分隔允许引用的域名,留空不限制
</p>
<UTextarea
v-model="refererDraft"
placeholder="example.com, www.example.com"
:rows="4"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
链接生成
</h3>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
生成图片链接的 URL 前缀,内网访问建议填公网域名
</p>
<UInput
v-model="imageBaseUrlDraft"
placeholder="https://pic.example.com"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
当前生效:<span class="font-mono break-all">{{ settings.imageBaseUrl }}</span>
</p>
</div>
</div>
</div>
<div
v-else
class="flex justify-center py-12"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-if="settings"
class="flex flex-wrap items-center justify-between gap-3 border-t border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon
name="i-lucide-key-round"
class="size-3.5 shrink-0"
/>
<span>
API Token · {{ tokenSourceText(settings.tokenSource) }}
<template v-if="hasServerChanges">
· <span class="text-warning">有未保存的更改</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
label="管理 Token"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
label="保存"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength'
const route = useRoute()
const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast()
const isMigrate = computed(() => route.query.migrate === '1')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const allowRegistration = ref(false)
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value
&& isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
)
onMounted(async () => {
const status = await fetchStatus()
if (status.initialized && !status.needsMigration) {
await router.replace('/')
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' })
return
}
loading.value = true
try {
const payload = {
username: username.value.trim(),
password: password.value,
allowRegistration: allowRegistration.value
}
const result = isMigrate.value
? await migrate(payload)
: await setup(payload)
if (result.ok) {
toast.add({
title: isMigrate.value ? '账号迁移完成' : '初始化完成',
color: 'success'
})
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center">
<img
:src="logoLight"
alt=""
aria-hidden="true"
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt=""
aria-hidden="true"
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
decoding="async"
draggable="false"
>
</div>
<div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</h1>
<p class="text-sm text-muted">
{{ isMigrate ? '迁移到账号密码登录' : '首次使用,创建管理员账号' }}
</p>
</div>
</div>
<form
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
<div class="w-full space-y-2">
<label
for="setup-username"
class="block text-sm font-medium text-default"
>
用户名
</label>
<UInput
id="setup-username"
v-model="username"
placeholder="3–32 位字母、数字或下划线"
autocomplete="username"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="w-full space-y-2">
<label
for="setup-password"
class="block text-sm font-medium text-default"
>
密码
</label>
<UInput
id="setup-password"
v-model="password"
type="password"
placeholder="设置登录密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
<div class="w-full space-y-2">
<label
for="setup-confirm"
class="block text-sm font-medium text-default"
>
确认密码
</label>
<UInput
id="setup-confirm"
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-default">
<UCheckbox v-model="allowRegistration" />
允许其他用户注册
</label>
<UButton
type="submit"
:label="isMigrate ? '完成迁移' : '创建并进入'"
icon="i-lucide-user-plus"
size="lg"
block
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
</form>
</div>
</div>
</template>
+551
View File
@@ -0,0 +1,551 @@
<script setup lang="ts">
interface StatsResponse {
uploadToday: number
uploadMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
storedCount: number
userCount?: number
bySource: {
web: number
api: number
twikoo: number
}
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
}
const {
items,
page: galleryPage,
totalPages: galleryTotalPages,
total: galleryTotal,
loading: galleryLoading,
searchQuery,
activeSearch,
listSummary,
refreshList,
initializeList,
setActiveFolder,
submitSearch,
goToPage,
removeItems
} = useImageList()
const { formatFileSize } = useFileSize()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const currentFolder = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
{ label: '全部', value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [
'var(--ui-primary)',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
]
const folderUploadItems = computed(() =>
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
key: item.folder,
label: item.folder,
count: item.count,
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
}))
)
const donutSegments = computed(() =>
folderUploadItems.value.map(item => ({
label: item.label,
value: item.count,
color: item.color
}))
)
const folderStats = computed(() => stats.value?.byFolder ?? [])
const folderMaxBytes = computed(() =>
Math.max(...folderStats.value.map(item => item.bytes), 1)
)
const uploadChartTotal = computed(() =>
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const statCards = computed(() => {
const s = stats.value
const base = [
{ label: '今日上传', value: s?.uploadToday ?? '—' },
{ label: '本月上传', value: s?.uploadMonth ?? '—' },
{ label: '累计上传', value: s?.uploadTotal ?? '—' },
{ label: '当前库存', value: s?.storedCount ?? '—' },
{ label: '今日删除', value: s?.deleteToday ?? '—' },
{ label: '本月删除', value: s?.deleteMonth ?? '—' },
isAdmin.value
? { label: '注册用户数量', value: s?.userCount ?? '—' }
: { label: '累计删除', value: s?.deleteTotal ?? '—' },
{ label: '累计上传体积', value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
]
return base
})
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function folderPercent(bytes: number) {
return Math.round((bytes / folderMaxBytes.value) * 100)
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include'
})
}
async function refreshStats() {
statsLoading.value = true
try {
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载统计失败', color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function refreshGallery() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await Promise.all([refreshList(), loadFolders()])
} catch (error: unknown) {
handleAuthError(error)
}
}
async function refreshAll() {
if (!isAuthenticated.value) return
currentFolder.value = 'all'
await Promise.all([
refreshStats(),
initializeList('all').then(() => loadFolders())
])
}
async function handleFolderChange(folder: string) {
if (!isAuthenticated.value) return
currentFolder.value = folder
selectedKeys.value = new Set()
try {
await setActiveFolder(folder)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await submitSearch()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function clearSearch() {
searchQuery.value = ''
if (!activeSearch.value) return
await refreshGallery()
}
function requestDelete(key: string) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: '请先选择图片', color: 'warning' })
return
}
deleteTargetKeys.value = Array.from(selectedKeys.value)
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
batchDeleting.value = true
for (const key of keys) {
deletingKeys.value.add(key)
}
const deleted: string[] = []
const failed: string[] = []
try {
const response = await $fetch<{
deleted: string[]
failed: Array<{ key: string }>
}>('/api/images/batch-delete', {
method: 'POST',
body: { keys }
})
deleted.push(...response.deleted)
failed.push(...response.failed.map(item => item.key))
if (deleted.length) {
removeItems(deleted)
for (const key of deleted) {
selectedKeys.value.delete(key)
}
selectedKeys.value = new Set(selectedKeys.value)
toast.add({
title: `已删除 ${deleted.length} 张图片`,
color: 'success'
})
await refreshStats()
}
if (failed.length) {
toast.add({
title: `${failed.length} 张图片删除失败`,
color: 'error'
})
}
deleteModalOpen.value = false
deleteTargetKeys.value = []
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '删除失败', color: 'error' })
}
} finally {
batchDeleting.value = false
deletingKeys.value = new Set()
}
}
async function handleGalleryPageChange(targetPage: number) {
selectedKeys.value = new Set()
try {
await goToPage(targetPage)
} catch (error: unknown) {
handleAuthError(error)
}
}
onMounted(async () => {
window.addEventListener('scroll', onWindowScroll, { passive: true })
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
onUnmounted(() => {
window.removeEventListener('scroll', onWindowScroll)
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await refreshAll()
} else if (!authed) {
stats.value = null
items.value = []
selectedKeys.value = new Set()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">
统计
</h1>
<p class="text-sm text-muted">
数据概览与图库记录
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
label="刷新"
variant="outline"
:loading="statsLoading || galleryLoading"
@click="refreshAll"
/>
</div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div
v-for="item in statCards"
:key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
>
<p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
上传目录分布
</h2>
<div
v-if="!uploadChartTotal"
class="py-6 text-center text-xs text-muted"
>
暂无数据
</div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
目录占用
</h2>
<div
v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted"
>
暂无数据
</div>
<ul
v-else
class="space-y-2.5"
>
<li
v-for="item in folderStats"
:key="item.folder"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span>
<span class="tabular-nums text-muted">
{{ item.count }} 张 · {{ formatFileSize(item.bytes) }}
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary/70"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
/>
</div>
</li>
</ul>
</div>
</section>
<section class="space-y-4">
<form
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
@submit.prevent="handleSearch"
>
<div class="min-w-0 shrink-0">
<h2 class="text-lg font-medium">
图库记录
</h2>
<p
v-if="!galleryLoading"
class="text-sm text-muted"
>
{{ listSummary }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted shrink-0">目录</span>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-28"
@update:model-value="handleFolderChange"
/>
</div>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="搜索文件名"
class="w-40 sm:w-48"
size="sm"
:disabled="galleryLoading"
/>
<UButton
type="submit"
label="搜索"
size="sm"
:loading="galleryLoading"
/>
<UButton
v-if="activeSearch"
type="button"
label="清除"
size="sm"
variant="ghost"
color="neutral"
@click="clearSearch"
/>
<UButton
type="button"
icon="i-lucide-refresh-cw"
label="刷新"
variant="outline"
size="sm"
:loading="galleryLoading"
@click="refreshGallery"
/>
<UBadge
v-if="selectedCount"
color="primary"
variant="subtle"
>
已选 {{ selectedCount }}
</UBadge>
<UButton
type="button"
icon="i-lucide-trash-2"
label="批量删除"
color="error"
variant="soft"
size="sm"
:disabled="!selectedCount"
@click="requestBatchDelete"
/>
</div>
</form>
<div
v-if="galleryLoading"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 12"
:key="n"
class="aspect-square rounded-lg"
/>
</div>
<ImageGrid
v-else
:items="items"
:selected-keys="selectedKeys"
:deleting-keys="deletingKeys"
@update:selected-keys="selectedKeys = $event"
@delete="requestDelete"
/>
<PaginationBar
v-if="!galleryLoading && galleryTotal > 0"
:page="galleryPage"
:total-pages="galleryTotalPages"
:total="galleryTotal"
unit="张"
:loading="galleryLoading"
@update:page="handleGalleryPageChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="batchDeleting"
@confirm="confirmDelete"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
aria-label="回到顶部"
color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg"
@click="scrollToTop"
/>
</AppShell>
</div>
</template>