Compare commits

...

13 Commits

Author SHA1 Message Date
imeepos 37a4a2f807 fix: 成功对接登录接口 2026-01-13 16:25:49 +08:00
imeepos 2f290eeedd fix: 区分登录失败和 Token 过期的 401 错误
- 登录/注册接口的 401 不触发全局拦截器
- 让认证错误正常传递到表单组件显示 i18n 翻译
- 只有其他接口的 401 才认为是 Token 过期
- 移除调试日志

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:19:22 +08:00
imeepos a66bcca38b fix: 使用 RNText 并设置白色文字颜色 2026-01-13 16:16:43 +08:00
imeepos 40571d2337 fix: 修复 Toast 支持字符串参数 2026-01-13 16:13:21 +08:00
imeepos 7cf896215c fix: 增加 Toast 默认显示时间从 2秒 到 4秒 2026-01-13 16:12:20 +08:00
imeepos 3eeff10338 fix: 使用内联样式修复 Toast 显示过小问题
- 使用内联样式替代 TailwindCSS 类名
- 设置固定位置在屏幕 30% 高度处
- 字体大小 18px,字重 600
- 背景 rgba(0,0,0,0.9) 深色半透明
- 最小高度 60px,内边距 20px
- 圆角 12px,添加阴影效果

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:09:59 +08:00
imeepos 3f1f10be49 fix: 优化 Toast 样式使其更明显
- 增大字体从 16px 到 18px
- 添加字重加粗 (font-semibold)
- 增加内边距 (px: 12→24, py: 8→16)
- 增加外边距 (mx: 20→40)
- 增加背景不透明度 (85%→90%)
- 增加圆角 (2px→8px)
- 添加阴影效果 (shadow-lg)
- 调整垂直位置 (mt-[-40px]→mt-[-80px])

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:08:21 +08:00
imeepos 99e2c5fafc fix: 修复 Toast 未定义导致 fetch 拦截器崩溃
- 添加 Toast 调用的 try-catch 安全检查
- 修复 "Cannot read properties of undefined (reading 'show')" 错误
- 确保 fetch 拦截器在 Toast 未初始化时也能正常工作

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:06:47 +08:00
imeepos 2d0a21205f feat: 添加认证错误 i18n 翻译
- 添加常见认证错误代码的翻译(中英文)
- 修改 AuthForm 组件处理错误代码
- 支持的错误类型:
  - INVALID_USERNAME_OR_PASSWORD: 用户名或密码错误
  - USER_NOT_FOUND: 用户不存在
  - EMAIL_ALREADY_EXISTS: 邮箱已被注册
  - USERNAME_ALREADY_EXISTS: 用户名已被占用
  - WEAK_PASSWORD: 密码强度不够
  - INVALID_EMAIL: 邮箱格式不正确
  - VALIDATION_ERROR: 输入信息格式错误
  - NETWORK_ERROR: 网络连接失败
  - UNKNOWN_ERROR: 未知错误

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:05:30 +08:00
imeepos ee119d472f fix: 修复 fetch 拦截器正确处理 JSON 请求体
- 正确处理不同类型的 headers(Headers 对象、数组、普通对象)
- 自动序列化对象类型的请求体为 JSON 字符串
- 确保非 GET/HEAD 请求设置正确的 Content-Type
- 修复登录请求体变成 [object Object] 的问题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:02:28 +08:00
imeepos c2c097efb3 fix: 添加 Content-Type: application/json 请求头
修复登录/注册接口返回 VALIDATION_ERROR 的问题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:01:13 +08:00
imeepos 24fd3a8847 feat: 完善认证系统
- 修复 Storage 层使用 AsyncStorage
- 统一 Token 存储键名为 bestaibest.better-auth.session_token
- 启用 401 自动跳转登录页并显示 Toast 提示
- 添加全局认证守卫(AuthGuard 组件)
- 修复 x-ownerid header 配置(从环境变量读取商户ID)
- 导出 loomart API 供活动数据使用

主要修改:
- lib/storage.native.ts: 新建,使用 AsyncStorage
- lib/storage.ts: 添加错误处理和注释
- lib/fetch-logger.ts: 统一 Token 键,启用 401 拦截
- lib/auth.ts: 导出 TOKEN_KEY,修复 x-ownerid,导出 loomart
- app/_layout.tsx: 添加 AuthGuard 组件实现路由守卫

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 16:00:02 +08:00
imeepos 1a6df3c806 fix: 登录页面 2026-01-13 15:55:52 +08:00
12 changed files with 396 additions and 87 deletions

2
.gitignore vendored
View File

@ -8,7 +8,7 @@ node_modules/
dist/
web-build/
expo-env.d.ts
tmpclaude-*
# Native
.kotlin/
*.orig.*

View File

@ -1,24 +1,23 @@
import { useState, useRef, useEffect } from 'react'
import { Image } from 'expo-image'
import { LinearGradient } from 'expo-linear-gradient'
import { useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
View,
Text,
StyleSheet,
Animated,
Dimensions,
ScrollView,
Platform,
Pressable,
StatusBar as RNStatusBar,
Animated,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, DownArrowIcon, WhiteStarIcon } from '@/components/icon'
import { AuthForm } from '@/components/blocks/AuthForm'
import { DownArrowIcon, PointsIcon, SearchIcon, WhiteStarIcon } from '@/components/icon'
import { useActivates } from '@/hooks/use-activates'
const { width: screenWidth } = Dimensions.get('window')
@ -279,10 +278,6 @@ export default function HomeScreen() {
{renderTabs()}
</View>
<View className='py-6'>
<AuthForm mode='register' />
</View>
{/* 内容网格 */}
<View
style={styles.gridContainer}

View File

@ -1,42 +1,110 @@
import '../global.css'
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack } from 'expo-router'
import { Stack, usePathname, useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { useEffect } from 'react'
import 'react-native-reanimated'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { View, ActivityIndicator } from 'react-native'
import { useColorScheme } from '@/hooks/use-color-scheme'
import { KeyboardProvider } from 'react-native-keyboard-controller'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { ModalPortal } from '@/components/ui'
import '@/lib/i18n'
import { useSession } from '@/lib/auth'
export const unstable_settings = {
anchor: '(tabs)',
}
// 受保护的路由(需要登录才能访问)
const PROTECTED_ROUTES = [
'/(tabs)',
'/channels',
'/generateVideo',
'/generationRecord',
'/worksList',
'/membership',
'/changePassword',
]
// 公开路由(无需登录)
const PUBLIC_ROUTES = [
'/auth',
'/modal',
'/searchTemplate',
'/searchResults',
'/searchWorks',
'/searchWorksResults',
'/templateDetail',
'/terms',
'/privacy',
]
// 认证守卫组件
function AuthGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { data: session, isPending } = useSession()
useEffect(() => {
// 加载中不处理
if (isPending) return
// 检查是否在受保护路由
const isProtectedRoute = PROTECTED_ROUTES.some(route => pathname?.startsWith(route))
// 未登录访问受保护路由 -> 跳转到登录页
if (isProtectedRoute && !session?.user) {
router.replace('/auth')
return
}
// 已登录访问登录页 -> 跳转到首页
if (pathname === '/auth' && session?.user) {
router.replace('/(tabs)')
return
}
}, [pathname, session, isPending, router])
// 加载中显示 loading
if (isPending) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#090A0B' }}>
<ActivityIndicator size="large" color="#9966FF" />
</View>
)
}
return <>{children}</>
}
// 路由层
export default function RootLayout() {
return (
<Providers>
<Stack>
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="channels" options={{ headerShown: false }} />
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
<Stack.Screen name="membership" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="worksList" options={{ headerShown: false }} />
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
</Stack>
<AuthGuard>
<Stack>
<Stack.Screen name="auth" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="channels" options={{ headerShown: false }} />
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
<Stack.Screen name="membership" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="worksList" options={{ headerShown: false }} />
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
</Stack>
</AuthGuard>
<StatusBar style="auto" />
</Providers>
)

View File

@ -1,5 +1,75 @@
import React, { useEffect } from 'react'
import { View, StyleSheet, StatusBar as RNStatusBar } from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { LinearGradient } from 'expo-linear-gradient'
import { AuthForm } from '@/components/blocks/AuthForm'
import { useSession } from '@/lib/auth'
export default function Auth() {
const router = useRouter()
const { data: session, isPending } = useSession()
export default () => {
return null;
}
useEffect(() => {
// 如果用户已登录,跳转到首页
if (session?.user) {
router.replace('/(tabs)' as any)
}
}, [session, router])
// 会话加载中显示空白
if (isPending) {
return (
<View style={styles.container}>
<StatusBar style="light" />
</View>
)
}
// 如果已登录,不显示内容(等待跳转)
if (session?.user) {
return null
}
const handleSuccess = () => {
router.replace('/(tabs)' as any)
}
return (
<LinearGradient
colors={['#1a1a2e', '#16213e', '#0f3460']}
locations={[0, 0.5, 1]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.background}
>
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<View style={styles.content}>
<AuthForm onSuccess={handleSuccess} />
</View>
</SafeAreaView>
</LinearGradient>
)
}
const styles = StyleSheet.create({
background: {
flex: 1,
width: '100%',
height: '100%',
},
container: {
flex: 1,
backgroundColor: 'transparent',
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
})

View File

@ -1,19 +1,15 @@
import React, { useState } from "react";
import { View, TextInput, ActivityIndicator } from "react-native";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput } from "react-native";
import { authClient, setAuthToken, useSession } from "../../lib/auth";
import { Block } from "../ui";
import { Button } from "../ui/button";
import Text from "../ui/Text";
import { authClient, useSession } from "../../lib/auth";
import { Block } from "../ui";
type AuthMode = "login" | "register";
type AuthResponse = { data: unknown; error: { message: string } | null };
type SignInFn = (params: { username: string; password: string }) => Promise<AuthResponse>;
type SignUpFn = (params: { email: string; password: string; name: string }) => Promise<AuthResponse>;
const signIn = authClient.signIn as unknown as { username: SignInFn };
const signUp = authClient.signUp as unknown as { email: SignUpFn };
const signIn = authClient.signIn
const signUp = authClient.signUp
interface AuthFormProps {
mode?: AuthMode;
@ -32,6 +28,18 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
const isLogin = currentMode === "login";
// 根据错误代码获取翻译后的错误信息
const getErrorMessage = (error: { message: string; code?: string }): string => {
if (error.code) {
const translated = t(`authForm.errors.${error.code}`);
// 如果翻译不存在,返回原始 message
if (!translated.includes('authForm.errors')) {
return translated;
}
}
return error.message;
};
const handleSubmit = async () => {
if (!username.trim() || !password.trim()) {
setError(t("authForm.fillCompleteInfo"));
@ -48,11 +56,31 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
try {
if (isLogin) {
const res = await signIn.username({ username, password });
if (res.error) throw new Error(res.error.message);
await signIn.username({ username, password }, {
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
setAuthToken(authToken)
}
},
onError: (ctx) => {
setError(getErrorMessage(ctx.error));
console.error(`[LOGIN] username login error`, ctx)
},
});
} else {
const res = await signUp.email({ email, password, name: username });
if (res.error) throw new Error(res.error.message);
await signUp.email({ email, password, name: username }, {
onSuccess: async (ctx) => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
setAuthToken(authToken)
}
},
onError: (ctx) => {
setError(getErrorMessage(ctx.error));
console.error(`[LOGIN] username login error`, ctx)
},
});
}
onSuccess?.();
} catch (e: unknown) {
@ -140,3 +168,4 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
}
export { useSession };

View File

@ -1,6 +1,7 @@
import React, { ReactNode } from 'react'
import Text from './Text'
import Block from './Block'
import { Text as RNText } from 'react-native'
import { ActivityIndicator } from 'react-native'
@ -65,20 +66,35 @@ const Toast = (function () {
// })
let toastTimer: number | null = null
const show = (params?: ShowParams): void => {
const { renderContent, title, duration = 2000, hideBackdrop = true } = params || {}
const show = (params?: ShowParams | string): void => {
// 兼容字符串参数
const options: ShowParams = typeof params === 'string' ? { title: params } : (params || {})
const { renderContent, title, duration = 4000, hideBackdrop = true } = options
hide()
const renderBody = (): ReactNode => {
if (renderContent) {
return renderContent()
}
return title && <Text className="text-[16px] text-white">{title}</Text>
return title && <RNText style={{ fontSize: 18, fontWeight: '600', color: '#ffffff' }}>{title}</RNText>
}
; (global as any).toast?.show(
<Block className="z-[9999] flex items-center justify-center">
<Block className="relative mx-[20px] mt-[-40px] rounded-[2px] bg-black/85 px-[12px] py-[8px]">
<Block className="z-[9999] flex items-center justify-center" style={{ position: 'fixed', top: '30%', left: 0, right: 0 }}>
<Block style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 20,
marginHorizontal: 40,
minHeight: 60,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}}>
{renderBody()}
</Block>
</Block>,

View File

@ -12,13 +12,17 @@ import {
usernameClient,
} from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import Constants from 'expo-constants'
import { fetchWithLogger } from './fetch-logger'
import { fetchWithLogger, TOKEN_KEY } from './fetch-logger'
import type { Subscription } from './plugins/stripe'
import { stripeClient } from './plugins/stripe-plugin'
import { storage } from './storage'
import type { ApiError } from './types'
// 商户ID配置 - 从环境变量或应用配置中读取
const MERCHANT_ID = Constants.expoConfig?.extra?.merchantId || ''
export interface CreditBalance {
tokenUsage: number
remainingTokenBalance: number
@ -72,10 +76,11 @@ export interface ISubscription {
restore: (params?: SubscriptionRestoreParams) => Promise<{ data?: SubscriptionRestoreResponse; error?: ApiError }>
billingPortal: (params?: BillingPortalParams) => Promise<{ data?: BillingPortalResponse; error?: ApiError }>
}
export const getAuthToken = async () => (await storage.getItem('bestaibest.better-auth.session_token')) || ''
// 统一的 Token 存储键名(与 fetch-logger.ts 保持一致)
export const getAuthToken = async () => (await storage.getItem(TOKEN_KEY)) || ''
export const setAuthToken = async (token: string) => {
await storage.setItem(`bestaibest.better-auth.session_token`, token)
await storage.setItem(TOKEN_KEY, token)
}
export const authClient = createAuthClient({
baseURL: 'https://api.mixvideo.bowong.cc',
@ -83,6 +88,12 @@ export const authClient = createAuthClient({
storage,
scheme: 'duooomi',
fetchOptions: {
headers: {
'Content-Type': 'application/json',
// x-ownerid: 商户ID如果后端需要
// 如果 MERCHANT_ID 为空,则不添加此 header
...(MERCHANT_ID && { 'x-ownerid': MERCHANT_ID }),
},
auth: {
type: 'Bearer',
token: async () => {
@ -111,4 +122,8 @@ export const authClient = createAuthClient({
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp } =
authClient
// 导出 loomart API来自 createSkerClientPlugin
export const loomart = Reflect.get(authClient, 'loomart')
// 导出 subscription API
export const subscription: ISubscription = Reflect.get(authClient, 'subscription')

View File

@ -1,6 +1,10 @@
import { router } from 'expo-router'
import { router } from 'expo-router';
import Toast from '@/components/ui/Toast';
import { storage } from './storage'
import { storage } from './storage';
// 统一的 Token 存储键名(与 lib/auth.ts 保持一致)
export const TOKEN_KEY = 'bestaibest.better-auth.session_token';
interface FetchLoggerOptions {
enableLogging?: boolean
@ -18,6 +22,9 @@ const defaultOptions: FetchLoggerOptions = {
const originalFetch = global.fetch
// 防止重复跳转的标志
let isRedirecting = false
export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
const config = { ...defaultOptions, ...options }
@ -27,24 +34,79 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
const method = init?.method || 'GET'
try {
const token = await storage.getItem('token')
// 使用统一的 Token 存储键
const token = await storage.getItem(TOKEN_KEY)
if (token) {
init = {
...init,
headers: {
...init?.headers,
authorization: `Bearer ${token}`,
},
// 确保 headers 是一个对象
const headers: Record<string, string> = {}
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => {
headers[key] = value
})
} else if (Array.isArray(init.headers)) {
init.headers.forEach(([key, value]) => {
headers[key] = value as string
})
} else {
Object.assign(headers, init.headers)
}
}
const response = await originalFetch(input, init)
// 添加 Bearer Token
if (token) {
headers['authorization'] = `Bearer ${token}`
}
if (response.status === 401) {
// 确保 Content-Type 正确设置
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
// 序列化请求体(如果是对象)
let body = init?.body
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof URLSearchParams)) {
body = JSON.stringify(body)
}
const response = await originalFetch(input, {
...init,
headers,
...(body !== undefined && { body }),
})
// 401 未授权处理
if (response.status === 401 && !isRedirecting) {
// 检查是否是登录/注册接口的 401 错误(用户名密码错误)
const isAuthEndpoint = url.includes('/sign-in/') || url.includes('/sign-up/')
// 如果是认证接口的 401不处理让错误传递到组件
if (isAuthEndpoint) {
console.log('🔐 认证接口返回 401不处理')
return response
}
// 其他接口的 401 才认为是 Token 过期
console.warn('🔐 401 未授权,跳转到登录页')
// router.replace('/auth')
router.push('/auth')
isRedirecting = true
// 显示 Toast 提示(安全检查)
try {
Toast.show('登录已过期,请重新登录')
} catch (error) {
console.warn('[Toast] 显示失败:', error)
}
// 清除过期 Token
await storage.removeItem(TOKEN_KEY)
// 跳转到登录页(使用 replace 避免返回栈问题)
router.replace('/auth')
// 延迟重置标志(防止并发请求导致重复跳转)
setTimeout(() => {
isRedirecting = false
}, 1000)
}
return response

View File

@ -1,15 +1,28 @@
import * as SecureStore from "expo-secure-store";
import AsyncStorage from '@react-native-async-storage/async-storage';
export const storage = {
async getItem(key: string): Promise<string | null> {
return await SecureStore.getItemAsync(key);
try {
return await AsyncStorage.getItem(key);
} catch (error) {
console.error(`[Storage] Error getting item "${key}":`, error);
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
try {
await AsyncStorage.setItem(key, value);
} catch (error) {
console.error(`[Storage] Error setting item "${key}":`, error);
}
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
try {
await AsyncStorage.removeItem(key);
} catch (error) {
console.error(`[Storage] Error removing item "${key}":`, error);
}
},
};

View File

@ -1,17 +1,36 @@
/**
* Web平台存储实现 (使 localStorage)
* Native平台会自动使用 storage.native.ts (使 AsyncStorage)
*/
declare const window: any;
export const storage = {
async getItem(key: string): Promise<string | null> {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
try {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
} catch (error) {
console.error(`[Storage] Error getting item "${key}":`, error);
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
if (typeof window === "undefined") return;
window.localStorage.setItem(key, value);
try {
if (typeof window === "undefined") return;
window.localStorage.setItem(key, value);
} catch (error) {
console.error(`[Storage] Error setting item "${key}":`, error);
}
},
async removeItem(key: string): Promise<void> {
if (typeof window === "undefined") return;
window.localStorage.removeItem(key);
try {
if (typeof window === "undefined") return;
window.localStorage.removeItem(key);
} catch (error) {
console.error(`[Storage] Error removing item "${key}":`, error);
}
},
};

View File

@ -181,7 +181,18 @@
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
"noAccountRegister": "No account? Register",
"haveAccountLogin": "Already have an account? Login"
"haveAccountLogin": "Already have an account? Login",
"errors": {
"INVALID_USERNAME_OR_PASSWORD": "Invalid username or password",
"USER_NOT_FOUND": "User not found",
"EMAIL_ALREADY_EXISTS": "Email already exists",
"USERNAME_ALREADY_EXISTS": "Username already taken",
"WEAK_PASSWORD": "Password is too weak",
"INVALID_EMAIL": "Invalid email format",
"VALIDATION_ERROR": "Validation error",
"NETWORK_ERROR": "Network connection failed",
"UNKNOWN_ERROR": "Unknown error, please try again later"
}
}
}

View File

@ -181,7 +181,18 @@
"loginFailed": "登录失败",
"registerFailed": "注册失败",
"noAccountRegister": "没有账号?去注册",
"haveAccountLogin": "已有账号?去登录"
"haveAccountLogin": "已有账号?去登录",
"errors": {
"INVALID_USERNAME_OR_PASSWORD": "用户名或密码错误",
"USER_NOT_FOUND": "用户不存在",
"EMAIL_ALREADY_EXISTS": "邮箱已被注册",
"USERNAME_ALREADY_EXISTS": "用户名已被占用",
"WEAK_PASSWORD": "密码强度不够",
"INVALID_EMAIL": "邮箱格式不正确",
"VALIDATION_ERROR": "输入信息格式错误",
"NETWORK_ERROR": "网络连接失败",
"UNKNOWN_ERROR": "未知错误,请稍后重试"
}
}
}