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
@@ -10,6 +10,7 @@ const {
|
||||
changePassword
|
||||
} = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
@@ -37,7 +38,7 @@ async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toast.add({ title: '两次输入的新密码不一致', color: 'error' })
|
||||
toast.add({ title: t('password.mismatch'), color: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ async function submit() {
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
toast.add({ title: '密码已修改', color: 'success' })
|
||||
toast.add({ title: t('password.changed'), color: 'success' })
|
||||
await router.replace('/')
|
||||
} else {
|
||||
toast.add({ title: result.error, color: 'error' })
|
||||
@@ -71,7 +72,7 @@ async function submit() {
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
正在验证登录状态…
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,10 +83,10 @@ async function submit() {
|
||||
<section class="mx-auto max-w-md overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
|
||||
<div class="border-b border-default px-5 py-4 sm:px-6">
|
||||
<h1 class="text-lg font-semibold tracking-tight">
|
||||
修改密码
|
||||
{{ t('password.title') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
修改您的登录密码
|
||||
{{ t('password.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +99,7 @@ async function submit() {
|
||||
for="current-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
当前密码
|
||||
{{ t('password.current') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="current-password"
|
||||
@@ -116,13 +117,13 @@ async function submit() {
|
||||
for="new-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
新密码
|
||||
{{ t('password.new') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="设置新密码"
|
||||
:placeholder="t('password.newPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -136,7 +137,7 @@ async function submit() {
|
||||
for="confirm-new-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
确认新密码
|
||||
{{ t('password.confirmNew') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="confirm-new-password"
|
||||
@@ -152,13 +153,13 @@ async function submit() {
|
||||
<div class="flex gap-2 pt-1">
|
||||
<UButton
|
||||
type="submit"
|
||||
label="保存"
|
||||
:label="t('common.save')"
|
||||
icon="i-lucide-save"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
<UButton
|
||||
label="取消"
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
to="/"
|
||||
|
||||
+49
-48
@@ -22,6 +22,7 @@ interface ApiDocItem {
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const settings = ref<SettingsResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
@@ -49,8 +50,8 @@ const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
|
||||
|
||||
const apiDocs = computed<ApiDocItem[]>(() => {
|
||||
const uploadDescription = isAdmin.value
|
||||
? '上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。'
|
||||
: '上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。'
|
||||
? t('api.docs.upload.descAdmin')
|
||||
: t('api.docs.upload.descUser')
|
||||
const uploadCurl = isAdmin.value
|
||||
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
${authHeaderFlag.value} \\
|
||||
@@ -66,8 +67,8 @@ curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
-F "image=@./demo.png"`
|
||||
|
||||
const listDescription = isAdmin.value
|
||||
? '分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。'
|
||||
: '分页列出您的图库,limit 默认 20、最大 100。'
|
||||
? t('api.docs.list.descAdmin')
|
||||
: t('api.docs.list.descUser')
|
||||
const listCurl = isAdmin.value
|
||||
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||
${authHeaderFlag.value}
|
||||
@@ -83,7 +84,7 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
index: 1,
|
||||
method: 'POST',
|
||||
path: '/api/images/upload',
|
||||
title: '上传图片',
|
||||
title: t('api.docs.upload.title'),
|
||||
description: uploadDescription,
|
||||
curl: uploadCurl
|
||||
},
|
||||
@@ -91,7 +92,7 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
index: 2,
|
||||
method: 'GET',
|
||||
path: '/api/images',
|
||||
title: '获取图片列表',
|
||||
title: t('api.docs.list.title'),
|
||||
description: listDescription,
|
||||
curl: listCurl
|
||||
},
|
||||
@@ -99,8 +100,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
index: 3,
|
||||
method: 'GET',
|
||||
path: '/api/images/search',
|
||||
title: '搜索图片',
|
||||
description: '按文件名或路径关键词搜索,参数 q 必填。',
|
||||
title: t('api.docs.search.title'),
|
||||
description: t('api.docs.search.desc'),
|
||||
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
|
||||
${authHeaderFlag.value}`
|
||||
},
|
||||
@@ -108,8 +109,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
index: 4,
|
||||
method: 'DELETE',
|
||||
path: '/api/images',
|
||||
title: '删除图片',
|
||||
description: '通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。',
|
||||
title: t('api.docs.delete.title'),
|
||||
description: t('api.docs.delete.desc'),
|
||||
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
|
||||
${authHeaderFlag.value}`
|
||||
},
|
||||
@@ -117,8 +118,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
index: 5,
|
||||
method: 'POST',
|
||||
path: '/api/images/batch-delete',
|
||||
title: '批量删除',
|
||||
description: '一次删除多张图片,请求体为 keys 数组。',
|
||||
title: t('api.docs.batchDelete.title'),
|
||||
description: t('api.docs.batchDelete.desc'),
|
||||
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
|
||||
${authHeaderFlag.value} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
@@ -130,8 +131,8 @@ curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
const twikooDoc = computed(() => ({
|
||||
method: 'POST' as const,
|
||||
path: '/api/index.php',
|
||||
title: 'twikoo 兼容上传',
|
||||
description: '兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。',
|
||||
title: t('api.docs.twikoo.title'),
|
||||
description: t('api.docs.twikoo.desc'),
|
||||
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
|
||||
-F "token=${authHeader.value}" \\
|
||||
-F "image=@./demo.png"`
|
||||
@@ -140,11 +141,11 @@ const twikooDoc = computed(() => ({
|
||||
function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
|
||||
switch (source) {
|
||||
case 'env':
|
||||
return '来源:环境变量 API_UPLOAD_TOKEN'
|
||||
return t('api.tokenSourceEnv')
|
||||
case 'db':
|
||||
return '来源:后台生成(可重新生成)'
|
||||
return t('api.tokenSourceDb')
|
||||
default:
|
||||
return '来源:未配置'
|
||||
return t('api.tokenSourceNone')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ async function loadSettings() {
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '加载 API 设置失败', color: 'error' })
|
||||
toast.add({ title: t('api.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -203,11 +204,11 @@ async function confirmRegenerate() {
|
||||
: 0
|
||||
if (status === 409) {
|
||||
toast.add({
|
||||
title: '环境变量已配置 Token,无法在后台重新生成',
|
||||
title: t('api.regenerateEnvBlocked'),
|
||||
color: 'warning'
|
||||
})
|
||||
} else {
|
||||
toast.add({ title: '重新生成 Token 失败', color: 'error' })
|
||||
toast.add({ title: t('api.regenerateFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -218,9 +219,9 @@ async function confirmRegenerate() {
|
||||
async function copyCurl(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.add({ title: '已复制 cURL 示例', color: 'success' })
|
||||
toast.add({ title: t('copy.curl'), color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: '复制失败', color: 'error' })
|
||||
toast.add({ title: t('copy.failed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +264,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
正在验证登录状态…
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +279,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="size-5 text-primary"
|
||||
/>
|
||||
<h2 class="text-base font-semibold">
|
||||
API Token
|
||||
{{ t('api.tokenTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -288,8 +289,8 @@ watch(isAuthenticated, async (authed) => {
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Token 由环境变量覆盖"
|
||||
description="修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。"
|
||||
:title="t('api.envOverrideTitle')"
|
||||
:description="t('api.envOverrideDesc')"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
@@ -297,8 +298,8 @@ watch(isAuthenticated, async (authed) => {
|
||||
color="info"
|
||||
variant="subtle"
|
||||
icon="i-lucide-info"
|
||||
title="个人 API Token"
|
||||
description="此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。"
|
||||
:title="t('api.personalTitle')"
|
||||
:description="t('api.personalDesc')"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
@@ -306,25 +307,25 @@ watch(isAuthenticated, async (authed) => {
|
||||
color="info"
|
||||
variant="subtle"
|
||||
icon="i-lucide-info"
|
||||
title="尚未配置 Token"
|
||||
description="点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。"
|
||||
:title="t('api.notConfiguredTitle')"
|
||||
:description="t('api.notConfiguredDesc')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<UInput
|
||||
:model-value="hasToken ? tokenDisplay : '(未配置)'"
|
||||
:model-value="hasToken ? tokenDisplay : t('common.notConfigured')"
|
||||
readonly
|
||||
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
|
||||
:loading="loading"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<CopyButton
|
||||
label="复制"
|
||||
:label="t('common.copy')"
|
||||
:value="tokenDisplay"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="重新生成"
|
||||
:label="t('api.regenerate')"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
:loading="regenerating"
|
||||
@@ -339,10 +340,10 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ tokenSourceLabel(settings.tokenSource) }}
|
||||
· 请求头:
|
||||
· {{ t('api.tokenHeader') }}
|
||||
<code class="font-mono">Auth-Token: <token></code>
|
||||
<template v-if="hasToken && !settings.envTokenOverride">
|
||||
· 重新生成后旧 Token 立即失效
|
||||
· {{ t('api.tokenInvalidate') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -355,7 +356,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="size-5 text-primary"
|
||||
/>
|
||||
<h2 class="text-base font-semibold">
|
||||
API 文档
|
||||
{{ t('api.docsTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -395,12 +396,12 @@ watch(isAuthenticated, async (authed) => {
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-muted">
|
||||
cURL 示例
|
||||
{{ t('api.curlExample') }}
|
||||
</p>
|
||||
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
label="复制"
|
||||
:label="t('common.copy')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@@ -445,12 +446,12 @@ watch(isAuthenticated, async (authed) => {
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-muted">
|
||||
cURL 示例
|
||||
{{ t('api.curlExample') }}
|
||||
</p>
|
||||
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
label="复制"
|
||||
:label="t('common.copy')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@@ -466,20 +467,20 @@ watch(isAuthenticated, async (authed) => {
|
||||
|
||||
<UModal
|
||||
:open="confirmRegenerateOpen"
|
||||
title="确认重新生成 Token?"
|
||||
description="旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。"
|
||||
:title="t('api.confirmRegenerateTitle')"
|
||||
:description="t('api.confirmRegenerateDesc')"
|
||||
@update:open="confirmRegenerateOpen = $event"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
label="取消"
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="closeConfirmRegenerate"
|
||||
/>
|
||||
<UButton
|
||||
label="确认重新生成"
|
||||
:label="t('api.confirmRegenerate')"
|
||||
color="warning"
|
||||
:loading="regenerating"
|
||||
@click="confirmRegenerate"
|
||||
@@ -490,8 +491,8 @@ watch(isAuthenticated, async (authed) => {
|
||||
|
||||
<UModal
|
||||
:open="regenerateResultOpen"
|
||||
title="新 Token 已生成"
|
||||
description="请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。"
|
||||
:title="t('api.newTokenTitle')"
|
||||
:description="t('api.newTokenDesc')"
|
||||
@update:open="regenerateResultOpen = $event"
|
||||
>
|
||||
<template #body>
|
||||
@@ -504,11 +505,11 @@ watch(isAuthenticated, async (authed) => {
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-wrap justify-end gap-2">
|
||||
<CopyButton
|
||||
label="复制 Token"
|
||||
:label="t('api.copyToken')"
|
||||
:value="newToken"
|
||||
/>
|
||||
<UButton
|
||||
label="我已保存"
|
||||
:label="t('api.saved')"
|
||||
@click="closeRegenerateResult"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+12
-11
@@ -13,6 +13,7 @@ const {
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
|
||||
= useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
const sessionItems = ref<ImageItem[]>([])
|
||||
const deletingKeys = ref<Set<string>>(new Set())
|
||||
@@ -76,11 +77,11 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
function copyFormatLabel() {
|
||||
switch (copyFormat.value) {
|
||||
case 'url':
|
||||
return '直链'
|
||||
return t('copy.url')
|
||||
case 'html':
|
||||
return 'HTML'
|
||||
return t('copy.html')
|
||||
default:
|
||||
return 'Markdown'
|
||||
return t('copy.markdown')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,15 +99,15 @@ async function copyUploadLink(text: string) {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
toast.add({ title: `已复制 ${copyFormatLabel()}`, color: 'success' })
|
||||
toast.add({ title: t('copy.copiedFormat', { format: copyFormatLabel() }), color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: '复制失败', color: 'error' })
|
||||
toast.add({ title: t('copy.failed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.add({ title: '请先登录', color: 'warning' })
|
||||
toast.add({ title: t('upload.loginRequired'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,10 +150,10 @@ async function handleDelete(key: string) {
|
||||
query: { key }
|
||||
})
|
||||
sessionItems.value = sessionItems.value.filter(item => item.key !== key)
|
||||
toast.add({ title: '已删除', color: 'success' })
|
||||
toast.add({ title: t('stats.deletedSingle'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
toast.add({ title: '删除失败', color: 'error' })
|
||||
toast.add({ title: t('stats.deleteFailed'), color: 'error' })
|
||||
} finally {
|
||||
deletingKeys.value.delete(key)
|
||||
deletingKeys.value = new Set(deletingKeys.value)
|
||||
@@ -172,7 +173,7 @@ async function handleDelete(key: string) {
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
正在验证登录状态…
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +197,7 @@ async function handleDelete(key: string) {
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-medium">
|
||||
本次上传
|
||||
{{ t('upload.sessionTitle') }}
|
||||
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
|
||||
</h2>
|
||||
</div>
|
||||
@@ -207,7 +208,7 @@ async function handleDelete(key: string) {
|
||||
:deleting-keys="deletingKeys"
|
||||
:selectable="false"
|
||||
:show-key="false"
|
||||
empty-text="暂无本次上传"
|
||||
:empty-text="t('upload.sessionEmpty')"
|
||||
@update:selected-keys="() => {}"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
+18
-14
@@ -6,6 +6,7 @@ import { isPasswordValid } from '~/utils/password-strength'
|
||||
const router = useRouter()
|
||||
const { register, fetchStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
@@ -34,7 +35,7 @@ async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
toast.add({ title: '两次输入的密码不一致', color: 'error' })
|
||||
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ async function submit() {
|
||||
try {
|
||||
const result = await register(username.value.trim(), password.value)
|
||||
if (result.ok) {
|
||||
toast.add({ title: '注册成功', color: 'success' })
|
||||
toast.add({ title: t('auth.registerSuccess'), color: 'success' })
|
||||
await router.replace('/')
|
||||
} else {
|
||||
toast.add({ title: result.error, color: 'error' })
|
||||
@@ -54,7 +55,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">
|
||||
@@ -77,10 +81,10 @@ async function submit() {
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
|
||||
注册账号
|
||||
{{ t('register.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
创建 PicHost 用户账号
|
||||
{{ t('register.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,12 +98,12 @@ async function submit() {
|
||||
for="register-username"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
用户名
|
||||
{{ t('auth.username') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="register-username"
|
||||
v-model="username"
|
||||
placeholder="3–32 位字母、数字或下划线"
|
||||
:placeholder="t('setup.usernamePlaceholder')"
|
||||
autocomplete="username"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -112,13 +116,13 @@ async function submit() {
|
||||
for="register-password"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
密码
|
||||
{{ t('auth.password') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="register-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="设置登录密码"
|
||||
:placeholder="t('setup.passwordPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -132,13 +136,13 @@ async function submit() {
|
||||
for="register-confirm"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
确认密码
|
||||
{{ t('auth.confirmPassword') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="register-confirm"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
:placeholder="t('setup.confirmPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -148,7 +152,7 @@ async function submit() {
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="注册"
|
||||
:label="t('auth.register')"
|
||||
icon="i-lucide-user-plus"
|
||||
size="lg"
|
||||
block
|
||||
@@ -158,12 +162,12 @@ async function submit() {
|
||||
/>
|
||||
|
||||
<p class="text-center text-sm text-muted">
|
||||
已有账号?
|
||||
{{ t('auth.hasAccount') }}
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
返回登录
|
||||
{{ t('auth.backToLogin') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
+31
-31
@@ -21,6 +21,7 @@ interface SettingsResponse {
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const { defaultFolder, loadPreferences } = useUploadPreferences()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const settings = ref<SettingsResponse | null>(null)
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
@@ -44,24 +45,24 @@ const hasServerChanges = computed(() => {
|
||||
function sourceBadge(source: SettingSource | WebpQualitySource) {
|
||||
switch (source) {
|
||||
case 'env':
|
||||
return { label: '.env', color: 'warning' as const }
|
||||
return { label: t('settings.badgeEnv'), color: 'warning' as const }
|
||||
case 'db':
|
||||
return { label: '已保存', color: 'success' as const }
|
||||
return { label: t('settings.badgeSaved'), color: 'success' as const }
|
||||
case 'default':
|
||||
return { label: '默认', color: 'neutral' as const }
|
||||
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
|
||||
default:
|
||||
return { label: '未设置', color: 'neutral' as const }
|
||||
return { label: t('settings.badgeUnset'), color: 'neutral' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function tokenSourceText(source: SettingSource) {
|
||||
switch (source) {
|
||||
case 'env':
|
||||
return '环境变量'
|
||||
return t('settings.tokenEnv')
|
||||
case 'db':
|
||||
return '后台生成'
|
||||
return t('settings.tokenDb')
|
||||
default:
|
||||
return '未配置'
|
||||
return t('settings.tokenNone')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ function handlePatchError(error: unknown, fallback: string) {
|
||||
handleAuthError(error)
|
||||
if (!isAuthenticated.value) return
|
||||
if (errorStatus(error) === 409) {
|
||||
toast.add({ title: '该项已由环境变量覆盖,无法在后台修改', color: 'warning' })
|
||||
toast.add({ title: t('settings.envOverride'), color: 'warning' })
|
||||
void loadSettings()
|
||||
return
|
||||
}
|
||||
@@ -110,9 +111,9 @@ async function saveServerSettings() {
|
||||
imageBaseUrl: imageBaseUrlDraft.value,
|
||||
allowRegistration: allowRegistrationDraft.value
|
||||
})
|
||||
toast.add({ title: '服务器配置已保存', color: 'success' })
|
||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
handlePatchError(error, '保存服务器配置失败')
|
||||
handlePatchError(error, t('settings.saveFailed'))
|
||||
} finally {
|
||||
savingServer.value = false
|
||||
}
|
||||
@@ -137,7 +138,7 @@ async function loadSettings() {
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '加载设置失败', color: 'error' })
|
||||
toast.add({ title: t('settings.loadFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +190,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
正在验证登录状态…
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,10 +210,10 @@ watch(isAuthenticated, async (authed) => {
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-base font-semibold">
|
||||
系统设置
|
||||
{{ t('settings.title') }}
|
||||
</h1>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
保存后写入数据库;环境变量优先级更高
|
||||
{{ t('settings.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,13 +225,13 @@ watch(isAuthenticated, async (authed) => {
|
||||
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
上传与账号
|
||||
{{ t('settings.uploadAccount') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">默认上传目录</label>
|
||||
<label class="text-sm">{{ t('settings.defaultFolder') }}</label>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
保存在本机浏览器,仅影响您的上传目标目录
|
||||
{{ t('settings.defaultFolderHint') }}
|
||||
</p>
|
||||
<USelect
|
||||
v-model="defaultFolder"
|
||||
@@ -245,9 +246,9 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm">开放用户注册</span>
|
||||
<span class="block text-sm">{{ t('settings.allowRegistration') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
允许新用户自行注册(普通用户仅可见自己的图片)
|
||||
{{ t('settings.allowRegistrationHint') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -256,7 +257,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
访问控制
|
||||
{{ t('settings.accessControl') }}
|
||||
</h3>
|
||||
<UBadge
|
||||
:color="sourceBadge(settings.refererSource).color"
|
||||
@@ -267,11 +268,11 @@ watch(isAuthenticated, async (authed) => {
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
Referer 防盗链,逗号分隔允许引用的域名,留空不限制
|
||||
{{ t('settings.refererHint') }}
|
||||
</p>
|
||||
<UTextarea
|
||||
v-model="refererDraft"
|
||||
placeholder="example.com, www.example.com"
|
||||
:placeholder="t('settings.refererPlaceholder')"
|
||||
:rows="4"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
@@ -280,7 +281,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
链接生成
|
||||
{{ t('settings.linkGeneration') }}
|
||||
</h3>
|
||||
<UBadge
|
||||
:color="sourceBadge(settings.imageBaseUrlSource).color"
|
||||
@@ -291,18 +292,18 @@ watch(isAuthenticated, async (authed) => {
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
生成图片链接的 URL 前缀,内网访问建议填公网域名
|
||||
{{ t('settings.imageBaseUrlHint') }}
|
||||
</p>
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
placeholder="https://pic.example.com"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.imageBaseUrl"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
当前生效:<span class="font-mono break-all">{{ settings.imageBaseUrl }}</span>
|
||||
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,16 +329,15 @@ watch(isAuthenticated, async (authed) => {
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
API Token · {{ tokenSourceText(settings.tokenSource) }}
|
||||
<span class="text-dimmed">· v{{ settings.appVersion }}</span>
|
||||
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource), version: settings.appVersion }) }}
|
||||
<template v-if="hasServerChanges">
|
||||
· <span class="text-warning">有未保存的更改</span>
|
||||
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
label="管理 Token"
|
||||
:label="t('settings.manageToken')"
|
||||
icon="i-lucide-external-link"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@@ -345,7 +345,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
to="/api"
|
||||
/>
|
||||
<UButton
|
||||
label="保存"
|
||||
:label="t('common.save')"
|
||||
icon="i-lucide-save"
|
||||
size="sm"
|
||||
:loading="savingServer"
|
||||
|
||||
+16
-12
@@ -7,6 +7,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { setup, migrate, fetchStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isMigrate = computed(() => route.query.migrate === '1')
|
||||
|
||||
@@ -34,7 +35,7 @@ async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
toast.add({ title: '两次输入的密码不一致', color: 'error' })
|
||||
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ async function submit() {
|
||||
|
||||
if (result.ok) {
|
||||
toast.add({
|
||||
title: isMigrate.value ? '账号迁移完成' : '初始化完成',
|
||||
title: isMigrate.value ? t('setup.migrateSuccess') : t('setup.initSuccess'),
|
||||
color: 'success'
|
||||
})
|
||||
await router.replace('/')
|
||||
@@ -65,7 +66,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">
|
||||
@@ -91,7 +95,7 @@ async function submit() {
|
||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ isMigrate ? '迁移到账号密码登录' : '首次使用,创建管理员账号' }}
|
||||
{{ isMigrate ? t('setup.migrateTitle') : t('setup.initTitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,12 +109,12 @@ async function submit() {
|
||||
for="setup-username"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
用户名
|
||||
{{ t('auth.username') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="setup-username"
|
||||
v-model="username"
|
||||
placeholder="3–32 位字母、数字或下划线"
|
||||
:placeholder="t('setup.usernamePlaceholder')"
|
||||
autocomplete="username"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -123,13 +127,13 @@ async function submit() {
|
||||
for="setup-password"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
密码
|
||||
{{ t('auth.password') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="设置登录密码"
|
||||
:placeholder="t('setup.passwordPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -143,13 +147,13 @@ async function submit() {
|
||||
for="setup-confirm"
|
||||
class="block text-sm font-medium text-default"
|
||||
>
|
||||
确认密码
|
||||
{{ t('auth.confirmPassword') }}
|
||||
</label>
|
||||
<UInput
|
||||
id="setup-confirm"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
:placeholder="t('setup.confirmPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@@ -159,12 +163,12 @@ async function submit() {
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-default">
|
||||
<UCheckbox v-model="allowRegistration" />
|
||||
允许其他用户注册
|
||||
{{ t('setup.allowRegistration') }}
|
||||
</label>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="isMigrate ? '完成迁移' : '创建并进入'"
|
||||
:label="isMigrate ? t('setup.migrateSubmit') : t('setup.initSubmit')"
|
||||
icon="i-lucide-user-plus"
|
||||
size="lg"
|
||||
block
|
||||
|
||||
+120
-93
@@ -35,7 +35,9 @@ const {
|
||||
activeSearch,
|
||||
listSummary,
|
||||
refreshList,
|
||||
initializeList,
|
||||
fetchList,
|
||||
fetchSearch,
|
||||
fetchTotal,
|
||||
setActiveFolder,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
@@ -45,9 +47,11 @@ const {
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const stats = ref<StatsResponse | null>(null)
|
||||
const statsLoading = ref(false)
|
||||
const pageRefreshing = computed(() => statsLoading.value || galleryLoading.value)
|
||||
|
||||
const selectedKeys = ref<Set<string>>(new Set())
|
||||
const deletingKeys = ref<Set<string>>(new Set())
|
||||
@@ -59,7 +63,7 @@ const currentFolder = ref('all')
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
|
||||
const folderSelectItems = computed(() => [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: t('stats.allFolders'), value: 'all' },
|
||||
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
|
||||
])
|
||||
|
||||
@@ -104,16 +108,16 @@ const uploadChartTotal = computed(() =>
|
||||
const statCards = computed(() => {
|
||||
const s = stats.value
|
||||
const base = [
|
||||
{ label: '今日上传', value: s?.uploadToday ?? '—' },
|
||||
{ label: '本月上传', value: s?.uploadMonth ?? '—' },
|
||||
{ label: '累计上传', value: s?.uploadTotal ?? '—' },
|
||||
{ label: '当前库存', value: s?.storedCount ?? '—' },
|
||||
{ label: '今日删除', value: s?.deleteToday ?? '—' },
|
||||
{ label: '本月删除', value: s?.deleteMonth ?? '—' },
|
||||
{ label: t('stats.uploadToday'), value: s?.uploadToday ?? '—' },
|
||||
{ label: t('stats.uploadMonth'), value: s?.uploadMonth ?? '—' },
|
||||
{ label: t('stats.uploadTotal'), value: s?.uploadTotal ?? '—' },
|
||||
{ label: t('stats.storedCount'), value: s?.storedCount ?? '—' },
|
||||
{ label: t('stats.deleteToday'), value: s?.deleteToday ?? '—' },
|
||||
{ label: t('stats.deleteMonth'), value: s?.deleteMonth ?? '—' },
|
||||
isAdmin.value
|
||||
? { label: '注册用户数量', value: s?.userCount ?? '—' }
|
||||
: { label: '累计删除', value: s?.deleteTotal ?? '—' },
|
||||
{ label: '累计上传体积', value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
|
||||
? { label: t('stats.userCount'), value: s?.userCount ?? '—' }
|
||||
: { label: t('stats.deleteTotal'), value: s?.deleteTotal ?? '—' },
|
||||
{ label: t('stats.uploadBytes'), value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
|
||||
]
|
||||
return base
|
||||
})
|
||||
@@ -150,7 +154,7 @@ async function refreshStats() {
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '加载统计失败', color: 'error' })
|
||||
toast.add({ title: t('stats.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
@@ -167,13 +171,33 @@ async function refreshGallery() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadGallery() {
|
||||
if (activeSearch.value) {
|
||||
await fetchSearch(galleryPage.value)
|
||||
} else {
|
||||
await fetchList(galleryPage.value)
|
||||
}
|
||||
await fetchTotal()
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (!isAuthenticated.value) return
|
||||
currentFolder.value = 'all'
|
||||
await Promise.all([
|
||||
refreshStats(),
|
||||
initializeList('all').then(() => loadFolders())
|
||||
])
|
||||
selectedKeys.value = new Set()
|
||||
statsLoading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchStats(),
|
||||
reloadGallery(),
|
||||
loadFolders()
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('stats.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFolderChange(folder: string) {
|
||||
@@ -212,7 +236,7 @@ function requestDelete(key: string) {
|
||||
function requestBatchDelete() {
|
||||
if (!isAuthenticated.value) return
|
||||
if (!selectedCount.value) {
|
||||
toast.add({ title: '请先选择图片', color: 'warning' })
|
||||
toast.add({ title: t('stats.selectFirst'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
deleteTargetKeys.value = Array.from(selectedKeys.value)
|
||||
@@ -249,7 +273,7 @@ async function confirmDelete() {
|
||||
}
|
||||
selectedKeys.value = new Set(selectedKeys.value)
|
||||
toast.add({
|
||||
title: `已删除 ${deleted.length} 张图片`,
|
||||
title: t('stats.deleted', { n: deleted.length }),
|
||||
color: 'success'
|
||||
})
|
||||
await refreshStats()
|
||||
@@ -257,7 +281,7 @@ async function confirmDelete() {
|
||||
|
||||
if (failed.length) {
|
||||
toast.add({
|
||||
title: `${failed.length} 张图片删除失败`,
|
||||
title: t('stats.deletePartialFail', { n: failed.length }),
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
@@ -267,7 +291,7 @@ async function confirmDelete() {
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '删除失败', color: 'error' })
|
||||
toast.add({ title: t('stats.deleteFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
batchDeleting.value = false
|
||||
@@ -322,7 +346,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
正在验证登录状态…
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,21 +354,24 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<AdminLoginGate v-else-if="!isAuthenticated" />
|
||||
|
||||
<AppShell v-else>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
统计
|
||||
{{ t('stats.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
数据概览与图库记录
|
||||
{{ t('stats.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="刷新"
|
||||
variant="outline"
|
||||
:loading="statsLoading || galleryLoading"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="mt-0.5 shrink-0"
|
||||
:aria-label="t('common.refresh')"
|
||||
:loading="pageRefreshing"
|
||||
@click="refreshAll"
|
||||
/>
|
||||
</div>
|
||||
@@ -367,13 +394,13 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||
上传目录分布
|
||||
{{ t('stats.folderDistribution') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="!uploadChartTotal"
|
||||
class="py-6 text-center text-xs text-muted"
|
||||
>
|
||||
暂无数据
|
||||
{{ t('stats.noData') }}
|
||||
</div>
|
||||
<StatsDonut
|
||||
v-else
|
||||
@@ -384,13 +411,13 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||
目录占用
|
||||
{{ t('stats.folderUsage') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="!folderStats.length"
|
||||
class="py-6 text-center text-xs text-muted"
|
||||
>
|
||||
暂无数据
|
||||
{{ t('stats.noData') }}
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
@@ -403,7 +430,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="font-medium">{{ item.folder }}</span>
|
||||
<span class="tabular-nums text-muted">
|
||||
{{ item.count }} 张 · {{ formatFileSize(item.bytes) }}
|
||||
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||
@@ -418,86 +445,85 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<form
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
@submit.prevent="handleSearch"
|
||||
>
|
||||
<div class="min-w-0 shrink-0">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-medium">
|
||||
图库记录
|
||||
{{ t('stats.gallery') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!galleryLoading"
|
||||
class="text-sm text-muted"
|
||||
class="mt-0.5 text-sm text-muted"
|
||||
>
|
||||
{{ listSummary }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-muted shrink-0">目录</span>
|
||||
<USelect
|
||||
v-model="currentFolder"
|
||||
:items="folderSelectItems"
|
||||
class="w-28"
|
||||
@update:model-value="handleFolderChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
placeholder="搜索文件名"
|
||||
class="w-40 sm:w-48"
|
||||
size="sm"
|
||||
:disabled="galleryLoading"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
label="搜索"
|
||||
size="sm"
|
||||
:loading="galleryLoading"
|
||||
/>
|
||||
<UButton
|
||||
v-if="activeSearch"
|
||||
type="button"
|
||||
label="清除"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="刷新"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="galleryLoading"
|
||||
@click="refreshGallery"
|
||||
/>
|
||||
<form
|
||||
class="flex flex-wrap items-center justify-end gap-2"
|
||||
@submit.prevent="handleSearch"
|
||||
>
|
||||
<UBadge
|
||||
v-if="selectedCount"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
class="shrink-0"
|
||||
>
|
||||
已选 {{ selectedCount }}
|
||||
{{ t('stats.selected', { n: selectedCount }) }}
|
||||
</UBadge>
|
||||
<USelect
|
||||
v-model="currentFolder"
|
||||
:items="folderSelectItems"
|
||||
class="w-[5.5rem] shrink-0 sm:w-32"
|
||||
size="sm"
|
||||
:aria-label="t('stats.folder')"
|
||||
@update:model-value="handleFolderChange"
|
||||
/>
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('stats.searchPlaceholder')"
|
||||
class="w-36 shrink-0 sm:w-44"
|
||||
size="sm"
|
||||
:disabled="galleryLoading"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:aria-label="t('common.search')"
|
||||
:loading="galleryLoading"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('common.search') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="activeSearch"
|
||||
type="button"
|
||||
icon="i-lucide-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:aria-label="t('stats.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-trash-2"
|
||||
label="批量删除"
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:aria-label="t('stats.batchDelete')"
|
||||
:disabled="!selectedCount"
|
||||
@click="requestBatchDelete"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.batchDelete') }}</span>
|
||||
</UButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="galleryLoading"
|
||||
@@ -512,6 +538,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
|
||||
<ImageGrid
|
||||
v-else
|
||||
:show-key="false"
|
||||
:items="items"
|
||||
:selected-keys="selectedKeys"
|
||||
:deleting-keys="deletingKeys"
|
||||
@@ -524,7 +551,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:page="galleryPage"
|
||||
:total-pages="galleryTotalPages"
|
||||
:total="galleryTotal"
|
||||
unit="张"
|
||||
:unit="t('common.imagesUnit')"
|
||||
:loading="galleryLoading"
|
||||
@update:page="handleGalleryPageChange"
|
||||
/>
|
||||
@@ -540,7 +567,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<UButton
|
||||
v-show="showScrollTop"
|
||||
icon="i-lucide-arrow-up"
|
||||
aria-label="回到顶部"
|
||||
:aria-label="t('stats.scrollTop')"
|
||||
color="primary"
|
||||
class="fixed bottom-6 right-6 z-50 shadow-lg"
|
||||
size="lg"
|
||||
|
||||
Reference in new issue
Block a user