liuq hai 3 meses
pai
achega
7dd11460e1

+ 141 - 41
backend/app/api/v1/endpoints/apps.py

@@ -23,7 +23,8 @@ from app.schemas.application import (
     ApplicationSecretDisplay,
     ViewSecretRequest,
     RegenerateSecretRequest,
-    ApplicationTransferRequest
+    ApplicationTransferRequest,
+    AppSyncRequest
 )
 from app.schemas.mapping import (
     MappingList,
@@ -86,6 +87,25 @@ def read_apps(
     apps = query.order_by(desc(Application.id)).offset(skip).limit(limit).all()
     return {"total": total, "items": apps}
 
+@router.get("/{app_id}", response_model=ApplicationResponse, summary="获取单个应用详情")
+def read_app(
+    *,
+    db: Session = Depends(deps.get_db),
+    app_id: int,
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    获取单个应用详情。
+    """
+    app = db.query(Application).filter(Application.id == app_id).first()
+    if not app:
+        raise HTTPException(status_code=404, detail="应用未找到")
+    
+    if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
+        raise HTTPException(status_code=403, detail="权限不足")
+        
+    return app
+
 @router.post("/", response_model=ApplicationSecretDisplay, summary="创建应用")
 def create_app(
     *,
@@ -119,46 +139,6 @@ def create_app(
     db.commit()
     db.refresh(db_app)
 
-    # Sync users if requested
-    if app_in.sync_all_users:
-        users = db.query(User).filter(User.is_deleted == 0).all()
-        mappings_to_create = []
-        for user in users:
-            # Skip if user has no English name (required for mapping key)
-            if not user.english_name:
-                continue
-            
-            mapped_key = user.english_name
-            mapped_email = None
-            if app_in.sync_email and app_in.email_domain:
-                mapped_email = f"{mapped_key}@{app_in.email_domain}"
-            
-            mapping = AppUserMapping(
-                app_id=db_app.id,
-                user_id=user.id,
-                mapped_key=mapped_key,
-                mapped_email=mapped_email,
-                is_active=True
-            )
-            mappings_to_create.append(mapping)
-        
-        if mappings_to_create:
-            db.bulk_save_objects(mappings_to_create)
-            db.commit()
-            
-            LogService.create_log(
-                db=db,
-                app_id=db_app.id,
-                operator_id=current_user.id,
-                action_type=ActionType.IMPORT,
-                details={
-                    "message": "Auto synced users on creation",
-                    "count": len(mappings_to_create),
-                    "sync_email": app_in.sync_email,
-                    "email_domain": app_in.email_domain
-                }
-            )
-    
     return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret, access_token=access_token)
 
 @router.put("/{app_id}", response_model=ApplicationResponse, summary="更新应用")
@@ -770,6 +750,126 @@ def sync_users_to_app(
 
     return {"message": "没有需要同步的用户"}
 
+@router.post("/{app_id}/sync-users-v2", summary="同步用户 (新版)")
+def sync_users_to_app_v2(
+    *,
+    db: Session = Depends(deps.get_db),
+    app_id: int,
+    sync_req: AppSyncRequest,
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    高级用户同步功能。
+    支持全量/部分同步,以及可选的默认邮箱初始化。
+    需要手机验证码。
+    """
+    app = db.query(Application).filter(Application.id == app_id).first()
+    if not app:
+        raise HTTPException(status_code=404, detail="应用未找到")
+    
+    if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    # 1. Verify SMS Code
+    if not SmsService.verify_code(current_user.mobile, sync_req.verification_code):
+        raise HTTPException(status_code=400, detail="验证码无效或已过期")
+
+    # 2. Determine Target Users
+    query = db.query(User).filter(User.is_deleted == 0)
+    
+    if sync_req.mode == "SELECTED":
+        if not sync_req.user_ids:
+             raise HTTPException(status_code=400, detail="请选择要同步的用户")
+        query = query.filter(User.id.in_(sync_req.user_ids))
+    
+    users = query.all()
+    
+    if not users:
+        return {"message": "没有找到可同步的用户"}
+
+    # 3. Get existing mappings (user_ids) to skip
+    existing_mappings = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id).all()
+    mapped_user_ids = {m.user_id for m in existing_mappings}
+    
+    # Check if email domain is valid format if provided (simple check)
+    if sync_req.init_email and not sync_req.email_domain:
+         raise HTTPException(status_code=400, detail="开启邮箱初始化时必须填写域名")
+
+    new_mappings = []
+    
+    for user in users:
+        if user.id in mapped_user_ids:
+            continue
+            
+        # Determine mapped_key (English name)
+        # If english_name is missing, fallback to mobile? Or skip?
+        # User requirement: "账号就是英文名" (Account is English name)
+        # Assuming if english_name is empty, we might use mobile or some generation.
+        # Let's fallback to mobile if english_name is missing, but prefer english_name.
+        mapped_key = user.english_name if user.english_name else user.mobile
+        
+        mapped_email = None
+        if sync_req.init_email and user.english_name:
+            # Construct email
+            domain = sync_req.email_domain.strip()
+            if not domain.startswith("@"):
+                domain = "@" + domain
+            mapped_email = f"{user.english_name}{domain}"
+        
+        mapping = AppUserMapping(
+            app_id=app.id,
+            user_id=user.id,
+            mapped_key=mapped_key,
+            mapped_email=mapped_email,
+            is_active=True
+        )
+        new_mappings.append(mapping)
+
+    if not new_mappings:
+        return {"message": "所有选中的用户均已存在映射,无需同步"}
+
+    # 4. Insert
+    # We'll try to insert one by one to handle potential unique constraint violations (e.g. same english name for different users)
+    # Or we can try bulk and catch. Given "User can check selection", maybe best effort is good.
+    
+    success_count = 0
+    fail_count = 0
+    
+    for m in new_mappings:
+        try:
+            # Check unique key collision within this transaction if possible, 
+            # but db.add + commit per row is safer for partial success report.
+            
+            # Additional check: uniqueness of mapped_key in this app
+            # (We already have uniqueness constraint in DB)
+            db.add(m)
+            db.commit()
+            success_count += 1
+        except Exception:
+            db.rollback()
+            fail_count += 1
+            
+    # 5. Log
+    LogService.create_log(
+        db=db,
+        app_id=app.id,
+        operator_id=current_user.id,
+        action_type=ActionType.SYNC,
+        details={
+            "mode": sync_req.mode,
+            "init_email": sync_req.init_email,
+            "total_attempted": len(new_mappings),
+            "success": success_count,
+            "failed": fail_count
+        }
+    )
+    
+    msg = f"同步完成。成功: {success_count},失败: {fail_count}"
+    if fail_count > 0:
+        msg += " (失败原因可能是账号或邮箱冲突)"
+        
+    return {"message": msg}
+
 @router.get("/{app_id}/mappings/export", summary="导出映射")
 def export_mappings(
     *,

+ 3 - 0
backend/app/api/v1/endpoints/open_api.py

@@ -129,6 +129,9 @@ def reset_password(
         raise HTTPException(status_code=404, detail="用户未找到")
         
     # 3. Update Password
+    if not security.validate_password_strength(req.new_password):
+        raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
+
     user.password_hash = security.get_password_hash(req.new_password)
     db.add(user)
     db.commit()

+ 6 - 0
backend/app/api/v1/endpoints/simple_auth.py

@@ -204,6 +204,9 @@ def register_user(
     if req.password:
         req.password = req.password.strip()
 
+    if not security.validate_password_strength(req.password):
+        raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
+
     existing_user = db.query(User).filter(User.mobile == req.mobile, User.is_deleted == 0).first()
     if existing_user:
         raise HTTPException(status_code=400, detail="手机号已注册")
@@ -334,6 +337,9 @@ def change_my_password(
     if req.new_password:
         req.new_password = req.new_password.strip()
 
+    if not security.validate_password_strength(req.new_password):
+        raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
+
     current_user.password_hash = security.get_password_hash(req.new_password)
     db.add(current_user)
     db.commit()

+ 10 - 1
backend/app/core/security.py

@@ -46,4 +46,13 @@ def generate_alphanumeric_password(length: int = 8) -> str:
         if (any(c.islower() for c in password)
                 and any(c.isupper() for c in password)
                 and any(c.isdigit() for c in password)):
-            return password
+            return password
+
+def validate_password_strength(password: str) -> bool:
+    """
+    Validate that the password contains at least one letter and one digit.
+    Returns True if valid, False otherwise.
+    """
+    has_letter = any(c.isalpha() for c in password)
+    has_digit = any(c.isdigit() for c in password)
+    return has_letter and has_digit

+ 8 - 3
backend/app/schemas/application.py

@@ -15,9 +15,7 @@ class ApplicationBase(BaseModel):
     notification_url: Optional[str] = None
 
 class ApplicationCreate(ApplicationBase):
-    sync_all_users: bool = False
-    sync_email: bool = False
-    email_domain: Optional[str] = None
+    pass
 
 class ApplicationUpdate(BaseModel):
     app_name: Optional[str] = None
@@ -59,3 +57,10 @@ class ApplicationSecretDisplay(BaseModel):
 
 class ViewSecretRequest(BaseModel):
     password: str
+
+class AppSyncRequest(BaseModel):
+    mode: str = "ALL"  # "ALL" or "SELECTED"
+    user_ids: Optional[List[int]] = []
+    init_email: bool = False
+    email_domain: Optional[str] = None
+    verification_code: str

+ 1 - 0
backend/app/schemas/operation_log.py

@@ -16,6 +16,7 @@ class ActionType(str, Enum):
     VIEW_SECRET = "VIEW_SECRET"
     REGENERATE_SECRET = "REGENERATE_SECRET"
     SYNC_M2M = "SYNC_M2M"
+    SYNC = "SYNC"
 
 class OperationLogBase(BaseModel):
     app_id: Optional[int] = None

+ 17 - 4
frontend/src/api/apps.ts

@@ -20,9 +20,6 @@ export interface ApplicationCreate {
   notification_url?: string
   password?: string
   verification_code?: string
-  sync_all_users?: boolean
-  sync_email?: boolean
-  email_domain?: string
 }
 
 export interface AppSecretResponse {
@@ -57,7 +54,7 @@ export interface MappingListResponse {
 export interface OperationLog {
     id: number
     app_id: number
-    action_type: 'MANUAL_ADD' | 'DELETE' | 'UPDATE' | 'IMPORT' | 'TRANSFER' | 'VIEW_SECRET' | 'REGENERATE_SECRET'
+    action_type: 'MANUAL_ADD' | 'DELETE' | 'UPDATE' | 'IMPORT' | 'TRANSFER' | 'VIEW_SECRET' | 'REGENERATE_SECRET' | 'SYNC'
     target_mobile?: string
     operator_mobile?: string
     details?: any
@@ -82,6 +79,10 @@ export const getApps = (skip = 0, limit = 10, search = '') => {
   return api.get<ApplicationListResponse>('/apps/', { params: { skip, limit, search } })
 }
 
+export const getApp = (id: number) => {
+  return api.get<Application>(`/apps/${id}`)
+}
+
 export const createApp = (data: ApplicationCreate) => {
   return api.post<AppSecretResponse>('/apps/', data)
 }
@@ -131,6 +132,18 @@ export const syncAppUsers = (appId: number) => {
   return api.post<{ message: string }>(`/apps/${appId}/sync-users`)
 }
 
+export interface AppSyncRequest {
+    mode: 'ALL' | 'SELECTED'
+    user_ids?: number[]
+    init_email: boolean
+    email_domain?: string
+    verification_code: string
+}
+
+export const syncAppUsersV2 = (appId: number, data: AppSyncRequest) => {
+  return api.post<{ message: string }>(`/apps/${appId}/sync-users-v2`, data)
+}
+
 export const getOperationLogs = (appId: number, params: LogQueryParams) => {
     return api.get<LogListResponse>(`/apps/${appId}/logs`, { params })
 }

+ 9 - 0
frontend/src/api/users.ts

@@ -11,3 +11,12 @@ export const searchUsers = (keyword: string) => {
   return api.get<User[]>('/users/search', { params: { keyword } })
 }
 
+export interface UserListResponse {
+  total: number
+  items: User[]
+}
+
+export const getUsers = (params: any) => {
+  return api.get<UserListResponse>('/users/', { params })
+}
+

+ 5 - 0
frontend/src/router/index.ts

@@ -61,6 +61,11 @@ const routes: Array<RouteRecordRaw> = [
         name: 'MappingImport',
         component: () => import('../views/apps/MappingImport.vue')
       },
+      {
+        path: 'apps/:id/sync',
+        name: 'AppSync',
+        component: () => import('../views/apps/AppSync.vue')
+      },
       {
         path: 'mappings',
         name: 'MyMappings',

+ 2 - 1
frontend/src/views/Dashboard.vue

@@ -167,7 +167,8 @@ const pwdRules = reactive<FormRules>({
   old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
   new_password: [
     { required: true, message: '请输入新密码', trigger: 'blur' },
-    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
+    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
+    { pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/, message: '密码必须包含字母和数字', trigger: 'blur' }
   ],
   confirm_password: [{ required: true, validator: validatePass2, trigger: 'blur' }]
 })

+ 2 - 1
frontend/src/views/Register.vue

@@ -146,7 +146,8 @@ const step2Rules = reactive<FormRules>({
   ],
   password: [
     { required: true, message: '请输入密码', trigger: 'blur' },
-    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
+    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
+    { pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/, message: '密码必须包含字母和数字', trigger: 'blur' }
   ],
   confirmPassword: [
     { validator: validatePass2, trigger: 'blur' }

+ 57 - 22
frontend/src/views/ResetPassword.vue

@@ -34,11 +34,11 @@
       </el-form>
 
       <!-- Step 2: Reset Password -->
-      <el-form v-if="activeStep === 1" :model="form" label-width="0">
+      <el-form v-if="activeStep === 1" :model="form" :rules="step2Rules" ref="step2FormRef" label-width="0">
         <el-form-item>
            <el-input v-model="form.mobile" disabled prefix-icon="Iphone" />
         </el-form-item>
-        <el-form-item>
+        <el-form-item prop="sms_code">
           <div style="display: flex; width: 100%; gap: 10px;">
             <el-input 
               v-model="form.sms_code" 
@@ -53,7 +53,7 @@
             </el-button>
           </div>
         </el-form-item>
-        <el-form-item>
+        <el-form-item prop="new_password">
           <el-input 
             v-model="form.new_password" 
             type="password" 
@@ -64,6 +64,16 @@
             autocomplete="new-password"
           />
         </el-form-item>
+        <el-form-item prop="confirm_password">
+          <el-input 
+            v-model="form.confirm_password" 
+            type="password" 
+            placeholder="确认新密码" 
+            prefix-icon="Lock" 
+            show-password 
+            autocomplete="new-password"
+          />
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" style="width: 100%" @click="handleReset" :loading="loading">
             确认重置
@@ -103,11 +113,13 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { getCaptcha, sendSms, resetPassword } from '../api/public'
 import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
 
 const router = useRouter()
 const activeStep = ref(0)
 const loading = ref(false)
 const captchaImage = ref('')
+const step2FormRef = ref<FormInstance>()
 
 // Resend Logic State
 const countdown = ref(0)
@@ -130,7 +142,28 @@ const form = reactive({
   captcha_id: '',
   captcha_code: '',
   sms_code: '',
-  new_password: ''
+  new_password: '',
+  confirm_password: ''
+})
+
+const validatePass2 = (rule: any, value: any, callback: any) => {
+  if (value === '') {
+    callback(new Error('请再次输入密码'))
+  } else if (value !== form.new_password) {
+    callback(new Error('两次输入密码不一致!'))
+  } else {
+    callback()
+  }
+}
+
+const step2Rules = reactive<FormRules>({
+  sms_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
+  new_password: [
+    { required: true, message: '请输入新密码', trigger: 'blur' },
+    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
+    { pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/, message: '密码必须包含字母和数字', trigger: 'blur' }
+  ],
+  confirm_password: [{ validator: validatePass2, trigger: 'blur' }]
 })
 
 const fetchCaptcha = async () => {
@@ -216,24 +249,26 @@ const handleSendSms = async () => {
 }
 
 const handleReset = async () => {
-  if (!form.sms_code || !form.new_password) {
-     ElMessage.warning('请输入验证码和新密码')
-     return
-  }
-  loading.value = true
-  try {
-    await resetPassword({
-      mobile: form.mobile,
-      sms_code: form.sms_code,
-      new_password: form.new_password
-    })
-    ElMessage.success('密码重置成功')
-    router.push('/login')
-  } catch (e) {
-    // handled
-  } finally {
-    loading.value = false
-  }
+  if (!step2FormRef.value) return
+  
+  await step2FormRef.value.validate(async (valid) => {
+    if (valid) {
+      loading.value = true
+      try {
+        await resetPassword({
+          mobile: form.mobile,
+          sms_code: form.sms_code,
+          new_password: form.new_password
+        })
+        ElMessage.success('密码重置成功')
+        router.push('/login')
+      } catch (e) {
+        // handled
+      } finally {
+        loading.value = false
+      }
+    }
+  })
 }
 
 onMounted(() => {

+ 5 - 43
frontend/src/views/apps/AppList.vue

@@ -90,21 +90,6 @@
         <el-form-item label="Webhook">
           <el-input v-model="form.notification_url" />
         </el-form-item>
-        <template v-if="!isEdit">
-          <el-form-item label="同步用户">
-             <el-checkbox v-model="form.sync_all_users" label="同步当前所有用户信息" />
-          </el-form-item>
-          <template v-if="form.sync_all_users">
-              <el-form-item label=" ">
-                  <el-checkbox v-model="form.sync_email" label="同步邮箱" />
-              </el-form-item>
-              <el-form-item label="邮箱域名" v-if="form.sync_email" required>
-                  <el-input v-model="form.email_domain" placeholder="例如: example.com">
-                      <template #prepend>@</template>
-                  </el-input>
-              </el-form-item>
-          </template>
-        </template>
       </el-form>
       <template #footer>
         <span class="dialog-footer">
@@ -242,6 +227,7 @@
                 <el-option label="删除映射" value="DELETE" />
                 <el-option label="更新映射" value="UPDATE" />
                 <el-option label="批量导入" value="IMPORT" />
+                <el-option label="同步用户" value="SYNC" />
                 <el-option label="应用转让" value="TRANSFER" />
                 <el-option label="查看密钥" value="VIEW_SECRET" />
                 <el-option label="重置密钥" value="REGENERATE_SECRET" />
@@ -311,7 +297,7 @@ import { ref, onMounted, reactive, computed, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../../store/auth'
 import { 
-  getApps, createApp, updateApp, deleteApp, regenerateSecret, viewSecret, transferApp, getOperationLogs, syncAppUsers,
+  getApps, createApp, updateApp, deleteApp, regenerateSecret, viewSecret, transferApp, getOperationLogs,
   Application, ApplicationCreate, OperationLog
 } from '../../api/apps'
 import { searchUsers, User } from '../../api/users'
@@ -360,10 +346,7 @@ const form = reactive<ApplicationCreate>({
   app_name: '',
   protocol_type: 'SIMPLE_API',
   redirect_uris: '',
-  notification_url: '',
-  sync_all_users: true,
-  sync_email: true,
-  email_domain: ''
+  notification_url: ''
 })
 
 const secretData = reactive({
@@ -410,9 +393,6 @@ const openCreateDialog = () => {
   form.protocol_type = 'SIMPLE_API'
   form.redirect_uris = ''
   form.notification_url = ''
-  form.sync_all_users = true
-  form.sync_email = true
-  form.email_domain = ''
   dialogVisible.value = true
 }
 
@@ -433,10 +413,6 @@ const handleSubmit = async () => {
       securityAction.value = 'EDIT'
       openSecurityDialog()
     } else {
-      if (form.sync_all_users && form.sync_email && !form.email_domain) {
-        ElMessage.warning('请输入邮箱域名')
-        return
-      }
       const res = await createApp(form)
       secretData.app_id = res.data.app_id
       secretData.app_secret = res.data.app_secret
@@ -575,22 +551,7 @@ const transferDynamicFields = reactive({
 
 // Sync Users
 const handleSyncUsers = (row: Application) => {
-  ElMessageBox.confirm(
-    '这将把所有系统用户同步到此应用中。已存在的映射将被跳过,新映射将使用英文名作为账号。是否继续?',
-    '同步确认',
-    {
-      confirmButtonText: '确定同步',
-      cancelButtonText: '取消',
-      type: 'warning',
-    }
-  ).then(async () => {
-    try {
-      const res = await syncAppUsers(row.id)
-      ElMessage.success(res.data.message)
-    } catch (e: any) {
-       // Handled by interceptor usually
-    }
-  }).catch(() => {})
+  router.push(`/dashboard/apps/${row.id}/sync`)
 }
 
 // Logs
@@ -665,6 +626,7 @@ const formatActionType = (type: string) => {
         'DELETE': '删除映射',
         'UPDATE': '更新映射',
         'IMPORT': '批量导入',
+        'SYNC': '同步用户',
         'TRANSFER': '应用转让',
         'VIEW_SECRET': '查看密钥',
         'REGENERATE_SECRET': '重置密钥',

+ 371 - 0
frontend/src/views/apps/AppSync.vue

@@ -0,0 +1,371 @@
+<template>
+  <div class="app-sync-container">
+    <div class="page-header">
+      <el-page-header @back="goBack">
+        <template #content>
+          <span class="text-large font-600 mr-3"> 数据同步 </span>
+        </template>
+        <template #extra>
+          <div class="flex items-center">
+            <span v-if="app" class="app-name">应用: {{ app.app_name }}</span>
+          </div>
+        </template>
+      </el-page-header>
+    </div>
+
+    <el-card class="sync-card" v-loading="loading">
+      <template #header>
+        <div class="card-header">
+          <span>同步配置</span>
+        </div>
+      </template>
+
+      <el-form :model="form" label-width="120px">
+        <el-form-item label="同步范围">
+          <el-radio-group v-model="form.mode">
+            <el-radio label="ALL">所有用户</el-radio>
+            <el-radio label="SELECTED">选择用户</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <div v-if="form.mode === 'SELECTED'" class="user-selection-area">
+          <el-table
+            ref="userTableRef"
+            :data="users"
+            style="width: 100%"
+            border
+            @selection-change="handleSelectionChange"
+          >
+            <el-table-column type="selection" width="55" />
+            <el-table-column prop="name" label="姓名" width="120" />
+            <el-table-column prop="english_name" label="英文名" width="120" />
+            <el-table-column prop="mobile" label="手机号" width="150" />
+            <el-table-column prop="status" label="状态" width="100">
+              <template #default="scope">
+                <el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'info'">
+                  {{ scope.row.status }}
+                </el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+          
+          <div class="pagination-container">
+            <el-pagination
+              v-model:current-page="page"
+              v-model:page-size="pageSize"
+              :page-sizes="[10, 20, 50, 100]"
+              layout="total, sizes, prev, pager, next"
+              :total="total"
+              @size-change="fetchUsers"
+              @current-change="fetchUsers"
+            />
+          </div>
+        </div>
+
+        <el-form-item label="邮箱设置">
+          <el-checkbox v-model="form.init_email">初始化默认域名邮箱</el-checkbox>
+          <div v-if="form.init_email" class="email-domain-input">
+            <el-input 
+              v-model="form.email_domain" 
+              placeholder="请输入域名 (例如: @example.com)" 
+              style="width: 300px"
+            >
+              <template #prepend>账号 (英文名) + </template>
+            </el-input>
+            <div class="form-tip">
+              勾选后,将为没有映射邮箱的用户生成邮箱:english_name@domain
+            </div>
+          </div>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="handlePreSync" :disabled="form.mode === 'SELECTED' && selectedUsers.length === 0">
+            开始同步
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- Confirmation Dialog -->
+    <el-dialog
+      v-model="confirmDialogVisible"
+      title="同步确认"
+      width="500px"
+    >
+      <div class="confirm-content">
+        <el-alert
+          title="安全验证"
+          type="warning"
+          :closable="false"
+          show-icon
+          style="margin-bottom: 20px"
+        >
+          <p>请确认同步信息并进行安全验证。</p>
+        </el-alert>
+        
+        <div class="summary-item">
+          <span class="label">同步目标应用:</span>
+          <span class="value">{{ app?.app_name }}</span>
+        </div>
+        <div class="summary-item">
+          <span class="label">同步用户数量:</span>
+          <span class="value highlight">{{ syncCount }} 人</span>
+        </div>
+        <div class="summary-item">
+          <span class="label">同步字段:</span>
+          <span class="value">
+            账号 (英文名/手机号), 手机号, 状态
+            <span v-if="form.init_email">, 邮箱 ({{ form.email_domain }})</span>
+          </span>
+        </div>
+
+        <el-divider />
+
+        <el-form :model="securityForm" label-width="0">
+           <el-form-item>
+             <div class="verify-code-container">
+                <el-input v-model="securityForm.verificationCode" placeholder="请输入手机验证码" />
+                <el-button type="primary" :disabled="timer > 0" @click="sendCode">
+                  {{ timer > 0 ? `${timer}s` : '发送验证码' }}
+                </el-button>
+             </div>
+           </el-form-item>
+        </el-form>
+      </div>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="confirmDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmSync" :loading="syncing">
+            确认同步
+          </el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getApp, Application, syncAppUsersV2 } from '../../api/apps'
+import { getUsers, User } from '../../api/users'
+import { sendImportVerificationCode } from '../../api/mapping' // Reusing the send verification code API
+import { ElMessage } from 'element-plus'
+
+const route = useRoute()
+const router = useRouter()
+const appId = Number(route.params.id)
+
+const app = ref<Application | null>(null)
+const loading = ref(false)
+const syncing = ref(false)
+
+// Form
+const form = reactive({
+  mode: 'ALL',
+  init_email: false,
+  email_domain: '@hnyunzhu.com'
+})
+
+// User Table Data
+const users = ref<User[]>([])
+const total = ref(0)
+const page = ref(1)
+const pageSize = ref(10)
+const selectedUsers = ref<User[]>([])
+
+// Security
+const confirmDialogVisible = ref(false)
+const securityForm = reactive({
+  verificationCode: ''
+})
+const timer = ref(0)
+
+// Computed
+const syncCount = computed(() => {
+  if (form.mode === 'ALL') return total.value // Approximation if we don't fetch all. Ideally backend tells us, but for now we use total.
+  return selectedUsers.value.length
+})
+
+onMounted(async () => {
+  if (!appId) {
+    ElMessage.error('Invalid App ID')
+    router.push({ name: 'AppList' })
+    return
+  }
+  await fetchApp()
+  await fetchUsers()
+})
+
+const fetchApp = async () => {
+  try {
+    const res = await getApp(appId)
+    app.value = res.data
+  } catch (e) {
+    // handled by interceptor
+  }
+}
+
+const fetchUsers = async () => {
+  loading.value = true
+  try {
+    // We only need to fetch users if we are displaying the table. 
+    // But we also need 'total' for "ALL" mode estimation? 
+    // Yes, fetchUsers gets total.
+    const res = await getUsers({
+      skip: (page.value - 1) * pageSize.value,
+      limit: pageSize.value
+    })
+    users.value = res.data.items
+    total.value = res.data.total
+  } catch (e) {
+    // error
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSelectionChange = (val: User[]) => {
+  selectedUsers.value = val
+}
+
+const goBack = () => {
+  router.push({ name: 'AppList' })
+}
+
+const validateDomain = (domain: string) => {
+  // Allow optional starting @, then standard domain regex
+  // ^@? indicates optional @ at start
+  // [a-zA-Z0-9] starts the domain part
+  // (?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? middle part
+  // \. dot
+  // [a-zA-Z]{2,} TLD
+  const regex = /^@?([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
+  return regex.test(domain)
+}
+
+const handlePreSync = () => {
+  if (form.init_email) {
+      if (!form.email_domain) {
+          ElMessage.warning('请输入邮箱域名')
+          return
+      }
+      if (!validateDomain(form.email_domain)) {
+          ElMessage.warning('邮箱域名格式不正确 (例如: @hnyunzhu.com)')
+          return
+      }
+  }
+  
+  if (form.mode === 'SELECTED' && selectedUsers.value.length === 0) {
+    ElMessage.warning('请选择要同步的用户')
+    return
+  }
+  
+  securityForm.verificationCode = ''
+  confirmDialogVisible.value = true
+}
+
+const sendCode = async () => {
+  try {
+    await sendImportVerificationCode() // Using the existing endpoint which sends to current user
+    ElMessage.success('验证码已发送')
+    timer.value = 60
+    const interval = setInterval(() => {
+      timer.value--
+      if (timer.value <= 0) clearInterval(interval)
+    }, 1000)
+  } catch (e) {
+    // error
+  }
+}
+
+const confirmSync = async () => {
+  if (!securityForm.verificationCode) {
+    ElMessage.warning('请输入验证码')
+    return
+  }
+  
+  syncing.value = true
+  try {
+    const userIds = form.mode === 'SELECTED' ? selectedUsers.value.map(u => u.id) : []
+    
+    const res = await syncAppUsersV2(appId, {
+      mode: form.mode as 'ALL' | 'SELECTED',
+      user_ids: userIds,
+      init_email: form.init_email,
+      email_domain: form.email_domain,
+      verification_code: securityForm.verificationCode
+    })
+    
+    ElMessage.success(res.data.message)
+    confirmDialogVisible.value = false
+    // Maybe go back or stay?
+    // User might want to see logs?
+    // Let's stay but refresh selection?
+    // Or go back to app list.
+    // Let's offer to go to logs or close.
+    // For now, simple success message.
+  } catch (e) {
+    // error
+  } finally {
+    syncing.value = false
+  }
+}
+</script>
+
+<style scoped>
+.app-sync-container {
+  padding: 20px;
+}
+.page-header {
+  margin-bottom: 20px;
+}
+.sync-card {
+  max-width: 1000px;
+  margin: 0 auto;
+}
+.user-selection-area {
+  margin-top: 20px;
+  margin-bottom: 20px;
+}
+.pagination-container {
+  margin-top: 15px;
+  display: flex;
+  justify-content: flex-end;
+}
+.email-domain-input {
+  margin-top: 10px;
+  display: flex;
+  flex-direction: column;
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 5px;
+}
+.confirm-content {
+  padding: 10px;
+}
+.summary-item {
+  margin-bottom: 10px;
+  display: flex;
+  justify-content: space-between;
+}
+.summary-item .label {
+  font-weight: bold;
+  color: #606266;
+}
+.summary-item .value {
+  color: #303133;
+}
+.summary-item .value.highlight {
+  color: #409EFF;
+  font-weight: bold;
+}
+.verify-code-container {
+  display: flex;
+  gap: 10px;
+  width: 100%;
+}
+</style>
+