zlwu il y a 10 mois
Parent
commit
c3dc4665c7

+ 7 - 1
backend/main.py

@@ -114,6 +114,7 @@ class UpdateBotNameRequest(BaseModel):
 
 class RegisterPhoneRequest(BaseModel):
     """手机号注册请求"""
+    area_code: Optional[str] = None
     phone: str
     username: str = ""
     user_id: Optional[str] = None  # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
@@ -140,6 +141,7 @@ class LoginQQRequest(BaseModel):
 
 class LoginPhoneRequest(BaseModel):
     """手机号登录请求"""
+    area_code: Optional[str] = None
     phone: str
 
 
@@ -708,6 +710,7 @@ async def register_with_phone(request: RegisterPhoneRequest):
     """
     try:
         user = await user_service.register_with_phone(
+            area_code=request.area_code,
             phone=request.phone,
             username=request.username,
             user_id=request.user_id
@@ -831,7 +834,10 @@ async def login_with_phone(request: LoginPhoneRequest):
         }
     """
     try:
-        user = await user_service.login_with_phone(request.phone)
+        user = await user_service.login_with_phone(
+            phone=request.phone,
+            area_code=request.area_code
+        )
         if user:
             return {
                 "success": True,

+ 92 - 0
backend/migrate_add_area_code_to_users.py

@@ -0,0 +1,92 @@
+"""
+数据库迁移脚本:为 users 表添加 area_code 字段
+运行: python migrate_add_area_code_to_users.py
+"""
+import asyncio
+import sys
+import aiomysql
+from config import settings
+
+
+async def migrate_add_area_code_column() -> bool:
+    """为 users 表添加 area_code 字段"""
+    try:
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查 users 表是否存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'users'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            table_exists = (await cursor.fetchone())[0] > 0
+            if not table_exists:
+                print("❌ users 表不存在,请先创建表")
+                conn.close()
+                return False
+
+            # 检查 area_code 列是否已存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s
+                  AND table_name = 'users'
+                  AND column_name = 'area_code'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            has_area_code = (await cursor.fetchone())[0] > 0
+
+            if has_area_code:
+                print("✅ area_code 字段已存在,无需迁移")
+                conn.close()
+                return True
+
+            print("🔄 添加 area_code 字段...")
+            await cursor.execute(
+                """
+                ALTER TABLE users
+                ADD COLUMN area_code VARCHAR(10) NULL COMMENT '手机区号(如 +86)' AFTER user_type
+                """
+            )
+            print("✅ area_code 字段添加成功")
+
+            await conn.commit()
+            print("✅ 迁移完成")
+
+        conn.close()
+        return True
+
+    except Exception as e:  # noqa: BLE001
+        import traceback
+
+        print(f"❌ 迁移失败: {e}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    # 确认是否继续
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续迁移 users 表添加 area_code 字段? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    success = asyncio.run(migrate_add_area_code_column())
+
+    if not success:
+        sys.exit(1)
+

+ 2 - 0
backend/models_db.py

@@ -37,6 +37,7 @@ class User(Base):
     bot_name = Column(String(100), nullable=True, comment="用户给机器人取的名字")
     
     # 注册信息(可选)
+    area_code = Column(String(10), nullable=True, comment="手机区号(如 +86)")
     phone = Column(String(20), unique=True, nullable=True, index=True, comment="手机号")
     email = Column(String(200), unique=True, nullable=True, index=True, comment="邮箱")
     wechat_openid = Column(String(100), unique=True, nullable=True, index=True, comment="微信OpenID")
@@ -68,6 +69,7 @@ class User(Base):
             "username": self.username,
             "user_type": self.user_type,
             "bot_name": self.bot_name,
+            "area_code": self.area_code,
             "phone": self.phone,
             "email": self.email,
             "wechat_openid": self.wechat_openid,

+ 24 - 5
backend/services/user_service.py

@@ -589,7 +589,13 @@ class UserService:
             logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
             return None
     
-    async def register_with_phone(self, phone: str, username: str = "", user_id: Optional[str] = None) -> Dict:
+    async def register_with_phone(
+        self,
+        phone: str,
+        username: str = "",
+        user_id: Optional[str] = None,
+        area_code: Optional[str] = None,
+    ) -> Dict:
         """
         使用手机号注册
         
@@ -640,6 +646,8 @@ class UserService:
                             username = existing_user.username
                     
                     existing_user.phone = phone
+                    if area_code is not None:
+                        existing_user.area_code = area_code
                     existing_user.username = username
                     existing_user.user_type = "registered"
                     existing_user.is_verified = True
@@ -668,6 +676,7 @@ class UserService:
                         user_id=new_user_id,
                         username=username,
                         phone=phone,
+                        area_code=area_code,
                         user_type="registered",
                         is_verified=True,
                         registered_at=datetime.now(),
@@ -694,7 +703,7 @@ class UserService:
                 current_avatar_id=REGISTERED_AVATAR_ID
             )
             
-            logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, phone={phone}")
+            logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, area_code={area_code}, phone={phone}")
             return user.to_dict()
         except Exception as e:
             logger.error(f"❌ 手机号注册失败: {e}")
@@ -969,7 +978,7 @@ class UserService:
             logger.error(f"❌ QQ登录/注册失败: {e}")
             raise
     
-    async def login_with_phone(self, phone: str) -> Optional[Dict]:
+    async def login_with_phone(self, phone: str, area_code: Optional[str] = None) -> Optional[Dict]:
         """
         使用手机号登录
         
@@ -982,12 +991,22 @@ class UserService:
         try:
             async with db_manager.get_session() as session:
                 result = await session.execute(
-                    select(User).where(User.phone == phone)
+                    select(User).where(
+                        User.phone == phone,
+                        *( [User.area_code == area_code] if area_code else [] )
+                    )
                 )
                 user = result.scalar_one_or_none()
+                
+                # 兼容旧数据:未存区号时再用空区号回退查询
+                if not user and area_code:
+                    fallback_result = await session.execute(
+                        select(User).where(User.phone == phone, User.area_code.is_(None))
+                    )
+                    user = fallback_result.scalar_one_or_none()
             
             if user:
-                logger.info(f"✅ 手机号登录成功: user_id={user.user_id}, phone={phone}")
+                logger.info(f"✅ 手机号登录成功: user_id={user.user_id}, area_code={area_code}, phone={phone}")
                 return user.to_dict()
             return None
         except Exception as e:

+ 5 - 5
frontend/src/pages/FirstLoginPage.tsx

@@ -13,6 +13,8 @@ const phoneLogin = getOssUrl('phone-login.png');
 const emailLogin = getOssUrl('email-login.png');
 const continueBtn = getOssUrl('continue-btn.png');
 const continueBtnA = getOssUrl('continue-btn-a.png');
+const focusGradient =
+  'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
 interface LocationState {
   loginType?: 'phone' | 'qq' | 'email';
@@ -223,7 +225,7 @@ export const FirstLoginPage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -286,7 +288,7 @@ export const FirstLoginPage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -351,9 +353,7 @@ export const FirstLoginPage = () => {
               className="w-full transition-opacity duration-300"
               style={{
                 height: '2PX',
-                background: isEmailFocused
-                  ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                  : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+                background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
               }}
             ></div>
           </div>

+ 3 - 3
frontend/src/pages/GreetingPage.tsx

@@ -16,6 +16,8 @@ import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { getOssUrl } from '../utils/oss';
 
 const ConfirmBtnImage = getOssUrl('confirm-btn.png');
+const focusGradient =
+  'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
 const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());
 
@@ -390,9 +392,7 @@ export const GreetingPage = () => {
             className="w-full rounded-b-[4px] transition-opacity duration-300"
             style={{
               height: '2PX',
-              background: isFocused
-                ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+              background: isFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
             }}
           ></div>
         </div>

+ 7 - 6
frontend/src/pages/LoginPage.tsx

@@ -68,7 +68,7 @@ export const LoginPage = () => {
       setIsChecking(true);
       try {
         // 检查用户是否存在
-        await loginWithPhone(phoneNumber);
+        await loginWithPhone(phoneNumber, areaCode);
         // 用户存在,跳转到验证码页面
         navigate('/login-verification-code', {
           state: {
@@ -128,6 +128,9 @@ export const LoginPage = () => {
     }
   };
 
+  const focusGradient =
+    'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
+
   return (
     <div>
       {/* 顶部返回按钮 */}
@@ -271,7 +274,7 @@ export const LoginPage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -334,7 +337,7 @@ export const LoginPage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -399,9 +402,7 @@ export const LoginPage = () => {
               className="w-full transition-opacity duration-300"
               style={{
                 height: '2PX',
-                background: isEmailFocused
-                  ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                  : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+                background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
               }}
             ></div>
           </div>

+ 5 - 5
frontend/src/pages/LoginVerificationCodePage.tsx

@@ -10,6 +10,8 @@ import { getOssUrl } from '@/utils/oss';
 const sendCodeNo = getOssUrl('send-code-no.png');
 const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
+const focusGradient =
+  'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
 interface LocationState {
   loginType?: 'phone' | 'email';
@@ -233,7 +235,7 @@ export const LoginVerificationCodePage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -296,7 +298,7 @@ export const LoginVerificationCodePage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -361,9 +363,7 @@ export const LoginVerificationCodePage = () => {
               className="w-full transition-opacity duration-300"
               style={{
                 height: '2PX',
-                background: isEmailFocused
-                  ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                  : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+                background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
               }}
             ></div>
           </div>

+ 1 - 1
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -140,7 +140,7 @@ export const LoginVerificationInputPage = () => {
       if (state?.loginType === 'email' && state?.email) {
         response = await loginWithEmail(state.email);
       } else if (state?.phoneNumber) {
-        response = await loginWithPhone(state.phoneNumber);
+        response = await loginWithPhone(state.phoneNumber, state?.areaCode);
       } else {
         message.error('登录信息不完整');
         setIsLoggingIn(false);

+ 3 - 3
frontend/src/pages/LumiNamingPage.tsx

@@ -8,6 +8,8 @@ import { getOssUrl } from '../utils/oss';
 const ConfirmBtnImage = getOssUrl('confirm-btn.png');
 
 const DEFAULT_LUMI_NAME = 'Lumi';
+const focusGradient =
+  'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
 export const LumiNamingPage = () => {
   const navigate = useNavigate();
@@ -232,9 +234,7 @@ export const LumiNamingPage = () => {
             className="w-full rounded-b-[4px] transition-opacity duration-300"
             style={{
               height: '2PX',
-              background: isFocused
-                ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+              background: isFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
             }}
           ></div>
         </div>

+ 5 - 5
frontend/src/pages/VerificationCodePage.tsx

@@ -10,6 +10,8 @@ const giftProgressBar = getOssUrl('gift-progress-bar.png');
 const sendCodeNo = getOssUrl('send-code-no.png');
 const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
+const focusGradient =
+  'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
 interface LocationState {
   loginType?: 'phone' | 'email';
@@ -232,7 +234,7 @@ export const VerificationCodePage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -295,7 +297,7 @@ export const VerificationCodePage = () => {
                 style={{
                   height: '2PX',
                   background: isAreaCodeFocused || isPhoneFocused
-                    ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
+                    ? focusGradient
                     : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
                 }}
               ></div>
@@ -360,9 +362,7 @@ export const VerificationCodePage = () => {
               className="w-full transition-opacity duration-300"
               style={{
                 height: '2PX',
-                background: isEmailFocused
-                  ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
-                  : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
+                background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
               }}
             ></div>
           </div>

+ 6 - 1
frontend/src/pages/VerificationInputPage.tsx

@@ -240,7 +240,12 @@ export const VerificationInputPage = () => {
       if (state?.loginType === 'email' && state?.email) {
         response = await registerWithEmail(state.email, '', currentUserId);
       } else if (state?.phoneNumber) {
-        response = await registerWithPhone(state.phoneNumber, '', currentUserId);
+        response = await registerWithPhone(
+          state.phoneNumber,
+          '',
+          currentUserId,
+          state?.areaCode,
+        );
       } else {
         message.error('注册信息不完整');
         return;

+ 14 - 4
frontend/src/utils/api.ts

@@ -283,6 +283,7 @@ export interface RegisterPhoneResponse {
   data: {
     user_id: string;
     username: string;
+    area_code?: string;
     phone: string;
     user_type: string;
     user_level?: number;
@@ -290,12 +291,20 @@ export interface RegisterPhoneResponse {
   };
 }
 
-export const registerWithPhone = async (phone: string, username: string = '', user_id?: string | null): Promise<RegisterPhoneResponse> => {
+export const registerWithPhone = async (
+  phone: string,
+  username: string = '',
+  user_id?: string | null,
+  area_code?: string,
+): Promise<RegisterPhoneResponse> => {
   const apiBaseUrl = getApiBaseUrl();
-  const body: { phone: string; username: string; user_id?: string } = { phone, username };
+  const body: { phone: string; username: string; user_id?: string; area_code?: string } = { phone, username };
   if (user_id) {
     body.user_id = user_id;
   }
+  if (area_code) {
+    body.area_code = area_code;
+  }
   const response = await fetch(`${apiBaseUrl}/api/auth/register/phone`, {
     method: 'POST',
     headers: {
@@ -415,6 +424,7 @@ export interface LoginPhoneResponse {
   data: {
     user_id: string;
     username: string;
+    area_code?: string;
     phone: string;
     user_type: string;
     user_level?: number;
@@ -422,14 +432,14 @@ export interface LoginPhoneResponse {
   };
 }
 
-export const loginWithPhone = async (phone: string): Promise<LoginPhoneResponse> => {
+export const loginWithPhone = async (phone: string, area_code?: string): Promise<LoginPhoneResponse> => {
   const apiBaseUrl = getApiBaseUrl();
   const response = await fetch(`${apiBaseUrl}/api/auth/login/phone`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
-    body: JSON.stringify({ phone }),
+    body: JSON.stringify({ phone, area_code }),
   });
 
   if (!response.ok) {