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

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

No files matched your search

+194
View File
@@ -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>
+198
View File
@@ -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>
+88
View File
@@ -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>
+46
View File
@@ -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>
+169
View File
@@ -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>
+53
View File
@@ -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>
+140
View File
@@ -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>
+80
View File
@@ -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>
+81
View File
@@ -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>
+111
View File
@@ -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>
+39
View File
@@ -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>
+221
View File
@@ -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>
+44
View File
@@ -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>