liuq 3 ماه پیش
والد
کامیت
fc3679a04b

+ 2 - 1
backend/app/api/v1/api.py

@@ -1,10 +1,11 @@
 from fastapi import APIRouter
 
-from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup, login_logs
+from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup, login_logs, user_import
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
+api_router.include_router(user_import.router, prefix="/users", tags=["用户导入 (User Import)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(logs.router, prefix="/logs", tags=["操作日志 (Logs)"])
 api_router.include_router(login_logs.router, prefix="/login-logs", tags=["登录日志 (Login Logs)"])

+ 97 - 0
backend/app/api/v1/endpoints/user_import.py

@@ -0,0 +1,97 @@
+import json
+from typing import Any, List
+from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, Query
+from sqlalchemy.orm import Session
+from app.api.v1.deps import get_db, get_current_active_user
+from app.models.user import User
+from app.schemas.import_log import ImportPreview, ImportLog as ImportLogSchema, ImportMapping
+from app.services.import_service import UserImportService
+from app.models.import_log import ImportLog
+
+router = APIRouter()
+
+@router.post("/import/preview", response_model=ImportPreview)
+async def preview_import_file(
+    file: UploadFile = File(...),
+    current_user: User = Depends(get_current_active_user),
+):
+    """
+    Upload file and preview first few rows to help with mapping.
+    """
+    if not file.filename.endswith(('.xlsx', '.xls', '.csv')):
+        raise HTTPException(status_code=400, detail="Invalid file format")
+    
+    contents = await file.read()
+    try:
+        result = UserImportService.preview_excel(contents, file.filename)
+        # Create default headers (A, B, C...) if needed, but the service returns data list
+        data = result["preview_data"]
+        headers = []
+        if data and len(data) > 0:
+            # Assuming max columns based on first row
+            num_cols = len(data[0])
+            # Generate A, B, C...
+            import string
+            headers = [string.ascii_uppercase[i] for i in range(min(num_cols, 26))] 
+            # If more than 26, it gets complex (AA, AB), simple loop:
+            # For now assume < 26 columns or frontend handles it
+        
+        return {
+            "headers": headers, 
+            "preview_data": data
+        }
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+@router.post("/import", response_model=ImportLogSchema)
+async def import_users(
+    file: UploadFile = File(...),
+    start_row: int = Form(...),
+    mapping: str = Form(...), # JSON string
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_active_user),
+):
+    """
+    Execute import with file and mapping configuration.
+    Mapping example: {"mobile": 0, "name": 1}
+    """
+    try:
+        mapping_dict = json.loads(mapping)
+    except json.JSONDecodeError:
+        raise HTTPException(status_code=400, detail="Invalid mapping JSON")
+        
+    contents = await file.read()
+    try:
+        log = UserImportService.process_import(
+            db=db,
+            file_contents=contents,
+            filename=file.filename,
+            start_row=start_row,
+            mapping=mapping_dict,
+            user_id=current_user.id
+        )
+        return log
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+@router.get("/import/logs", response_model=List[ImportLogSchema])
+def get_import_logs(
+    skip: int = 0,
+    limit: int = 10,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_active_user),
+):
+    logs = db.query(ImportLog).order_by(ImportLog.created_at.desc()).offset(skip).limit(limit).all()
+    return logs
+
+@router.get("/import/logs/{log_id}", response_model=ImportLogSchema)
+def get_import_log(
+    log_id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_active_user),
+):
+    log = db.query(ImportLog).filter(ImportLog.id == log_id).first()
+    if not log:
+        raise HTTPException(status_code=404, detail="Log not found")
+    return log
+

+ 1 - 0
backend/app/models/__init__.py

@@ -3,3 +3,4 @@ from app.models.user import User
 from app.models.mapping import AppUserMapping
 from app.models.operation_log import OperationLog
 from app.models.login_log import LoginLog
+from app.models.import_log import ImportLog

+ 26 - 0
backend/app/models/import_log.py

@@ -0,0 +1,26 @@
+from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+class ImportLog(Base):
+    __tablename__ = "import_logs"
+
+    id = Column(Integer, primary_key=True, index=True)
+    filename = Column(String(255), nullable=False)
+    total_count = Column(Integer, default=0)
+    success_count = Column(Integer, default=0)
+    fail_count = Column(Integer, default=0)
+    
+    # Store detailed results, e.g., [{"row": 2, "error": "Mobile duplicate"}, ...]
+    # Or [{"mobile": "...", "password": "..."}] for success export (be careful with sensitive data, maybe only store errors here and regenerate success file on demand? 
+    # User said "Export successful results with account and initial password". 
+    # We should probably store the success data temporarily or generated file path. 
+    # Storing passwords in plain text in logs is bad practice, but required for "Export initial password". 
+    # We can store an encrypted blob or just the result JSON if the requirement strictly asks for it. 
+    # For now, let's store `details` as a generic JSON which can hold error logs. 
+    # For the success export, we might generate it immediately and return it, or store the success data in this JSON field (encrypted ideally, but simple JSON for this task).
+    result_data = Column(JSON, nullable=True) 
+    
+    created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+

+ 33 - 0
backend/app/schemas/import_log.py

@@ -0,0 +1,33 @@
+from pydantic import BaseModel
+from typing import Optional, Dict, List, Any
+from datetime import datetime
+
+# Request schema for mapping
+class ImportMapping(BaseModel):
+    start_row: int = 1
+    # Mapping from database field (key) to Excel column index/name (value)
+    # e.g. {"mobile": "A", "name": "B", "english_name": "C"} or {"mobile": 0, "name": 1}
+    mapping: Dict[str, Any]
+
+class ImportLogBase(BaseModel):
+    filename: str
+    total_count: int
+    success_count: int
+    fail_count: int
+    result_data: Optional[List[Dict[str, Any]]] = None
+
+class ImportLogCreate(ImportLogBase):
+    created_by: int
+
+class ImportLog(ImportLogBase):
+    id: int
+    created_at: datetime
+    created_by: Optional[int]
+
+    class Config:
+        from_attributes = True
+
+class ImportPreview(BaseModel):
+    headers: List[str]
+    preview_data: List[List[Any]] # First few rows
+

+ 207 - 0
backend/app/services/import_service.py

@@ -0,0 +1,207 @@
+import io
+import pandas as pd
+import random
+import string
+import re
+from sqlalchemy.orm import Session
+from sqlalchemy import or_
+from app.models.user import User, UserStatus, UserRole
+from app.models.import_log import ImportLog
+from app.core.security import get_password_hash
+# from app.core.utils import generate_password 
+from typing import List, Dict, Any, Tuple
+from datetime import datetime
+
+# Helper to generate random password if utils doesn't have one
+def _generate_random_password(length=8):
+    chars = string.ascii_letters + string.digits
+    return ''.join(random.choice(chars) for _ in range(length))
+
+def _generate_random_string(length=6):
+    chars = string.ascii_lowercase + string.digits
+    return ''.join(random.choice(chars) for _ in range(length))
+
+MOBILE_REGEX = r'^1[3-9]\d{9}$'
+
+class UserImportService:
+    
+    @staticmethod
+    def preview_excel(file_contents: bytes, filename: str) -> Dict[str, Any]:
+        """
+        Read first few rows of excel to let user map columns.
+        """
+        try:
+            if filename.endswith('.csv'):
+                df = pd.read_csv(io.BytesIO(file_contents), header=None, nrows=10)
+            else:
+                df = pd.read_excel(io.BytesIO(file_contents), header=None, nrows=10)
+            
+            # Convert to list of lists (handle NaN)
+            data = df.fillna("").values.tolist()
+            
+            # Generate default headers like A, B, C... if no header? 
+            # Or just return data and let frontend handle "Row 1 is header" logic.
+            # Returning raw data is flexible.
+            return {
+                "preview_data": data,
+                "filename": filename
+            }
+        except Exception as e:
+            raise ValueError(f"Failed to parse file: {str(e)}")
+
+    @staticmethod
+    def process_import(
+        db: Session, 
+        file_contents: bytes, 
+        filename: str, 
+        start_row: int, # 1-based index from frontend
+        mapping: Dict[str, int], # Field -> Column Index (0-based)
+        user_id: int
+    ) -> ImportLog:
+        """
+        Process the Excel file based on mapping.
+        start_row: The row index where data starts (0-based in pandas logic? Frontend usually gives 1-based).
+        mapping: {"mobile": 0, "name": 1, "english_name": 2}
+        """
+        
+        # Load Data
+        try:
+            if filename.endswith('.csv'):
+                df = pd.read_csv(io.BytesIO(file_contents), header=None)
+            else:
+                df = pd.read_excel(io.BytesIO(file_contents), header=None)
+        except Exception as e:
+            raise ValueError(f"Failed to read file: {str(e)}")
+
+        # Adjust start_row to 0-based
+        data_start_idx = start_row - 1
+        if data_start_idx < 0 or data_start_idx >= len(df):
+            raise ValueError("Invalid start row")
+
+        # Slice dataframe
+        df_data = df.iloc[data_start_idx:]
+        
+        success_count = 0
+        fail_count = 0
+        results = [] # Stores {row: 1, status: success/fail, msg: ..., account: ..., password: ...}
+
+        # Cache existing mobiles to minimize DB hits (optional, but good for batch)
+        # For simplicity and correctness with concurrent edits, we check DB row by row or handle IntegrityError.
+        # But logic requires "Repeated mobile cannot be imported", so check first.
+
+        for index, row in df_data.iterrows():
+            row_num = index + 1 # 1-based row number for display
+            
+            try:
+                with db.begin_nested():
+                    # 1. Extract Data
+                    mobile_col = mapping.get("mobile")
+                    name_col = mapping.get("name")
+                    en_name_col = mapping.get("english_name")
+                    
+                    mobile = str(row[mobile_col]).strip() if mobile_col is not None and pd.notna(row[mobile_col]) else None
+                    name = str(row[name_col]).strip() if name_col is not None and pd.notna(row[name_col]) else None
+                    en_name = str(row[en_name_col]).strip() if en_name_col is not None and pd.notna(row[en_name_col]) else None
+                    
+                    if not mobile:
+                        raise ValueError("Mobile is required")
+                    
+                    # Validate Mobile Format
+                    if not re.match(MOBILE_REGEX, mobile):
+                        raise ValueError(f"Invalid mobile format: {mobile}")
+
+                    # 2. Check Mobile Uniqueness
+                    existing_user = db.query(User).filter(User.mobile == mobile).first()
+                    if existing_user:
+                        raise ValueError(f"Mobile {mobile} already exists")
+
+                    # 3. Handle Name Duplicates (Auto-increment or Random)
+                    if not name:
+                        name = f"用户_{_generate_random_string(6)}"
+                    final_name = UserImportService._resolve_duplicate_name(db, name)
+                    
+                    if not en_name:
+                        en_name = f"user_{_generate_random_string(6)}"
+                    final_en_name = UserImportService._resolve_duplicate_en_name(db, en_name)
+
+                    # 4. Create User
+                    plain_password = _generate_random_password()
+                    password_hash = get_password_hash(plain_password)
+                    
+                    new_user = User(
+                        mobile=mobile,
+                        name=final_name,
+                        english_name=final_en_name,
+                        password_hash=password_hash,
+                        status=UserStatus.ACTIVE,
+                        role=UserRole.ORDINARY_USER
+                    )
+                    db.add(new_user)
+                    db.flush()
+
+                    success_count += 1
+                    results.append({
+                        "row": row_num,
+                        "status": "success",
+                        "mobile": mobile,
+                        "name": final_name,
+                        "english_name": final_en_name,
+                        "initial_password": plain_password
+                    })
+
+            except Exception as e:
+                fail_count += 1
+                results.append({
+                    "row": row_num,
+                    "status": "fail",
+                    "error": str(e)
+                })
+
+        # Commit successful rows
+        db.commit()
+
+        # Re-calc counts if commit failed totally? 
+        # Let's refine the loop with begin_nested() for robust partial success.
+
+        # Save Log
+        log = ImportLog(
+            filename=filename,
+            total_count=success_count + fail_count,
+            success_count=success_count,
+            fail_count=fail_count,
+            result_data=results, # Stores passwords! Warning to user about this.
+            created_by=user_id
+        )
+        db.add(log)
+        db.commit()
+        db.refresh(log)
+        
+        return log
+
+    @staticmethod
+    def _resolve_duplicate_name(db: Session, base_name: str) -> str:
+        # Simple heuristic: check base_name, then base_name1, base_name2...
+        # Optimization: Query all matching `base_name%` and find gaps?
+        # Simpler: Loop.
+        
+        # Check exact match first
+        if not db.query(User).filter(User.name == base_name).first():
+            return base_name
+            
+        i = 1
+        while True:
+            new_name = f"{base_name}{i}"
+            if not db.query(User).filter(User.name == new_name).first():
+                return new_name
+            i += 1
+
+    @staticmethod
+    def _resolve_duplicate_en_name(db: Session, base_name: str) -> str:
+        if not db.query(User).filter(User.english_name == base_name).first():
+            return base_name
+        i = 1
+        while True:
+            new_name = f"{base_name}{i}"
+            if not db.query(User).filter(User.english_name == new_name).first():
+                return new_name
+            i += 1

+ 333 - 0
frontend/src/components/UserImportDialog.vue

@@ -0,0 +1,333 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="用户导入"
+    width="800px"
+    :close-on-click-modal="false"
+    @close="handleClose"
+  >
+    <el-steps :active="step" finish-status="success" align-center style="margin-bottom: 20px">
+      <el-step title="上传文件" />
+      <el-step title="列映射设置" />
+      <el-step title="导入结果" />
+    </el-steps>
+
+    <!-- Step 1: Upload -->
+    <div v-if="step === 0" class="upload-container">
+      <el-upload
+        class="upload-demo"
+        drag
+        action="#"
+        :auto-upload="false"
+        :on-change="handleFileChange"
+        :limit="1"
+        accept=".xlsx,.xls,.csv"
+      >
+        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+        <div class="el-upload__text">
+          拖拽文件到此处或 <em>点击上传</em>
+        </div>
+        <template #tip>
+          <div class="el-upload__tip">
+            支持 .xlsx, .xls, .csv 格式文件
+          </div>
+        </template>
+      </el-upload>
+    </div>
+
+    <!-- Step 2: Mapping -->
+    <div v-if="step === 1" class="mapping-container">
+      <div class="preview-header">
+        <el-form inline>
+          <el-form-item label="数据开始行">
+            <el-input-number v-model="startRow" :min="1" />
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <el-alert title="请选择Excel列与系统字段的对应关系" type="info" :closable="false" style="margin-bottom: 10px" />
+
+      <el-form label-width="120px">
+        <el-form-item label="手机号 (必填)">
+          <el-select v-model="mapping.mobile" placeholder="选择列">
+            <el-option
+              v-for="(header, index) in headers"
+              :key="index"
+              :label="`列 ${header}`"
+              :value="index"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="姓名">
+          <el-select v-model="mapping.name" placeholder="选择列">
+            <el-option
+              v-for="(header, index) in headers"
+              :key="index"
+              :label="`列 ${header}`"
+              :value="index"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="英文名">
+          <el-select v-model="mapping.english_name" placeholder="选择列">
+            <el-option
+              v-for="(header, index) in headers"
+              :key="index"
+              :label="`列 ${header}`"
+              :value="index"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+
+      <div class="data-preview">
+        <h4>数据预览 (前10行)</h4>
+        <el-table :data="previewData" border height="200" size="small">
+          <el-table-column
+            v-for="(header, index) in headers"
+            :key="index"
+            :label="header"
+            min-width="100"
+          >
+            <template #default="scope">
+              {{ scope.row[index] }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </div>
+
+    <!-- Step 3: Result -->
+    <div v-if="step === 2" class="result-container">
+      <div class="result-summary">
+        <el-result
+          :icon="importResult.fail_count > 0 ? 'warning' : 'success'"
+          :title="importResult.fail_count > 0 ? '导入完成 (有失败)' : '导入成功'"
+          :sub-title="`总计: ${importResult.total_count}, 成功: ${importResult.success_count}, 失败: ${importResult.fail_count}`"
+        >
+          <template #extra>
+             <el-button type="primary" @click="downloadSuccessRecords" v-if="importResult.success_count > 0">导出成功记录(含密码)</el-button>
+             <el-button type="danger" @click="downloadFailLog" v-if="importResult.fail_count > 0">导出失败日志</el-button>
+          </template>
+        </el-result>
+      </div>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose" v-if="step !== 2">取消</el-button>
+        <el-button @click="handleClose" v-if="step === 2">关闭</el-button>
+        
+        <el-button type="primary" @click="uploadAndPreview" v-if="step === 0" :disabled="!selectedFile">
+          下一步
+        </el-button>
+        
+        <el-button type="primary" @click="executeImport" v-if="step === 1" :loading="importing">
+          开始导入
+        </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { UploadFilled } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import request from '../utils/request' // Assuming request utility exists
+
+const props = defineProps<{
+  modelValue: boolean
+}>()
+
+const emit = defineEmits(['update:modelValue', 'success'])
+
+const visible = ref(props.modelValue)
+const step = ref(0)
+const selectedFile = ref<File | null>(null)
+const headers = ref<string[]>([])
+const previewData = ref<any[]>([])
+const startRow = ref(2) // Default start row 2 (assuming row 1 is header)
+const importing = ref(false)
+
+const mapping = reactive({
+  mobile: null as number | null,
+  name: null as number | null,
+  english_name: null as number | null
+})
+
+const importResult = ref({
+  total_count: 0,
+  success_count: 0,
+  fail_count: 0,
+  result_data: [] as any[]
+})
+
+// Watch prop change
+import { watch } from 'vue'
+watch(() => props.modelValue, (val) => {
+  visible.value = val
+  if (val) {
+    reset()
+  }
+})
+
+watch(visible, (val) => {
+  emit('update:modelValue', val)
+})
+
+const reset = () => {
+  step.value = 0
+  selectedFile.value = null
+  headers.value = []
+  previewData.value = []
+  startRow.value = 2
+  mapping.mobile = null
+  mapping.name = null
+  mapping.english_name = null
+  importResult.value = { total_count: 0, success_count: 0, fail_count: 0, result_data: [] }
+}
+
+const handleFileChange = (file: any) => {
+  selectedFile.value = file.raw
+}
+
+const uploadAndPreview = async () => {
+  if (!selectedFile.value) return
+
+  const formData = new FormData()
+  formData.append('file', selectedFile.value)
+
+  try {
+    const res = await request.post('/users/import/preview', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
+    headers.value = res.data.headers
+    previewData.value = res.data.preview_data
+    
+    // Auto guess mapping if headers match
+    // Simplified logic: If header row exists in preview data
+    // But preview endpoint returns data including header row usually? 
+    // Backend logic: "pd.read_excel(..., header=None)". So row 0 is likely headers.
+    
+    step.value = 1
+  } catch (error) {
+    ElMessage.error('文件解析失败')
+    console.error(error)
+  }
+}
+
+const executeImport = async () => {
+  if (mapping.mobile === null) {
+    ElMessage.warning('请至少映射手机号列')
+    return
+  }
+
+  importing.value = true
+  const formData = new FormData()
+  if (selectedFile.value) {
+    formData.append('file', selectedFile.value)
+  }
+  formData.append('start_row', startRow.value.toString())
+  
+  // Filter out nulls
+  const cleanMapping: any = { mobile: mapping.mobile }
+  if (mapping.name !== null) cleanMapping.name = mapping.name
+  if (mapping.english_name !== null) cleanMapping.english_name = mapping.english_name
+  
+  formData.append('mapping', JSON.stringify(cleanMapping))
+
+  try {
+    const res = await request.post('/users/import', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
+    importResult.value = res.data
+    step.value = 2
+    emit('success')
+  } catch (error) {
+    ElMessage.error('导入失败')
+  } finally {
+    importing.value = false
+  }
+}
+
+const handleClose = () => {
+  visible.value = false
+}
+
+// Download helpers
+const downloadSuccessRecords = () => {
+  const successes = importResult.value.result_data.filter((r: any) => r.status === 'success')
+  if (successes.length === 0) return
+  
+  // Create CSV content
+  const header = ['Row', 'Mobile', 'Name', 'English Name', 'Initial Password']
+  const rows = successes.map((r: any) => [
+    r.row, 
+    r.mobile, 
+    r.name, 
+    r.english_name, 
+    r.initial_password
+  ])
+  
+  downloadCSV(header, rows, `import_success_${new Date().getTime()}.csv`)
+}
+
+const downloadFailLog = () => {
+  const fails = importResult.value.result_data.filter((r: any) => r.status === 'fail')
+  if (fails.length === 0) return
+  
+  const header = ['Row', 'Error Message']
+  const rows = fails.map((r: any) => [r.row, r.error])
+  
+  downloadCSV(header, rows, `import_fail_${new Date().getTime()}.csv`)
+}
+
+const downloadCSV = (header: string[], rows: any[][], filename: string) => {
+  let csvContent = "data:text/csv;charset=utf-8,\uFEFF" // Add BOM
+  csvContent += header.join(",") + "\r\n"
+  
+  rows.forEach(rowArray => {
+    const row = rowArray.map(field => {
+        const str = String(field || '')
+        // Escape quotes
+        if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+            return `"${str.replace(/"/g, '""')}"`
+        }
+        return str
+    }).join(",")
+    csvContent += row + "\r\n"
+  })
+  
+  const encodedUri = encodeURI(csvContent)
+  const link = document.createElement("a")
+  link.setAttribute("href", encodedUri)
+  link.setAttribute("download", filename)
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+}
+</script>
+
+<style scoped>
+.upload-container {
+  display: flex;
+  justify-content: center;
+  padding: 40px 0;
+}
+.mapping-container {
+  margin-top: 20px;
+}
+.preview-header {
+  margin-bottom: 20px;
+}
+.data-preview {
+  margin-top: 20px;
+}
+.result-container {
+  display: flex;
+  justify-content: center;
+  padding: 40px 0;
+}
+</style>
+

+ 8 - 1
frontend/src/views/UserList.vue

@@ -36,6 +36,9 @@
         <el-button type="success" @click="handleCreateUser">
           <el-icon style="margin-right: 4px"><Plus /></el-icon> 新增用户
         </el-button>
+        <el-button type="primary" plain @click="showImportDialog = true">
+          <el-icon style="margin-right: 4px"><Upload /></el-icon> Excel导入
+        </el-button>
         <el-button @click="openLogDrawer">
           <el-icon style="margin-right: 4px"><List /></el-icon> 操作日志
         </el-button>
@@ -354,15 +357,18 @@
         </div>
     </el-drawer>
 
+    <UserImportDialog v-model="showImportDialog" @success="fetchUsers" />
+
   </div>
 </template>
 
 <script setup lang="ts">
 import { ref, onMounted, reactive } from 'vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
-import { Refresh, ArrowDown, Search, Plus, List } from '@element-plus/icons-vue'
+import { Refresh, ArrowDown, Search, Plus, List, Upload } from '@element-plus/icons-vue'
 import api from '../utils/request'
 import { getLogs, OperationLog } from '../api/logs'
+import UserImportDialog from '../components/UserImportDialog.vue'
 
 interface User {
   id: number
@@ -387,6 +393,7 @@ const total = ref(0)
 
 // Create User
 const createDialogVisible = ref(false)
+const showImportDialog = ref(false)
 const creating = ref(false)
 const createFormRef = ref<FormInstance>()
 const createForm = reactive({