release: v1.0.2
Publish Docker image / build-and-push (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build-and-push (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Add i18n (zh-CN/en), mobile navigation menus, stats and upload UI improvements, and Docker reset-password CLI for emergency recovery.
This commit is contained in:
1 parent
074e4502c2
commit
22cb2ca8bc
46 files changed
+3724
-587
No files matched your search
@@ -9,6 +9,7 @@ const emit = defineEmits<{
|
||||
const router = useRouter()
|
||||
const { login, fetchStatus, authStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
@@ -47,10 +48,10 @@ async function submit() {
|
||||
password.value = ''
|
||||
secret.value = ''
|
||||
emit('success')
|
||||
toast.add({ title: '登录成功', color: 'success' })
|
||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||
} else {
|
||||
toast.add({
|
||||
title: result.error ?? (legacyMode.value ? '登录失败,请检查密钥' : '用户名或密码错误'),
|
||||
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
@@ -61,7 +62,10 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
|
||||
<div class="relative flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
|
||||
<div class="absolute top-4 right-4">
|
||||
<AuthPagePreferences />
|
||||
</div>
|
||||
<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">
|
||||
@@ -87,7 +91,7 @@ async function submit() {
|
||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
个人轻量图床
|
||||
{{ t('app.tagline') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,13 +106,13 @@ async function submit() {
|
||||
for="admin-secret"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
管理密钥
|
||||
{{ t('auth.adminSecret') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="admin-secret"
|
||||
v-model="secret"
|
||||
type="password"
|
||||
placeholder="输入 ADMIN_SECRET"
|
||||
:placeholder="t('auth.adminSecretPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -116,7 +120,7 @@ async function submit() {
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
遗留部署:使用密钥登录后将引导创建账号
|
||||
{{ t('auth.legacyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -127,12 +131,12 @@ async function submit() {
|
||||
for="login-username"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
用户名
|
||||
{{ t('auth.username') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="login-username"
|
||||
v-model="username"
|
||||
placeholder="用户名"
|
||||
:placeholder="t('auth.username')"
|
||||
autocomplete="username"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -145,13 +149,13 @@ async function submit() {
|
||||
for="login-password"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
密码
|
||||
{{ t('auth.password') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:placeholder="t('auth.password')"
|
||||
autocomplete="current-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -163,7 +167,7 @@ async function submit() {
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="登录"
|
||||
:label="t('auth.login')"
|
||||
icon="i-lucide-log-in"
|
||||
size="lg"
|
||||
block
|
||||
@@ -176,18 +180,18 @@ async function submit() {
|
||||
v-if="allowRegistration"
|
||||
class="text-center text-sm text-muted"
|
||||
>
|
||||
没有账号?
|
||||
{{ t('auth.noAccount') }}
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
注册
|
||||
{{ t('auth.register') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-xs leading-relaxed text-dimmed">
|
||||
未登录时无法访问上传和管理功能
|
||||
{{ t('auth.loginFooter') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+107
-116
@@ -4,59 +4,27 @@ 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 { t } = useI18n()
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
const mobileUserOpen = ref(false)
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ label: 'API', to: '/api', icon: 'i-lucide-code-2' },
|
||||
{ label: '统计', to: '/stats', icon: 'i-lucide-chart-column' }
|
||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
||||
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: '设置', to: '/settings', icon: 'i-lucide-settings' })
|
||||
items.push({ label: t('nav.settings'), 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 accountMenuItems = computed<DropdownMenuItem[]>(() => {
|
||||
const items: DropdownMenuItem[] = [
|
||||
{
|
||||
label: '修改密码',
|
||||
label: t('nav.changePassword'),
|
||||
icon: 'i-lucide-key-round',
|
||||
to: '/account/password'
|
||||
}
|
||||
@@ -64,27 +32,39 @@ const userMenuItems = computed<DropdownMenuItem[][]>(() => {
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push({
|
||||
label: '系统设置',
|
||||
label: t('nav.systemSettings'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/settings'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: '退出登录',
|
||||
label: t('nav.logout'),
|
||||
icon: 'i-lucide-log-out',
|
||||
onSelect() {
|
||||
void handleLogout()
|
||||
}
|
||||
})
|
||||
|
||||
return [items]
|
||||
return items
|
||||
})
|
||||
|
||||
const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||
accountMenuItems.value
|
||||
])
|
||||
|
||||
function isNavActive(to: string) {
|
||||
return route.path.startsWith(to)
|
||||
}
|
||||
|
||||
watch(mobileNavOpen, (open) => {
|
||||
if (open) mobileUserOpen.value = false
|
||||
})
|
||||
|
||||
watch(mobileUserOpen, (open) => {
|
||||
if (open) mobileNavOpen.value = false
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
}
|
||||
@@ -93,83 +73,94 @@ async function handleLogout() {
|
||||
<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"
|
||||
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
:title="t('nav.backToUpload')"
|
||||
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
||||
>
|
||||
<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" />
|
||||
<img
|
||||
:src="logoLight"
|
||||
alt="PicHost"
|
||||
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<img
|
||||
:src="logoDark"
|
||||
alt="PicHost"
|
||||
class="hidden h-9 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 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
|
||||
{{ t('app.tagline') }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<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"
|
||||
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<nav class="hidden items-center gap-1 sm:flex">
|
||||
<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"
|
||||
/>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-3.5 shrink-0 opacity-60"
|
||||
</nav>
|
||||
|
||||
<div class="mx-1 hidden h-5 w-px bg-muted sm:block" />
|
||||
|
||||
<div class="hidden items-center gap-0.5 sm:flex">
|
||||
<LocaleMenu />
|
||||
<ThemeMenu />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 sm:hidden">
|
||||
<MobileUserMenu
|
||||
v-if="user"
|
||||
v-model:open="mobileUserOpen"
|
||||
/>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
<UDropdownMenu :items="themeItems">
|
||||
<UButton
|
||||
:icon="themeIcon"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
aria-label="切换主题"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
<MobileNavMenu
|
||||
v-model:open="mobileNavOpen"
|
||||
:items="navItems"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block">
|
||||
<UDropdownMenu
|
||||
v-if="user"
|
||||
:items="desktopUserMenuItems"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="size-8" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<LocaleMenu />
|
||||
<ThemeMenu />
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const copying = ref(false)
|
||||
|
||||
function copyWithFallback(text: string): boolean {
|
||||
@@ -40,7 +41,7 @@ function copyWithFallback(text: string): boolean {
|
||||
|
||||
function notifySuccess() {
|
||||
toast.add({
|
||||
title: props.successTitle ?? (props.label ? `已复制 ${props.label}` : '已复制到剪贴板'),
|
||||
title: props.successTitle ?? (props.label ? t('copy.copiedLabel', { label: props.label }) : t('copy.copied')),
|
||||
color: 'success'
|
||||
})
|
||||
emit('copied')
|
||||
@@ -64,7 +65,7 @@ async function copy() {
|
||||
if (copyWithFallback(props.value)) {
|
||||
notifySuccess()
|
||||
} else {
|
||||
toast.add({ title: '复制失败,请手动选中链接复制', color: 'error' })
|
||||
toast.add({ title: t('copy.failedManual'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
copying.value = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
count: number
|
||||
loading?: boolean
|
||||
@@ -10,6 +10,10 @@ const emit = defineEmits<{
|
||||
'confirm': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const description = computed(() => t('delete.confirmDesc', { count: props.count }))
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
@@ -22,20 +26,20 @@ function confirm() {
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
title="确认删除"
|
||||
:description="`确定要删除选中的 ${count} 张图片吗?此操作不可恢复。`"
|
||||
:title="t('delete.confirmTitle')"
|
||||
:description="description"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
label="取消"
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="close"
|
||||
/>
|
||||
<UButton
|
||||
label="确认删除"
|
||||
:label="t('delete.confirm')"
|
||||
color="error"
|
||||
:loading="loading"
|
||||
@click="confirm"
|
||||
|
||||
@@ -9,9 +9,11 @@ const props = withDefaults(defineProps<{
|
||||
deleting?: boolean
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true
|
||||
showKey: true,
|
||||
compact: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -20,18 +22,19 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
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 copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
])
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
try {
|
||||
return new Date(props.image.uploadedAt).toLocaleString('zh-CN', { hour12: false })
|
||||
return new Date(props.image.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return props.image.uploadedAt
|
||||
}
|
||||
@@ -51,11 +54,11 @@ const previewValue = computed(() => {
|
||||
const copySuccessTitle = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return '已复制 Markdown'
|
||||
return t('copy.copiedMarkdown')
|
||||
case 'html':
|
||||
return '已复制 HTML'
|
||||
return t('copy.copiedHtml')
|
||||
default:
|
||||
return '已复制直链'
|
||||
return t('copy.copiedUrl')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,7 +82,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-muted"
|
||||
>
|
||||
预览失败
|
||||
{{ t('image.previewFailed') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="selectable"
|
||||
@@ -96,7 +99,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
color="error"
|
||||
variant="solid"
|
||||
icon="i-lucide-trash-2"
|
||||
aria-label="删除"
|
||||
:aria-label="t('common.delete')"
|
||||
:loading="deleting"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
@@ -107,7 +110,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
>
|
||||
<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}`"
|
||||
:title="t('image.uploader', { name: image.owner.username })"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-user"
|
||||
@@ -118,7 +121,34 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||
<div
|
||||
v-if="compact"
|
||||
class="flex items-center gap-1.5 p-2 sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="truncate text-xs font-medium"
|
||||
:title="image.originalName"
|
||||
>
|
||||
{{ image.originalName }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted">
|
||||
{{ formatFileSize(image.size) }}
|
||||
</p>
|
||||
</div>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
:label="t('common.copy')"
|
||||
:value="image.url"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-2 p-3"
|
||||
:class="{ 'hidden sm:flex': compact }"
|
||||
>
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
:title="image.originalName"
|
||||
@@ -151,7 +181,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
/>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
label="复制"
|
||||
:label="t('common.copy')"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
|
||||
@@ -8,10 +8,13 @@ const props = withDefaults(defineProps<{
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
emptyText?: string
|
||||
dense?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
emptyText: '暂无图片'
|
||||
dense: false,
|
||||
compact: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,6 +22,10 @@ const emit = defineEmits<{
|
||||
'delete': [key: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
|
||||
|
||||
function updateSelection(key: string, selected: boolean) {
|
||||
const next = new Set(props.selectedKeys)
|
||||
if (selected) next.add(key)
|
||||
@@ -30,7 +37,8 @@ function updateSelection(key: string, selected: boolean) {
|
||||
<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"
|
||||
class="grid items-start sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
:class="dense ? 'grid-cols-2 gap-3' : 'grid-cols-1 gap-4'"
|
||||
>
|
||||
<ImageCard
|
||||
v-for="image in items"
|
||||
@@ -40,6 +48,7 @@ function updateSelection(key: string, selected: boolean) {
|
||||
:deleting="deletingKeys.has(image.key)"
|
||||
:selectable="selectable"
|
||||
:show-key="showKey"
|
||||
:compact="compact"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@delete="emit('delete', image.key)"
|
||||
/>
|
||||
@@ -48,6 +57,6 @@ function updateSelection(key: string, selected: boolean) {
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
|
||||
>
|
||||
{{ emptyText }}
|
||||
{{ displayEmptyText }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import uploadCardBg from '~/assets/image/upload-card-bg.webp'
|
||||
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
@@ -10,12 +13,10 @@ const emit = defineEmits<{
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const hovering = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bindPaste } = useClipboardImage(ref(null))
|
||||
|
||||
const isActive = computed(() => dragging.value || hovering.value)
|
||||
|
||||
bindPaste((files) => {
|
||||
if (!props.disabled) emit('upload', files)
|
||||
})
|
||||
@@ -51,31 +52,33 @@ function onDragLeave() {
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!props.disabled) hovering.value = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovering.value = false
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
function openSettings(event: Event) {
|
||||
event.stopPropagation()
|
||||
if (props.disabled) return
|
||||
emit('open-settings')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (props.disabled) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openFilePicker()
|
||||
}
|
||||
}
|
||||
</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'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="upload-card-surface group/upload relative cursor-pointer overflow-hidden text-center outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
:class="{ 'upload-card-surface--active': dragging }"
|
||||
:aria-disabled="disabled"
|
||||
@click="openFilePicker"
|
||||
@keydown="onKeydown"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
@@ -87,11 +90,20 @@ function openSettings() {
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
<div class="absolute top-3 right-3 z-10">
|
||||
<img
|
||||
:src="uploadCardBg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
<div class="absolute top-3 right-3 z-20">
|
||||
<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="上传设置"
|
||||
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
|
||||
:aria-label="t('upload.settings')"
|
||||
:disabled="disabled"
|
||||
@click="openSettings"
|
||||
>
|
||||
@@ -102,39 +114,31 @@ function openSettings() {
|
||||
</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 class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<img
|
||||
:src="uploadCardHero"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
|
||||
:class="dragging ? 'scale-105' : 'group-hover/upload:scale-[1.02]'"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
||||
{{ t('upload.title') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted sm:text-base">
|
||||
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</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 class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
||||
{{ t('upload.formats') }}
|
||||
</p>
|
||||
<UButton
|
||||
label="选择图片"
|
||||
icon="i-lucide-upload"
|
||||
size="lg"
|
||||
:disabled="disabled"
|
||||
@click="openFilePicker"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const { localeMenuItems } = useAppearanceMenus()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu :items="localeMenuItems">
|
||||
<UButton
|
||||
icon="i-lucide-languages"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:aria-label="t('locale.switch')"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: Array<{ label: string, to: string, icon: string }>
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function isActive(to: string) {
|
||||
return route.path.startsWith(to)
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!open.value || !menuRef.value) return
|
||||
const target = event.target
|
||||
if (target instanceof Node && !menuRef.value.contains(target)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, () => {
|
||||
open.value = false
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
} else {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="relative sm:hidden"
|
||||
>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-lucide-menu"
|
||||
:aria-label="t('nav.menu')"
|
||||
:aria-expanded="open"
|
||||
@click.stop="open = !open"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute top-full right-0 z-50 mt-2 w-52 rounded-xl border border-default bg-default p-1.5 shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<nav class="space-y-0.5">
|
||||
<NuxtLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
|
||||
:class="isActive(item.to)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-highlighted hover:bg-elevated'"
|
||||
@click="open = false"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="size-4 shrink-0 opacity-70"
|
||||
/>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { logout, isAdmin } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const {
|
||||
localeOptions,
|
||||
themeOptions,
|
||||
setLocalePreference,
|
||||
setThemePreference
|
||||
} = useAppearanceMenus()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
async function handleLogout() {
|
||||
open.value = false
|
||||
await logout()
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!open.value || !menuRef.value) return
|
||||
const target = event.target
|
||||
if (target instanceof Node && !menuRef.value.contains(target)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
} else {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="relative sm:hidden"
|
||||
>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-lucide-user"
|
||||
:aria-label="t('nav.account')"
|
||||
:aria-expanded="open"
|
||||
@click.stop="open = !open"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute top-full right-0 z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] rounded-xl border border-default bg-default p-4 shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<section class="space-y-2">
|
||||
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
|
||||
<UIcon
|
||||
name="i-lucide-globe"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('nav.language') }}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="item in localeOptions"
|
||||
:key="item.code"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border px-3.5 py-2.5 text-sm transition-colors"
|
||||
:class="locale === item.code
|
||||
? 'border-primary text-highlighted'
|
||||
: 'border-default text-highlighted hover:bg-elevated'"
|
||||
@click="setLocalePreference(item.code)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<UIcon
|
||||
v-if="locale === item.code"
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 space-y-2">
|
||||
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
|
||||
<UIcon
|
||||
name="i-lucide-sun"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('nav.appearance') }}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="item in themeOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border px-3.5 py-2.5 text-sm transition-colors"
|
||||
:class="colorMode.preference === item.value
|
||||
? 'border-primary text-highlighted'
|
||||
: 'border-default text-highlighted hover:bg-elevated'"
|
||||
@click="setThemePreference(item.value)"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="size-4 opacity-70"
|
||||
/>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<UIcon
|
||||
v-if="colorMode.preference === item.value"
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 space-y-2">
|
||||
<h3 class="flex items-center gap-2 px-1 text-sm font-medium text-muted">
|
||||
<UIcon
|
||||
name="i-lucide-user"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('nav.account') }}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink
|
||||
to="/account/password"
|
||||
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-lock"
|
||||
class="size-4 opacity-70"
|
||||
/>
|
||||
{{ t('nav.changePassword') }}
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/settings"
|
||||
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-settings"
|
||||
class="size-4 opacity-70"
|
||||
/>
|
||||
{{ t('nav.systemSettings') }}
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-error transition-colors hover:bg-error/5"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-log-out"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('nav.logout') }}
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 opacity-60"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,7 +11,9 @@ const emit = defineEmits<{
|
||||
'update:page': [page: number]
|
||||
}>()
|
||||
|
||||
const unitLabel = computed(() => props.unit ?? '条')
|
||||
const { t } = useI18n()
|
||||
|
||||
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
|
||||
|
||||
function goTo(target: number) {
|
||||
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
|
||||
@@ -30,13 +32,13 @@ function goTo(target: number) {
|
||||
v-if="totalPages > 1"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} {{ unitLabel }}
|
||||
{{ t('pagination.pageOf', { page, totalPages, total, unit: unitLabel }) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
共 {{ total }} {{ unitLabel }}
|
||||
{{ t('pagination.totalOnly', { total, unit: unitLabel }) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -47,7 +49,7 @@ function goTo(target: number) {
|
||||
icon="i-lucide-chevrons-left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="第一页"
|
||||
:aria-label="t('pagination.first')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(1)"
|
||||
/>
|
||||
@@ -55,7 +57,7 @@ function goTo(target: number) {
|
||||
icon="i-lucide-chevron-left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="上一页"
|
||||
:aria-label="t('pagination.prev')"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goTo(page - 1)"
|
||||
/>
|
||||
@@ -63,7 +65,7 @@ function goTo(target: number) {
|
||||
icon="i-lucide-chevron-right"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="下一页"
|
||||
:aria-label="t('pagination.next')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(page + 1)"
|
||||
/>
|
||||
@@ -71,7 +73,7 @@ function goTo(target: number) {
|
||||
icon="i-lucide-chevrons-right"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="最后一页"
|
||||
:aria-label="t('pagination.last')"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(totalPages)"
|
||||
/>
|
||||
|
||||
@@ -5,15 +5,17 @@ const props = defineProps<{
|
||||
password: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const analysis = computed(() => analyzePassword(props.password))
|
||||
|
||||
const show = computed(() => props.password.length > 0)
|
||||
|
||||
const labelColor = computed(() => {
|
||||
switch (analysis.value.label) {
|
||||
case '强':
|
||||
case 'strong':
|
||||
return 'text-success'
|
||||
case '中':
|
||||
case 'medium':
|
||||
return 'text-warning'
|
||||
default:
|
||||
return 'text-error'
|
||||
@@ -22,9 +24,9 @@ const labelColor = computed(() => {
|
||||
|
||||
const barColor = computed(() => {
|
||||
switch (analysis.value.label) {
|
||||
case '强':
|
||||
case 'strong':
|
||||
return 'bg-success'
|
||||
case '中':
|
||||
case 'medium':
|
||||
return 'bg-warning'
|
||||
default:
|
||||
return 'bg-error/80'
|
||||
@@ -32,10 +34,10 @@ const barColor = computed(() => {
|
||||
})
|
||||
|
||||
const rules = computed(() => [
|
||||
{ key: 'minLength', label: `至少 ${PASSWORD_MIN_LENGTH} 个字符` },
|
||||
{ key: 'mixedCase', label: '包含大小写字母' },
|
||||
{ key: 'hasNumber', label: '包含数字' },
|
||||
{ key: 'hasSpecial', label: '包含特殊字符' }
|
||||
{ key: 'minLength', label: t('password.ruleMinLength', { n: PASSWORD_MIN_LENGTH }) },
|
||||
{ key: 'mixedCase', label: t('password.ruleMixedCase') },
|
||||
{ key: 'hasNumber', label: t('password.ruleNumber') },
|
||||
{ key: 'hasSpecial', label: t('password.ruleSpecial') }
|
||||
] as const)
|
||||
</script>
|
||||
|
||||
@@ -45,12 +47,12 @@ const rules = computed(() => [
|
||||
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="text-muted">{{ t('password.strength') }}</span>
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="labelColor"
|
||||
>
|
||||
{{ analysis.label }}
|
||||
{{ t(`password.${analysis.label}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const { themeIcon, themeMenuItems } = useAppearanceMenus()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu :items="themeMenuItems">
|
||||
<UButton
|
||||
:icon="themeIcon"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:aria-label="t('theme.switch')"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
loadPreferences
|
||||
} = useUploadPreferences()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
userSettings,
|
||||
enabled: userAutoDeleteEnabled,
|
||||
@@ -27,16 +29,16 @@ const {
|
||||
load: loadUserSettings
|
||||
} = useUserAutoDeleteSettings()
|
||||
|
||||
const copyFormatItems = [
|
||||
{ label: '直链', value: 'url' as const },
|
||||
{ label: 'Markdown', value: 'markdown' as const },
|
||||
{ label: 'HTML', value: 'html' as const }
|
||||
]
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
])
|
||||
|
||||
const clientQualityLevel = computed(() => {
|
||||
if (clientWebpQuality.value >= 85) return '高质量'
|
||||
if (clientWebpQuality.value >= 60) return '中等质量'
|
||||
return '有损压缩'
|
||||
if (clientWebpQuality.value >= 85) return t('preferences.qualityHigh')
|
||||
if (clientWebpQuality.value >= 60) return t('preferences.qualityMedium')
|
||||
return t('preferences.qualityLow')
|
||||
})
|
||||
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||
@@ -73,22 +75,22 @@ onMounted(() => {
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold">
|
||||
上传偏好
|
||||
{{ t('preferences.title') }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
浏览器本地与账号设置,仅影响您的上传体验
|
||||
{{ t('preferences.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="!embedded"
|
||||
label="返回"
|
||||
:label="t('common.back')"
|
||||
icon="i-lucide-arrow-left"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
aria-label="返回上传"
|
||||
:aria-label="t('preferences.back')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
</div>
|
||||
@@ -100,7 +102,7 @@ onMounted(() => {
|
||||
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
图片处理
|
||||
{{ t('preferences.processing') }}
|
||||
</h3>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
@@ -109,9 +111,9 @@ onMounted(() => {
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm">客户端预压缩</span>
|
||||
<span class="block text-sm">{{ t('preferences.clientCompress') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
上传前在浏览器端压缩(服务端仍会转 WebP)
|
||||
{{ t('preferences.clientCompressHint') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -129,7 +131,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
|
||||
>
|
||||
<span>自动删除超过</span>
|
||||
<span>{{ t('preferences.autoDeletePrefix') }}</span>
|
||||
<UInput
|
||||
v-model.number="userAutoDeleteDaysDraft"
|
||||
type="number"
|
||||
@@ -143,10 +145,10 @@ onMounted(() => {
|
||||
: 'pointer-events-none opacity-40'
|
||||
"
|
||||
/>
|
||||
<span>天的图片</span>
|
||||
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
仅清理开启后上传的图片,不影响此前已存的图片
|
||||
{{ t('preferences.autoDeleteHint') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -164,15 +166,15 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
WebP 压缩质量
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</h3>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
客户端预压缩质量(1–100),需开启「客户端预压缩」
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-sm text-muted"
|
||||
>
|
||||
<span>当前:{{ clientWebpQuality }}</span>
|
||||
<span>{{ t('preferences.current', { n: clientWebpQuality }) }}</span>
|
||||
<span>{{ clientQualityLevel }}</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -187,7 +189,7 @@ onMounted(() => {
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
便捷功能
|
||||
{{ t('preferences.convenience') }}
|
||||
</h3>
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
<UCheckbox
|
||||
@@ -195,9 +197,9 @@ onMounted(() => {
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm">上传成功自动复制链接</span>
|
||||
<span class="block text-sm">{{ t('preferences.autoCopy') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
按所选格式复制到剪贴板
|
||||
{{ t('preferences.autoCopyHint') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -4,6 +4,21 @@ import type { UploadProgressItem } from '~/types/image'
|
||||
defineProps<{
|
||||
items: UploadProgressItem[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function statusLabel(status: UploadProgressItem['status']) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return t('upload.statusPending')
|
||||
case 'uploading':
|
||||
return t('upload.statusUploading')
|
||||
case 'success':
|
||||
return t('upload.statusSuccess')
|
||||
default:
|
||||
return t('upload.statusError')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -12,7 +27,7 @@ defineProps<{
|
||||
class="space-y-2 rounded-lg border border-default p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-muted">
|
||||
上传进度
|
||||
{{ t('upload.progress') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -26,7 +41,7 @@ defineProps<{
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ item.status === 'pending' ? '等待' : item.status === 'uploading' ? '上传中' : item.status === 'success' ? '成功' : '失败' }}
|
||||
{{ statusLabel(item.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<UProgress
|
||||
|
||||
Reference in new issue
Block a user