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,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>
|
||||
Reference in new issue
Block a user