release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
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:
commit
bc4cba7e52
115 files changed
+26508
No files matched your search
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'PicHost - 个人图床',
|
||||
meta: [
|
||||
{ name: 'description', content: '个人自用的轻量图床系统' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtPage />
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
.upload-card-flip {
|
||||
perspective: 1200px;
|
||||
}
|
||||
|
||||
.upload-card-flip-inner {
|
||||
position: relative;
|
||||
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.upload-card-face {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.upload-card-face--back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.upload-card-surface {
|
||||
min-height: 20.5rem;
|
||||
border-radius: 1rem;
|
||||
border-width: 2px;
|
||||
border-style: dashed;
|
||||
border-color: var(--ui-border);
|
||||
background-color: color-mix(in oklab, var(--ui-bg-elevated) 50%, transparent);
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.upload-card-surface--panel {
|
||||
border-style: solid;
|
||||
background-color: var(--ui-bg-elevated);
|
||||
box-shadow: var(--ui-shadow-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.upload-card-surface {
|
||||
min-height: 22rem;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.upload-card-surface--panel {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-settings-btn .upload-settings-icon {
|
||||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.upload-settings-btn:hover .upload-settings-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 546 KiB |
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { login, fetchStatus, authStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const secret = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await router.replace('/setup')
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = legacyMode.value
|
||||
? await login({ secret: secret.value })
|
||||
: await login({ username: username.value.trim(), password: password.value })
|
||||
|
||||
if (result.ok) {
|
||||
if (result.needsMigration) {
|
||||
await router.replace('/setup?migrate=1')
|
||||
return
|
||||
}
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
secret.value = ''
|
||||
emit('success')
|
||||
toast.add({ title: '登录成功', color: 'success' })
|
||||
} else {
|
||||
toast.add({
|
||||
title: result.error ?? (legacyMode.value ? '登录失败,请检查密钥' : '用户名或密码错误'),
|
||||
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">
|
||||
个人轻量图床
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="flex w-full flex-col gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<template v-if="legacyMode">
|
||||
<div class="w-full space-y-2">
|
||||
<label
|
||||
for="admin-secret"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
管理密钥
|
||||
</label>
|
||||
<UInput
|
||||
id="admin-secret"
|
||||
v-model="secret"
|
||||
type="password"
|
||||
placeholder="输入 ADMIN_SECRET"
|
||||
autocomplete="current-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ root: 'w-full' }"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
遗留部署:使用密钥登录后将引导创建账号
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="w-full space-y-2">
|
||||
<label
|
||||
for="login-username"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<UInput
|
||||
id="login-username"
|
||||
v-model="username"
|
||||
placeholder="用户名"
|
||||
autocomplete="username"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ root: 'w-full' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full space-y-2">
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<UInput
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
autocomplete="current-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ root: 'w-full' }"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="登录"
|
||||
icon="i-lucide-log-in"
|
||||
size="lg"
|
||||
block
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="allowRegistration"
|
||||
class="text-center text-sm text-muted"
|
||||
>
|
||||
没有账号?
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
注册
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-xs leading-relaxed text-dimmed">
|
||||
未登录时无法访问上传和管理功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
|
||||
const route = useRoute()
|
||||
const colorMode = useColorMode()
|
||||
const { logout, user, isAdmin } = useAuth()
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ label: 'API', to: '/api', icon: 'i-lucide-code-2' },
|
||||
{ label: '统计', to: '/stats', icon: 'i-lucide-chart-column' }
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: '设置', to: '/settings', icon: 'i-lucide-settings' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const themeIcon = computed(() => {
|
||||
if (colorMode.preference === 'system') return 'i-lucide-monitor'
|
||||
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
|
||||
})
|
||||
|
||||
const themeItems = computed<DropdownMenuItem[][]>(() => [[
|
||||
{
|
||||
label: '浅色',
|
||||
icon: 'i-lucide-sun',
|
||||
type: 'checkbox',
|
||||
checked: colorMode.preference === 'light',
|
||||
onUpdateChecked(checked: boolean) {
|
||||
if (checked) colorMode.preference = 'light'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '深色',
|
||||
icon: 'i-lucide-moon',
|
||||
type: 'checkbox',
|
||||
checked: colorMode.preference === 'dark',
|
||||
onUpdateChecked(checked: boolean) {
|
||||
if (checked) colorMode.preference = 'dark'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '跟随系统',
|
||||
icon: 'i-lucide-monitor',
|
||||
type: 'checkbox',
|
||||
checked: colorMode.preference === 'system',
|
||||
onUpdateChecked(checked: boolean) {
|
||||
if (checked) colorMode.preference = 'system'
|
||||
}
|
||||
}
|
||||
]])
|
||||
|
||||
const userMenuItems = computed<DropdownMenuItem[][]>(() => {
|
||||
const items: DropdownMenuItem[] = [
|
||||
{
|
||||
label: '修改密码',
|
||||
icon: 'i-lucide-key-round',
|
||||
to: '/account/password'
|
||||
}
|
||||
]
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push({
|
||||
label: '系统设置',
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/settings'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: '退出登录',
|
||||
icon: 'i-lucide-log-out',
|
||||
onSelect() {
|
||||
void handleLogout()
|
||||
}
|
||||
})
|
||||
|
||||
return [items]
|
||||
})
|
||||
|
||||
function isNavActive(to: string) {
|
||||
return route.path.startsWith(to)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
title="返回上传"
|
||||
class="group flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
||||
>
|
||||
<img
|
||||
:src="logoLight"
|
||||
alt="PicHost"
|
||||
class="h-10 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<img
|
||||
:src="logoDark"
|
||||
alt="PicHost"
|
||||
class="hidden h-10 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<div class="min-w-0 leading-tight">
|
||||
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-muted sm:text-xs">
|
||||
个人轻量图床
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
|
||||
<nav class="flex flex-wrap items-center gap-1">
|
||||
<UButton
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
size="sm"
|
||||
:variant="isNavActive(item.to) ? 'soft' : 'ghost'"
|
||||
:color="isNavActive(item.to) ? 'primary' : 'neutral'"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="mx-1 hidden h-5 w-px bg-muted sm:block" />
|
||||
|
||||
<ClientOnly>
|
||||
<UDropdownMenu
|
||||
v-if="user"
|
||||
:items="userMenuItems"
|
||||
>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="max-w-[10rem] gap-1.5"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-user"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
<UDropdownMenu :items="themeItems">
|
||||
<UButton
|
||||
:icon="themeIcon"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
aria-label="切换主题"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
<template #fallback>
|
||||
<div class="size-8" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl flex-1 space-y-6 p-4 sm:p-6">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="px-4 py-6 text-center text-xs text-muted">
|
||||
Copyright © 2026 O96u
|
||||
·
|
||||
<a
|
||||
href="https://github.com/O96u/PicHost"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-highlighted"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
value: string
|
||||
iconOnly?: boolean
|
||||
icon?: string
|
||||
block?: boolean
|
||||
successTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copied: []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const copying = ref(false)
|
||||
|
||||
function copyWithFallback(text: string): boolean {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, text.length)
|
||||
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
function notifySuccess() {
|
||||
toast.add({
|
||||
title: props.successTitle ?? (props.label ? `已复制 ${props.label}` : '已复制到剪贴板'),
|
||||
color: 'success'
|
||||
})
|
||||
emit('copied')
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (copying.value || !props.value) return
|
||||
copying.value = true
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
notifySuccess()
|
||||
return
|
||||
} catch {
|
||||
// 内网 http://IP 等非安全上下文会到这里,继续走回退
|
||||
}
|
||||
}
|
||||
|
||||
if (copyWithFallback(props.value)) {
|
||||
notifySuccess()
|
||||
} else {
|
||||
toast.add({ title: '复制失败,请手动选中链接复制', color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
copying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
:loading="copying"
|
||||
:disabled="!value"
|
||||
:label="iconOnly ? undefined : label"
|
||||
:icon="icon"
|
||||
:class="block ? 'flex-1 justify-center' : undefined"
|
||||
:aria-label="iconOnly ? label : undefined"
|
||||
:title="iconOnly ? label : undefined"
|
||||
@click="copy"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
open: boolean
|
||||
count: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm': []
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
title="确认删除"
|
||||
:description="`确定要删除选中的 ${count} 张图片吗?此操作不可恢复。`"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
label="取消"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="close"
|
||||
/>
|
||||
<UButton
|
||||
label="确认删除"
|
||||
color="error"
|
||||
:loading="loading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
|
||||
type CopyFormat = 'url' | 'markdown' | 'html'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: ImageItem
|
||||
selected: boolean
|
||||
deleting?: boolean
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: boolean]
|
||||
'delete': []
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const imageError = ref(false)
|
||||
const copyFormat = ref<CopyFormat>('url')
|
||||
|
||||
const copyFormatItems = [
|
||||
{ label: '直链', value: 'url' as const },
|
||||
{ label: 'Markdown', value: 'markdown' as const },
|
||||
{ label: 'HTML', value: 'html' as const }
|
||||
]
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
try {
|
||||
return new Date(props.image.uploadedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return props.image.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
const previewValue = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return props.image.markdown
|
||||
case 'html':
|
||||
return props.image.html
|
||||
default:
|
||||
return props.image.url
|
||||
}
|
||||
})
|
||||
|
||||
const copySuccessTitle = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return '已复制 Markdown'
|
||||
case 'html':
|
||||
return '已复制 HTML'
|
||||
default:
|
||||
return '已复制直链'
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSelected(value: boolean | 'indeterminate') {
|
||||
emit('update:selected', value === true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
|
||||
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||
<img
|
||||
v-if="!imageError"
|
||||
:src="image.url"
|
||||
:alt="image.originalName"
|
||||
loading="lazy"
|
||||
class="absolute inset-0 size-full object-cover object-center"
|
||||
@error="imageError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-muted"
|
||||
>
|
||||
预览失败
|
||||
</div>
|
||||
<div
|
||||
v-if="selectable"
|
||||
class="absolute top-2 left-2"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected"
|
||||
@update:model-value="toggleSelected"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute top-2 right-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
color="error"
|
||||
variant="solid"
|
||||
icon="i-lucide-trash-2"
|
||||
aria-label="删除"
|
||||
:loading="deleting"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="image.owner"
|
||||
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
|
||||
:title="`上传者:${image.owner.username}`"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-user"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ image.owner.username }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
:title="image.originalName"
|
||||
>
|
||||
{{ image.originalName }}
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-1 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="() => { copyFormat = item.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="xs"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
label="复制"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showKey"
|
||||
class="truncate text-xs text-dimmed"
|
||||
:title="image.key"
|
||||
>
|
||||
{{ image.key }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
items: ImageItem[]
|
||||
selectedKeys: Set<string>
|
||||
deletingKeys: Set<string>
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
emptyText: '暂无图片'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'delete': [key: string]
|
||||
}>()
|
||||
|
||||
function updateSelection(key: string, selected: boolean) {
|
||||
const next = new Set(props.selectedKeys)
|
||||
if (selected) next.add(key)
|
||||
else next.delete(key)
|
||||
emit('update:selectedKeys', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<ImageCard
|
||||
v-for="image in items"
|
||||
:key="image.key"
|
||||
:image="image"
|
||||
:selected="selectedKeys.has(image.key)"
|
||||
:deleting="deletingKeys.has(image.key)"
|
||||
:selectable="selectable"
|
||||
:show-key="showKey"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@delete="emit('delete', image.key)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload': [files: File[]]
|
||||
'open-settings': []
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const hovering = ref(false)
|
||||
|
||||
const { bindPaste } = useClipboardImage(ref(null))
|
||||
|
||||
const isActive = computed(() => dragging.value || hovering.value)
|
||||
|
||||
bindPaste((files) => {
|
||||
if (!props.disabled) emit('upload', files)
|
||||
})
|
||||
|
||||
function openFilePicker() {
|
||||
if (props.disabled) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
if (files.length) emit('upload', files)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dragging.value = false
|
||||
if (props.disabled) return
|
||||
|
||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||
.filter(file => file.type.startsWith('image/'))
|
||||
if (files.length) emit('upload', files)
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
if (!props.disabled) dragging.value = true
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!props.disabled) hovering.value = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovering.value = false
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
if (props.disabled) return
|
||||
emit('open-settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="upload-card-surface group/upload relative text-center transition-all duration-300 ease-out"
|
||||
:class="isActive
|
||||
? 'scale-[1.01] border-primary bg-primary/5 shadow-md shadow-primary/10'
|
||||
: 'hover:border-primary/50 hover:bg-primary/[0.04] hover:shadow-sm'"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
|
||||
multiple
|
||||
class="hidden"
|
||||
:disabled="disabled"
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
<div class="absolute top-3 right-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
|
||||
aria-label="上传设置"
|
||||
:disabled="disabled"
|
||||
@click="openSettings"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-settings"
|
||||
class="upload-settings-icon size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-4">
|
||||
<div
|
||||
class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-primary/10 transition-transform duration-300 ease-out"
|
||||
:class="isActive ? 'scale-110' : 'group-hover/upload:scale-105'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-cloud-upload"
|
||||
class="size-8 text-primary transition-transform duration-300"
|
||||
:class="isActive ? '-translate-y-0.5' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xl font-semibold tracking-tight">
|
||||
拖拽图片到此处上传
|
||||
</p>
|
||||
<p class="text-sm text-muted">
|
||||
或点击选择文件,按
|
||||
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">Ctrl</kbd>
|
||||
+
|
||||
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">V</kbd>
|
||||
粘贴图片
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-dimmed">
|
||||
支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张
|
||||
</p>
|
||||
<UButton
|
||||
label="选择图片"
|
||||
icon="i-lucide-upload"
|
||||
size="lg"
|
||||
:disabled="disabled"
|
||||
@click="openFilePicker"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
unit?: string
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number]
|
||||
}>()
|
||||
|
||||
const unitLabel = computed(() => props.unit ?? '条')
|
||||
|
||||
function goTo(target: number) {
|
||||
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
|
||||
return
|
||||
}
|
||||
emit('update:page', target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex flex-col items-center gap-2 border-t border-default pt-4 sm:flex-row sm:justify-between"
|
||||
>
|
||||
<p
|
||||
v-if="totalPages > 1"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} {{ unitLabel }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
共 {{ total }} {{ unitLabel }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-chevrons-left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="第一页"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(1)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="上一页"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(page - 1)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-right"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="下一页"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(page + 1)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevrons-right"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="最后一页"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(totalPages)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { analyzePassword, PASSWORD_MIN_LENGTH } from '~/utils/password-strength'
|
||||
|
||||
const props = defineProps<{
|
||||
password: string
|
||||
}>()
|
||||
|
||||
const analysis = computed(() => analyzePassword(props.password))
|
||||
|
||||
const show = computed(() => props.password.length > 0)
|
||||
|
||||
const labelColor = computed(() => {
|
||||
switch (analysis.value.label) {
|
||||
case '强':
|
||||
return 'text-success'
|
||||
case '中':
|
||||
return 'text-warning'
|
||||
default:
|
||||
return 'text-error'
|
||||
}
|
||||
})
|
||||
|
||||
const barColor = computed(() => {
|
||||
switch (analysis.value.label) {
|
||||
case '强':
|
||||
return 'bg-success'
|
||||
case '中':
|
||||
return 'bg-warning'
|
||||
default:
|
||||
return 'bg-error/80'
|
||||
}
|
||||
})
|
||||
|
||||
const rules = computed(() => [
|
||||
{ key: 'minLength', label: `至少 ${PASSWORD_MIN_LENGTH} 个字符` },
|
||||
{ key: 'mixedCase', label: '包含大小写字母' },
|
||||
{ key: 'hasNumber', label: '包含数字' },
|
||||
{ key: 'hasSpecial', label: '包含特殊字符' }
|
||||
] as const)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="space-y-3 rounded-lg border border-default bg-muted/20 px-3 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted">密码强度</span>
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="labelColor"
|
||||
>
|
||||
{{ analysis.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<span
|
||||
v-for="index in 4"
|
||||
:key="index"
|
||||
class="h-1.5 flex-1 rounded-full transition-colors"
|
||||
:class="index <= analysis.score ? barColor : 'bg-default'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||
<li
|
||||
v-for="rule in rules"
|
||||
:key="rule.key"
|
||||
class="flex items-center gap-1.5 transition-colors"
|
||||
:class="analysis.criteria[rule.key] ? 'text-success' : 'text-muted'"
|
||||
>
|
||||
<UIcon
|
||||
:name="analysis.criteria[rule.key] ? 'i-lucide-check' : 'i-lucide-circle'"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span>{{ rule.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
export interface DonutSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
segments: DonutSegment[]
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const total = computed(() =>
|
||||
props.segments.reduce((sum, item) => sum + item.value, 0)
|
||||
)
|
||||
|
||||
const gradient = computed(() => {
|
||||
if (!total.value) {
|
||||
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
const stops = props.segments
|
||||
.filter(item => item.value > 0)
|
||||
.map((item) => {
|
||||
const start = offset
|
||||
const percent = (item.value / total.value) * 100
|
||||
offset += percent
|
||||
return `${item.color} ${start}% ${offset}%`
|
||||
})
|
||||
|
||||
if (!stops.length) {
|
||||
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
|
||||
}
|
||||
|
||||
return `conic-gradient(${stops.join(', ')})`
|
||||
})
|
||||
|
||||
function percent(value: number) {
|
||||
if (!total.value) return 0
|
||||
return Math.round((value / total.value) * 100)
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
|
||||
}
|
||||
if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const centerText = computed(() => formatCount(total.value))
|
||||
|
||||
const centerTextClass = computed(() => {
|
||||
const raw = String(total.value)
|
||||
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
|
||||
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
|
||||
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
|
||||
return 'text-lg font-semibold leading-none'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="relative shrink-0 rounded-full"
|
||||
:style="{
|
||||
width: `${size ?? 88}px`,
|
||||
height: `${size ?? 88}px`,
|
||||
background: gradient
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="max-w-full truncate tabular-nums"
|
||||
:class="centerTextClass"
|
||||
:title="String(total)"
|
||||
>
|
||||
{{ centerText }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[10px] text-muted">
|
||||
次
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="min-w-0 flex-1 space-y-1.5">
|
||||
<li
|
||||
v-for="item in segments"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ background: item.color }"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
||||
<span
|
||||
class="shrink-0 tabular-nums text-muted"
|
||||
:title="`${item.value}`"
|
||||
>
|
||||
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [files: File[]]
|
||||
}>()
|
||||
|
||||
const flipped = ref(false)
|
||||
|
||||
function onOpenSettings() {
|
||||
flipped.value = true
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
flipped.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="upload-card-flip"
|
||||
:class="{ 'is-flipped': flipped }"
|
||||
>
|
||||
<div class="upload-card-flip-inner">
|
||||
<div class="upload-card-face upload-card-face--front">
|
||||
<ImageUploader
|
||||
:disabled="disabled"
|
||||
@upload="emit('upload', $event)"
|
||||
@open-settings="onOpenSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="upload-card-face upload-card-face--back">
|
||||
<UploadPreferencesPanel @back="onBack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>(),
|
||||
{
|
||||
embedded: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
compressEnabled,
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
loadPreferences
|
||||
} = useUploadPreferences()
|
||||
|
||||
const {
|
||||
userSettings,
|
||||
enabled: userAutoDeleteEnabled,
|
||||
daysDraft: userAutoDeleteDaysDraft,
|
||||
load: loadUserSettings
|
||||
} = useUserAutoDeleteSettings()
|
||||
|
||||
const copyFormatItems = [
|
||||
{ label: '直链', value: 'url' as const },
|
||||
{ label: 'Markdown', value: 'markdown' as const },
|
||||
{ label: 'HTML', value: 'html' as const }
|
||||
]
|
||||
|
||||
const clientQualityLevel = computed(() => {
|
||||
if (clientWebpQuality.value >= 85) return '高质量'
|
||||
if (clientWebpQuality.value >= 60) return '中等质量'
|
||||
return '有损压缩'
|
||||
})
|
||||
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
function onClientQualityInput(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPreferences()
|
||||
void loadUserSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="
|
||||
embedded ? '' : 'upload-card-surface upload-card-surface--panel h-full'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-settings-2"
|
||||
class="mt-0.5 size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold">
|
||||
上传偏好
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
浏览器本地与账号设置,仅影响您的上传体验
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="!embedded"
|
||||
label="返回"
|
||||
icon="i-lucide-arrow-left"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
aria-label="返回上传"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-5 sm:p-6"
|
||||
:class="embedded ? '' : 'flex flex-1 flex-col justify-center'"
|
||||
>
|
||||
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
图片处理
|
||||
</h3>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
<UCheckbox
|
||||
v-model="compressEnabled"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm">客户端预压缩</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
上传前在浏览器端压缩(服务端仍会转 WebP)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="userSettings"
|
||||
class="border-t border-default pt-4"
|
||||
>
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
<UCheckbox
|
||||
v-model="userAutoDeleteEnabled"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span
|
||||
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
|
||||
>
|
||||
<span>自动删除超过</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>天的图片</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
仅清理开启后上传的图片,不影响此前已存的图片
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex justify-center border-t border-default py-4"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-5 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
WebP 压缩质量
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
客户端预压缩质量(1–100),需开启「客户端预压缩」
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-sm text-muted"
|
||||
>
|
||||
<span>当前:{{ clientWebpQuality }}</span>
|
||||
<span>{{ clientQualityLevel }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-default accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
便捷功能
|
||||
</h3>
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
<UCheckbox
|
||||
v-model="autoCopyMarkdown"
|
||||
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
|
||||
class="ml-7 flex gap-1 rounded-lg border border-default p-0.5"
|
||||
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||
>
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
@click="setCopyFormat(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProgressItem } from '~/types/image'
|
||||
|
||||
defineProps<{
|
||||
items: UploadProgressItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="space-y-2 rounded-lg border border-default p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-muted">
|
||||
上传进度
|
||||
</p>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
<UBadge
|
||||
:color="item.status === 'success' ? 'success' : item.status === 'error' ? 'error' : 'primary'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ item.status === 'pending' ? '等待' : item.status === 'uploading' ? '上传中' : item.status === 'success' ? '成功' : '失败' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<UProgress
|
||||
:model-value="item.progress"
|
||||
size="xs"
|
||||
/>
|
||||
<p
|
||||
v-if="item.error"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
{{ item.error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,299 @@
|
||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
initialized: boolean
|
||||
allowRegistration: boolean
|
||||
legacyMode: boolean
|
||||
needsMigration: boolean
|
||||
user: AuthUser | null
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& 'statusCode' in error
|
||||
&& (error as { statusCode: number }).statusCode === 401
|
||||
}
|
||||
|
||||
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
||||
export const AUTH_MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
export function validateAuthInput(username: string, password: string): string | null {
|
||||
const trimmed = username.trim()
|
||||
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
|
||||
return '用户名需为 3–32 位字母、数字或下划线'
|
||||
}
|
||||
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||
return '密码至少 8 位'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
||||
if (!error || typeof error !== 'object') return fallback
|
||||
|
||||
const e = error as {
|
||||
data?: {
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
data?: { error?: { message?: string } }
|
||||
}
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const nested = e.data?.data?.error?.message
|
||||
?? e.data?.error?.message
|
||||
?? e.data?.message
|
||||
if (nested) return nested
|
||||
|
||||
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
|
||||
return e.statusMessage
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||
|
||||
export function useAuth() {
|
||||
const status = useState<AuthStatus>('auth-status', () => 'checking')
|
||||
const user = useState<AuthUser | null>('auth-user', () => null)
|
||||
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
|
||||
|
||||
const isChecking = computed(() => status.value === 'checking')
|
||||
const isAuthenticated = computed(() => status.value === 'authenticated')
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
async function fetchStatus(): Promise<AuthStatusResponse> {
|
||||
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
|
||||
credentials: 'include'
|
||||
})
|
||||
authStatus.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
status.value = 'checking'
|
||||
try {
|
||||
const data = await fetchStatus()
|
||||
if (data.user) {
|
||||
user.value = data.user
|
||||
status.value = 'authenticated'
|
||||
return true
|
||||
}
|
||||
user.value = null
|
||||
status.value = 'unauthenticated'
|
||||
return false
|
||||
} catch {
|
||||
user.value = null
|
||||
status.value = 'unauthenticated'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markUnauthenticated() {
|
||||
status.value = 'unauthenticated'
|
||||
user.value = null
|
||||
}
|
||||
|
||||
async function login(
|
||||
credentials: { username: string, password: string }
|
||||
| { secret: string }
|
||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||
try {
|
||||
const result = await $fetch<{
|
||||
success: boolean
|
||||
needsMigration?: boolean
|
||||
user?: AuthUser
|
||||
}>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: credentials
|
||||
})
|
||||
if (result.needsMigration) {
|
||||
return { ok: true, needsMigration: true }
|
||||
}
|
||||
if (result.user) {
|
||||
user.value = result.user
|
||||
}
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function register(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(username, password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/auth/register',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { username: username.trim(), password }
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '注册失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(input: {
|
||||
username: string
|
||||
password: string
|
||||
allowRegistration: boolean
|
||||
}): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(input.username, input.password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/setup',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
allowRegistration: input.allowRegistration
|
||||
}
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(input: {
|
||||
username: string
|
||||
password: string
|
||||
allowRegistration: boolean
|
||||
}): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(input.username, input.password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/auth/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
allowRegistration: input.allowRegistration
|
||||
}
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<AuthActionResult> {
|
||||
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, error: '新密码至少 8 位' }
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/password', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { currentPassword, newPassword }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '修改密码失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// 忽略登出失败
|
||||
} finally {
|
||||
status.value = 'unauthenticated'
|
||||
user.value = null
|
||||
authStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthError(error: unknown) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
markUnauthenticated()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
user,
|
||||
authStatus,
|
||||
isChecking,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
checkSession,
|
||||
fetchStatus,
|
||||
markUnauthenticated,
|
||||
login,
|
||||
register,
|
||||
setup,
|
||||
migrate,
|
||||
changePassword,
|
||||
logout,
|
||||
handleAuthError,
|
||||
getFetchErrorMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const MAX_WIDTH = 2560
|
||||
const QUALITY = 0.85
|
||||
|
||||
export interface CompressOptions {
|
||||
enabled: boolean
|
||||
maxWidth?: number
|
||||
quality?: number
|
||||
}
|
||||
|
||||
function shouldCompress(file: File): boolean {
|
||||
return file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
}
|
||||
|
||||
async function compressImage(
|
||||
file: File,
|
||||
maxWidth: number,
|
||||
quality: number
|
||||
): Promise<File> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const scale = Math.min(1, maxWidth / bitmap.width)
|
||||
const width = Math.round(bitmap.width * scale)
|
||||
const height = Math.round(bitmap.height * scale)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
bitmap.close()
|
||||
return file
|
||||
}
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/webp', quality)
|
||||
})
|
||||
|
||||
if (!blob) return file
|
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
||||
return new File([blob], `${baseName}.webp`, { type: 'image/webp' })
|
||||
}
|
||||
|
||||
export async function prepareFilesForUpload(
|
||||
files: File[],
|
||||
options: CompressOptions
|
||||
): Promise<File[]> {
|
||||
if (!options.enabled) return files
|
||||
|
||||
const maxWidth = options.maxWidth ?? MAX_WIDTH
|
||||
const quality = options.quality ?? QUALITY
|
||||
|
||||
const prepared: File[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!shouldCompress(file)) {
|
||||
prepared.push(file)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
prepared.push(await compressImage(file, maxWidth, quality))
|
||||
} catch {
|
||||
prepared.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return prepared
|
||||
}
|
||||
|
||||
export function useClipboardImage(_target?: Ref<HTMLElement | null>) {
|
||||
function onPaste(event: ClipboardEvent, onFiles: (files: File[]) => void) {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items?.length) return
|
||||
|
||||
const files: File[] = []
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
event.preventDefault()
|
||||
onFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
function bindPaste(onFiles: (files: File[]) => void) {
|
||||
const handler = (event: ClipboardEvent) => onPaste(event, onFiles)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('paste', handler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('paste', handler)
|
||||
})
|
||||
}
|
||||
|
||||
return { bindPaste, onPaste }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function useFileSize() {
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
return { formatFileSize }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
ImageItem,
|
||||
ImageListResponse
|
||||
} from '~/types/image'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
export function useImageList() {
|
||||
const items = ref<ImageItem[]>([])
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const folderTotal = ref<number | null>(null)
|
||||
const loadingTotal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const activeFolder = ref('all')
|
||||
|
||||
function folderQueryParams(): Record<string, string> {
|
||||
const folder = activeFolder.value.trim()
|
||||
if (!folder || folder === 'all') return {}
|
||||
return { folder }
|
||||
}
|
||||
|
||||
function matchesActiveFolder(key: string) {
|
||||
const folder = activeFolder.value.trim()
|
||||
if (!folder || folder === 'all') return true
|
||||
return key.startsWith(`${folder}/`)
|
||||
}
|
||||
|
||||
async function fetchTotal() {
|
||||
loadingTotal.value = true
|
||||
try {
|
||||
const data = await $fetch<{ total: number }>('/api/images/count', {
|
||||
credentials: 'include',
|
||||
query: folderQueryParams()
|
||||
})
|
||||
folderTotal.value = data.total
|
||||
} catch {
|
||||
folderTotal.value = null
|
||||
} finally {
|
||||
loadingTotal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(targetPage = 1) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<ImageListResponse>('/api/images', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
total.value = response.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSearch(targetPage = 1) {
|
||||
const q = activeSearch.value.trim()
|
||||
if (!q) {
|
||||
await fetchList(1)
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<ImageListResponse>('/api/images/search', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
q,
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
total.value = response.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
activeSearch.value = ''
|
||||
searchQuery.value = ''
|
||||
page.value = 1
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
|
||||
async function initializeList(folder = 'all') {
|
||||
const next = folder.trim() || 'all'
|
||||
activeFolder.value = next
|
||||
page.value = 1
|
||||
activeSearch.value = ''
|
||||
searchQuery.value = ''
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
|
||||
async function setActiveFolder(folder: string) {
|
||||
const next = folder.trim() || 'all'
|
||||
if (next === activeFolder.value) return
|
||||
activeFolder.value = next
|
||||
page.value = 1
|
||||
if (activeSearch.value) {
|
||||
await Promise.all([fetchSearch(1), fetchTotal()])
|
||||
} else {
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
const q = searchQuery.value.trim()
|
||||
activeSearch.value = q
|
||||
page.value = 1
|
||||
if (!q) {
|
||||
await refreshList()
|
||||
return
|
||||
}
|
||||
await fetchSearch(1)
|
||||
}
|
||||
|
||||
async function goToPage(targetPage: number) {
|
||||
if (targetPage < 1 || targetPage > totalPages.value || loading.value) return
|
||||
if (activeSearch.value) {
|
||||
await fetchSearch(targetPage)
|
||||
} else {
|
||||
await fetchList(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
function prependItems(newItems: ImageItem[]) {
|
||||
const matched = newItems.filter(item => matchesActiveFolder(item.key))
|
||||
if (!matched.length || page.value !== 1) return
|
||||
items.value = [...matched, ...items.value]
|
||||
total.value += matched.length
|
||||
if (folderTotal.value !== null) {
|
||||
folderTotal.value += matched.length
|
||||
}
|
||||
}
|
||||
|
||||
function removeItems(keys: string[]) {
|
||||
const keySet = new Set(keys)
|
||||
const removed = items.value.filter(item => keySet.has(item.key)).length
|
||||
items.value = items.value.filter(item => !keySet.has(item.key))
|
||||
if (removed > 0) {
|
||||
total.value = Math.max(0, total.value - removed)
|
||||
}
|
||||
if (folderTotal.value !== null && removed > 0) {
|
||||
folderTotal.value = Math.max(0, folderTotal.value - removed)
|
||||
}
|
||||
}
|
||||
|
||||
const listSummary = computed(() => {
|
||||
const folder = activeFolder.value.trim() || 'all'
|
||||
const folderLabel = folder === 'all' ? '全部' : folder
|
||||
if (loadingTotal.value && folderTotal.value === null) {
|
||||
return `${folderLabel} · 统计中…`
|
||||
}
|
||||
if (activeSearch.value) {
|
||||
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
|
||||
}
|
||||
if (folderTotal.value === null) {
|
||||
return `${folderLabel} · 共 ${total.value} 张`
|
||||
}
|
||||
return `${folderLabel} · 共 ${folderTotal.value} 张`
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
loading,
|
||||
activeFolder,
|
||||
searchQuery,
|
||||
activeSearch,
|
||||
listSummary,
|
||||
fetchList,
|
||||
fetchTotal,
|
||||
refreshList,
|
||||
initializeList,
|
||||
setActiveFolder,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
prependItems,
|
||||
removeItems
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
UploadProgressItem,
|
||||
UploadResponse
|
||||
} from '~/types/image'
|
||||
import { isUnauthorizedError } from './useAuth'
|
||||
import { prepareFilesForUpload } from './useClipboardImage'
|
||||
|
||||
const MAX_FILES = 10
|
||||
|
||||
export function useImageUpload() {
|
||||
const toast = useToast()
|
||||
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
|
||||
const uploading = ref(false)
|
||||
const progressItems = ref<UploadProgressItem[]>([])
|
||||
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||
|
||||
async function uploadFiles(
|
||||
files: File[],
|
||||
folder = 'images',
|
||||
options?: { notify?: boolean, includeFolder?: boolean }
|
||||
) {
|
||||
if (uploading.value) return
|
||||
|
||||
if (!files.length) {
|
||||
toast.add({ title: '请选择图片', color: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length > MAX_FILES) {
|
||||
toast.add({
|
||||
title: `每次最多上传 ${MAX_FILES} 张图片`,
|
||||
color: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
progressItems.value = files.map(file => ({
|
||||
name: file.name,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
try {
|
||||
const prepared = await prepareFilesForUpload(files, {
|
||||
enabled: compressEnabled.value,
|
||||
quality: clientWebpQuality.value / 100
|
||||
})
|
||||
|
||||
progressItems.value = prepared.map((file, index) => ({
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
progress: Math.round(((index + 1) / prepared.length) * 50)
|
||||
}))
|
||||
|
||||
const formData = new FormData()
|
||||
if (options?.includeFolder !== false) {
|
||||
formData.append('folder', folder.trim() || 'images')
|
||||
}
|
||||
for (const file of prepared) {
|
||||
formData.append('files', file, file.name)
|
||||
}
|
||||
|
||||
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
lastUploadResult.value = response
|
||||
|
||||
progressItems.value = prepared.map((file) => {
|
||||
const failed = response.errors.find(error => error.name === file.name)
|
||||
return {
|
||||
name: file.name,
|
||||
status: failed ? 'error' : 'success',
|
||||
progress: 100,
|
||||
error: failed?.error.message
|
||||
}
|
||||
})
|
||||
|
||||
if (response.items.length && options?.notify !== false) {
|
||||
toast.add({
|
||||
title: `成功上传 ${response.items.length} 张图片`,
|
||||
color: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
if (response.errors.length) {
|
||||
toast.add({
|
||||
title: `${response.errors.length} 张图片上传失败`,
|
||||
color: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: unknown) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '上传失败'
|
||||
progressItems.value = progressItems.value.map(item => ({
|
||||
...item,
|
||||
status: 'error',
|
||||
progress: 100,
|
||||
error: message
|
||||
}))
|
||||
toast.add({ title: message, color: 'error' })
|
||||
throw error
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploading,
|
||||
progressItems,
|
||||
lastUploadResult,
|
||||
uploadFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||
|
||||
export type CopyFormat = 'url' | 'markdown' | 'html'
|
||||
|
||||
export interface UploadPreferences {
|
||||
defaultFolder: string
|
||||
compressEnabled: boolean
|
||||
autoCopyMarkdown: boolean
|
||||
copyFormat: CopyFormat
|
||||
clientWebpQuality: number
|
||||
}
|
||||
|
||||
const DEFAULTS: UploadPreferences = {
|
||||
defaultFolder: 'images',
|
||||
compressEnabled: false,
|
||||
autoCopyMarkdown: false,
|
||||
copyFormat: 'markdown',
|
||||
clientWebpQuality: 80
|
||||
}
|
||||
|
||||
function clampQuality(value: unknown): number {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
||||
return Math.min(100, Math.max(1, Math.round(num)))
|
||||
}
|
||||
|
||||
function isCopyFormat(value: unknown): value is CopyFormat {
|
||||
return value === 'url' || value === 'markdown' || value === 'html'
|
||||
}
|
||||
|
||||
function readStored(): UploadPreferences {
|
||||
if (import.meta.server) return { ...DEFAULTS }
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||
return {
|
||||
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
|
||||
? parsed.defaultFolder.trim()
|
||||
: DEFAULTS.defaultFolder,
|
||||
compressEnabled: parsed.compressEnabled === true,
|
||||
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(prefs: UploadPreferences) {
|
||||
if (import.meta.server) return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||
}
|
||||
|
||||
export function useUploadPreferences() {
|
||||
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
|
||||
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
||||
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
||||
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
||||
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
||||
const loaded = useState('upload-pref-loaded', () => false)
|
||||
|
||||
function loadPreferences() {
|
||||
const stored = readStored()
|
||||
defaultFolder.value = stored.defaultFolder
|
||||
compressEnabled.value = stored.compressEnabled
|
||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||
copyFormat.value = stored.copyFormat
|
||||
clientWebpQuality.value = stored.clientWebpQuality
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
writeStored({
|
||||
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
|
||||
compressEnabled: compressEnabled.value,
|
||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||
copyFormat: copyFormat.value,
|
||||
clientWebpQuality: clampQuality(clientWebpQuality.value)
|
||||
})
|
||||
}
|
||||
|
||||
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
|
||||
if (loaded.value) {
|
||||
savePreferences()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
defaultFolder,
|
||||
compressEnabled,
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
loaded,
|
||||
loadPreferences,
|
||||
savePreferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export function useUserAutoDeleteSettings() {
|
||||
const { isAuthenticated, handleAuthError } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
|
||||
const enabled = ref(false)
|
||||
const daysDraft = ref(30)
|
||||
const hydrated = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
let daysSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function apply(data: { autoDeleteDays: number }) {
|
||||
hydrated.value = false
|
||||
userSettings.value = data
|
||||
enabled.value = data.autoDeleteDays > 0
|
||||
daysDraft.value = data.autoDeleteDays > 0 ? data.autoDeleteDays : 30
|
||||
nextTick(() => {
|
||||
hydrated.value = true
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
apply(data)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '加载设置失败', color: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!isAuthenticated.value || !userSettings.value) return
|
||||
|
||||
const days = enabled.value ? daysDraft.value : 0
|
||||
if (enabled.value && (days < 1 || days > 3650)) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { autoDeleteDays: days }
|
||||
})
|
||||
apply(data)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '保存设置失败', color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(enabled, () => {
|
||||
if (!hydrated.value) return
|
||||
void save()
|
||||
})
|
||||
|
||||
watch(daysDraft, () => {
|
||||
if (!hydrated.value || !enabled.value) return
|
||||
if (daysSaveTimer) clearTimeout(daysSaveTimer)
|
||||
daysSaveTimer = setTimeout(() => {
|
||||
void save()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return {
|
||||
userSettings,
|
||||
enabled,
|
||||
daysDraft,
|
||||
saving,
|
||||
load
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: <token></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,75 @@
|
||||
export type ErrorCode
|
||||
= | 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'CONFLICT'
|
||||
| 'INVALID_REQUEST'
|
||||
| 'TOO_MANY_FILES'
|
||||
| 'FILE_TOO_LARGE'
|
||||
| 'UNSUPPORTED_FILE_TYPE'
|
||||
| 'INVALID_FILE_SIGNATURE'
|
||||
| 'INVALID_IMAGE_KEY'
|
||||
| 'IMAGE_NOT_FOUND'
|
||||
| 'UPLOAD_FAILED'
|
||||
| 'DELETE_FAILED'
|
||||
|
||||
export interface ApiError {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
success: false
|
||||
error: ApiError
|
||||
}
|
||||
|
||||
export interface ImageItem {
|
||||
key: string
|
||||
url: string
|
||||
originalName: string
|
||||
contentType: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
markdown: string
|
||||
html: string
|
||||
/** 管理员列表可见:上传者信息 */
|
||||
owner?: {
|
||||
userId: number | null
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadErrorItem {
|
||||
name: string
|
||||
error: ApiError
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
success: boolean
|
||||
items: ImageItem[]
|
||||
errors: UploadErrorItem[]
|
||||
}
|
||||
|
||||
export interface ImageListResponse {
|
||||
items: ImageItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface DeleteResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface BatchDeleteResponse {
|
||||
success: boolean
|
||||
deleted: string[]
|
||||
failed: Array<{ key: string, error: ApiError }>
|
||||
}
|
||||
|
||||
export interface UploadProgressItem {
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'success' | 'error'
|
||||
progress: number
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const PASSWORD_MIN_LENGTH = 8
|
||||
|
||||
export interface PasswordCriteria {
|
||||
minLength: boolean
|
||||
mixedCase: boolean
|
||||
hasNumber: boolean
|
||||
hasSpecial: boolean
|
||||
}
|
||||
|
||||
export type PasswordStrengthLabel = '弱' | '中' | '强'
|
||||
|
||||
export function analyzePassword(password: string): {
|
||||
criteria: PasswordCriteria
|
||||
score: number
|
||||
label: PasswordStrengthLabel
|
||||
isValid: boolean
|
||||
} {
|
||||
const criteria: PasswordCriteria = {
|
||||
minLength: password.length >= PASSWORD_MIN_LENGTH,
|
||||
mixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSpecial: /[^a-zA-Z0-9]/.test(password)
|
||||
}
|
||||
|
||||
const score = Object.values(criteria).filter(Boolean).length
|
||||
const label: PasswordStrengthLabel
|
||||
= score <= 1 ? '弱' : score <= 3 ? '中' : '强'
|
||||
|
||||
return {
|
||||
criteria,
|
||||
score,
|
||||
label,
|
||||
isValid: criteria.minLength
|
||||
}
|
||||
}
|
||||
|
||||
export function isPasswordValid(password: string): boolean {
|
||||
return analyzePassword(password).isValid
|
||||
}
|
||||
Reference in new issue
Block a user