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