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

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:
O96u committed 2026-08-23 17:43:42 +08:00
1 parent 074e4502c2
commit 22cb2ca8bc
46 files changed
+3724 -587

No files matched your search

+12 -11
View File
@@ -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
View File
@@ -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: &lt;token&gt;</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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"