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

+98
View File
@@ -0,0 +1,98 @@
import type { DropdownMenuItem } from '@nuxt/ui'
export function useAppearanceMenus() {
const colorMode = useColorMode()
const { locale, locales, setLocale, t } = useI18n()
const themeIcon = computed(() => {
if (colorMode.preference === 'system') return 'i-lucide-monitor'
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
})
const localeMenuItems = computed<DropdownMenuItem[][]>(() => [[
...locales.value.map(loc => ({
label: t(`locale.${loc.code}`),
icon: 'i-lucide-languages',
type: 'checkbox' as const,
checked: locale.value === loc.code,
onUpdateChecked(checked: boolean) {
if (checked && typeof loc.code === 'string') {
void setLocale(loc.code)
}
}
}))
]])
const themeMenuItems = computed<DropdownMenuItem[][]>(() => [[
{
label: t('theme.light'),
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.preference === 'light',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'light'
}
},
{
label: t('theme.dark'),
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.preference === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'dark'
}
},
{
label: t('theme.system'),
icon: 'i-lucide-monitor',
type: 'checkbox',
checked: colorMode.preference === 'system',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'system'
}
}
]])
const themeOptions = computed(() => [
{
value: 'light' as const,
label: t('theme.light'),
icon: 'i-lucide-sun'
},
{
value: 'dark' as const,
label: t('theme.dark'),
icon: 'i-lucide-moon'
},
{
value: 'system' as const,
label: t('theme.system'),
icon: 'i-lucide-monitor'
}
])
const localeOptions = computed(() =>
locales.value.map(loc => ({
code: String(loc.code),
label: t(`locale.${loc.code}`)
}))
)
function setThemePreference(value: 'light' | 'dark' | 'system') {
colorMode.preference = value
}
async function setLocalePreference(code: string) {
await setLocale(code)
}
return {
themeIcon,
localeMenuItems,
themeMenuItems,
localeOptions,
themeOptions,
setThemePreference,
setLocalePreference
}
}
+15 -12
View File
@@ -26,13 +26,15 @@ export function isUnauthorizedError(error: unknown): boolean {
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const AUTH_MIN_PASSWORD_LENGTH = 8
export function validateAuthInput(username: string, password: string): string | null {
export type AuthValidationErrorKey = 'invalidUsername' | 'invalidPassword'
export function validateAuthInput(username: string, password: string): AuthValidationErrorKey | null {
const trimmed = username.trim()
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
return '用户名需为 3–32 位字母、数字或下划线'
return 'invalidUsername'
}
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
return '密码至少 8 位'
return 'invalidPassword'
}
return null
}
@@ -65,6 +67,7 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string }
export function useAuth() {
const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
@@ -131,7 +134,7 @@ export function useAuth() {
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
error: getFetchErrorMessage(error, t('auth.loginFailed'))
}
}
}
@@ -142,7 +145,7 @@ export function useAuth() {
): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password)
if (validationError) {
return { ok: false, error: validationError }
return { ok: false, error: t(`auth.errors.${validationError}`) }
}
try {
@@ -161,7 +164,7 @@ export function useAuth() {
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '注册失败,请检查输入')
error: getFetchErrorMessage(error, t('auth.registerFailed'))
}
}
}
@@ -173,7 +176,7 @@ export function useAuth() {
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
return { ok: false, error: t(`auth.errors.${validationError}`) }
}
try {
@@ -196,7 +199,7 @@ export function useAuth() {
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
error: getFetchErrorMessage(error, t('auth.setupFailed'))
}
}
}
@@ -208,7 +211,7 @@ export function useAuth() {
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
return { ok: false, error: t(`auth.errors.${validationError}`) }
}
try {
@@ -231,7 +234,7 @@ export function useAuth() {
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
error: getFetchErrorMessage(error, t('auth.migrateFailed'))
}
}
}
@@ -241,7 +244,7 @@ export function useAuth() {
newPassword: string
): Promise<AuthActionResult> {
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
return { ok: false, error: '新密码至少 8 位' }
return { ok: false, error: t('password.tooShort') }
}
try {
@@ -254,7 +257,7 @@ export function useAuth() {
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '修改密码失败')
error: getFetchErrorMessage(error, t('password.changeFailed'))
}
}
}
+6 -7
View File
@@ -6,6 +6,7 @@ import type {
const PAGE_SIZE = 12
export function useImageList() {
const { t } = useI18n()
const items = ref<ImageItem[]>([])
const page = ref(1)
const totalPages = ref(1)
@@ -165,17 +166,15 @@ export function useImageList() {
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? '全部' : folder
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · 统计中…`
return `${folderLabel} · ${t('stats.counting')}`
}
if (activeSearch.value) {
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
}
if (folderTotal.value === null) {
return `${folderLabel} · 共 ${total.value} 张`
}
return `${folderLabel} · 共 ${folderTotal.value} 张`
const count = folderTotal.value === null ? total.value : folderTotal.value
return t('stats.summaryTotal', { folder: folderLabel, total: count })
})
return {
+6 -5
View File
@@ -9,6 +9,7 @@ const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
@@ -22,13 +23,13 @@ export function useImageUpload() {
if (uploading.value) return
if (!files.length) {
toast.add({ title: '请选择图片', color: 'warning' })
toast.add({ title: t('upload.selectImages'), color: 'warning' })
return
}
if (files.length > MAX_FILES) {
toast.add({
title: `每次最多上传 ${MAX_FILES} 张图片`,
title: t('upload.maxFiles', { n: MAX_FILES }),
color: 'warning'
})
return
@@ -80,14 +81,14 @@ export function useImageUpload() {
if (response.items.length && options?.notify !== false) {
toast.add({
title: `成功上传 ${response.items.length} 张图片`,
title: t('upload.uploadSuccess', { n: response.items.length }),
color: 'success'
})
}
if (response.errors.length) {
toast.add({
title: `${response.errors.length} 张图片上传失败`,
title: t('upload.uploadPartialFail', { n: response.errors.length }),
color: 'warning'
})
}
@@ -97,7 +98,7 @@ export function useImageUpload() {
if (isUnauthorizedError(error)) {
throw error
}
const message = error instanceof Error ? error.message : '上传失败'
const message = error instanceof Error ? error.message : t('upload.uploadFailed')
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'error',
+3 -2
View File
@@ -1,6 +1,7 @@
export function useUserAutoDeleteSettings() {
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t } = useI18n()
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
const enabled = ref(false)
@@ -29,7 +30,7 @@ export function useUserAutoDeleteSettings() {
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
toast.add({ title: t('userSettings.loadFailed'), color: 'error' })
}
}
}
@@ -51,7 +52,7 @@ export function useUserAutoDeleteSettings() {
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '保存设置失败', color: 'error' })
toast.add({ title: t('userSettings.saveFailed'), color: 'error' })
}
} finally {
saving.value = false