瀏覽代碼

添加用户管理和统一登录

liuq 3 月之前
父節點
當前提交
a53da795c4

+ 98 - 0
Simple_Auth_Guide.md

@@ -0,0 +1,98 @@
+# 统一认证平台 - 简易认证 (Simple Auth) 集成指南
+
+## 1. 概述
+本指南适用于需要使用自定义登录页面(而非跳转到认证中心标准页面),并通过后端 API 直接进行用户认证的场景。
+
+**Base URL**: `http://localhost:8000/api/v1/simple` (开发环境)
+
+## 2. 核心流程
+1. **用户输入**: 用户在客户端输入账号密码。
+2. **签名**: 客户端/后端生成签名 (Sign)。
+3. **登录**: POST `/login` (账号+密码+签名) -> 获取 `Ticket`。
+4. **验证**: POST `/validate` (Ticket+签名) -> 获取用户信息。
+
+## 3. 安全警告
+- **App Secret** 严禁泄露给前端浏览器。
+- 建议所有涉及 Secret 的签名计算都在**后端**完成。
+
+## 4. 签名算法 (Signature)
+所有接口(除部分公开接口外)都需要校验签名。
+
+**步骤**:
+1. **准备参数**: 收集所有请求参数(**排除 `sign` 本身**)。
+2. **排序**: 按照参数名(key)的 ASCII 码从小到大排序。
+3. **拼接**: 将排序后的参数拼接成 `key1=value1&key2=value2...` 格式。
+4. **计算 HMAC**: 使用 `App Secret` 作为密钥,对拼接字符串进行 **HMAC-SHA256** 计算。
+5. **Hex 编码**: 将结果转换为十六进制字符串。
+
+### Python 示例
+```python
+import hmac
+import hashlib
+
+def generate_signature(secret: str, params: dict) -> str:
+    data = {k: v for k, v in params.items() if k != "sign" and v is not None}
+    sorted_keys = sorted(data.keys())
+    query_string = "&".join([f"{k}={data[k]}" for k in sorted_keys])
+    signature = hmac.new(
+        secret.encode('utf-8'),
+        query_string.encode('utf-8'),
+        hashlib.sha256
+    ).hexdigest()
+    return signature
+```
+
+## 5. 接口定义
+
+### 5.1 密码登录 (Login)
+获取临时票据 (Ticket)。
+
+- **URL**: `POST /login`
+- **Content-Type**: `application/json`
+
+**Request Body**:
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `app_id` | string | Yes | 应用 ID |
+| `identifier` | string | Yes | 用户标识(手机号、用户名或邮箱) |
+| `password` | string | Yes | 明文密码 |
+| `timestamp` | int | Yes | 当前时间戳 (秒) |
+| `sign` | string | Yes | 签名 |
+
+**Response (200)**:
+```json
+{
+  "ticket": "TICKET-7f8e9d0a-..." 
+}
+```
+
+### 5.2 验证票据 (Validate)
+解析票据获取用户信息。
+
+- **URL**: `POST /validate`
+- **Content-Type**: `application/json`
+
+**Request Body**:
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `app_id` | string | Yes | 应用 ID |
+| `ticket` | string | Yes | 上一步获取的票据 |
+| `timestamp` | int | Yes | 当前时间戳 |
+| `sign` | string | Yes | 签名 (参数变化需重新计算) |
+
+**Response (200)**:
+```json
+{
+  "valid": true,
+  "user_id": 1001,
+  "mobile": "13800138000",
+  "mapped_key": "user_zhangsan",  // 第三方映射ID
+  "mapped_email": "zhangsan@example.com"
+}
+```
+
+**Response (Invalid)**:
+```json
+{ "valid": false }
+```
+

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

@@ -1,8 +1,9 @@
 from fastapi import APIRouter
-from backend.app.api.endpoints import auth, cameras, models_api, tasks, logs, reports
+from backend.app.api.endpoints import auth, cameras, models_api, tasks, logs, reports, users
 
 api_router = APIRouter()
 api_router.include_router(auth.router, tags=["login"])
+api_router.include_router(users.router, prefix="/users", tags=["users"])
 api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"])
 api_router.include_router(models_api.router, prefix="/models", tags=["models"])
 api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])

+ 9 - 0
backend/app/api/deps.py

@@ -33,3 +33,12 @@ def get_current_user(
         raise credentials_exception
     return user
 
+def get_current_active_superuser(
+    current_user: sql_models.User = Depends(get_current_user),
+) -> sql_models.User:
+    if not current_user.is_superuser:
+        raise HTTPException(
+            status_code=400, detail="The user doesn't have enough privileges"
+        )
+    return current_user
+

+ 93 - 1
backend/app/api/endpoints/auth.py

@@ -1,6 +1,10 @@
 from datetime import timedelta
 from typing import Any
+import uuid
+import logging
+import urllib.parse
 from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import RedirectResponse
 from fastapi.security import OAuth2PasswordRequestForm
 from sqlalchemy.orm import Session
 
@@ -9,6 +13,9 @@ from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models import sql_models
 from backend.app.schemas import schemas
+from backend.app.services.simple_auth import SimpleAuthService
+
+logger = logging.getLogger(__name__)
 
 router = APIRouter()
 
@@ -28,5 +35,90 @@ def login_access_token(
     access_token = security.create_access_token(
         data={"sub": user.username}, expires_delta=access_token_expires
     )
-    return {"access_token": access_token, "token_type": "bearer"}
+    return {
+        "access_token": access_token, 
+        "token_type": "bearer",
+        "is_superuser": user.is_superuser if hasattr(user, "is_superuser") else False,
+        "username": user.username
+    }
+
+@router.get("/auth/simple-callback")
+async def simple_auth_callback(
+    ticket: str,
+    db: Session = Depends(get_db)
+) -> Any:
+    """
+    Callback for Simple Auth Platform.
+    Receives ticket (GET param), validates it, logs in/creates local user,
+    and redirects to frontend with token.
+    """
+    logger.info(f"Received simple-callback with ticket: {ticket}")
+    
+    validation_result = await SimpleAuthService.validate_ticket(ticket)
+    logger.info(f"Ticket validation result: {validation_result}")
+    
+    if not validation_result or not validation_result.get("valid"):
+        logger.error(f"Invalid ticket: {ticket}")
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Invalid ticket"
+        )
+    
+    # Determine local username
+    # Priority: mapped_key > mobile > mapped_email > user_id
+    username = validation_result.get("mapped_key")
+    if not username:
+        username = validation_result.get("mobile")
+    if not username:
+        username = validation_result.get("mapped_email")
+    if not username:
+        username = str(validation_result.get("user_id"))
+        
+    if not username:
+         logger.error("Could not determine user identity from auth response")
+         raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Could not determine user identity from auth response"
+        )
+    
+    logger.info(f"Identified user: {username}")
 
+    # Check if user exists
+    user = db.query(sql_models.User).filter(sql_models.User.username == username).first()
+    
+    if not user:
+        logger.info(f"User {username} not found. Creating new user.")
+        # Create new user
+        # Generate a random password since they use external auth
+        random_password = uuid.uuid4().hex
+        hashed_password = security.get_password_hash(random_password)
+        
+        user = sql_models.User(
+            username=username,
+            hashed_password=hashed_password,
+            is_active=True,
+            is_superuser=False 
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
+        logger.info(f"User {username} created successfully.")
+    else:
+        logger.info(f"User {username} found.")
+        
+    # Generate token
+    access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+    access_token = security.create_access_token(
+        data={"sub": user.username}, expires_delta=access_token_expires
+    )
+    
+    # Redirect to frontend root with token in query param
+    # URL Encode parameters to ensure special characters (like Chinese or +) are handled correctly
+    encoded_token = urllib.parse.quote(access_token)
+    encoded_username = urllib.parse.quote(user.username)
+    is_superuser_str = "true" if user.is_superuser else "false"
+    
+    redirect_url = f"/?token={encoded_token}&username={encoded_username}&superuser={is_superuser_str}"
+    logger.info(f"Redirecting to frontend: {redirect_url}")
+    
+    return RedirectResponse(url=redirect_url)

+ 115 - 0
backend/app/api/endpoints/users.py

@@ -0,0 +1,115 @@
+from typing import Any, List
+
+from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi.encoders import jsonable_encoder
+from sqlalchemy.orm import Session
+
+from backend.app.core import security
+from backend.app.core.database import get_db
+from backend.app.models import sql_models
+from backend.app.schemas import schemas
+from backend.app.api import deps
+
+router = APIRouter()
+
+@router.get("", response_model=List[schemas.User])
+def read_users(
+    db: Session = Depends(get_db),
+    skip: int = 0,
+    limit: int = 100,
+    current_user: sql_models.User = Depends(deps.get_current_active_superuser),
+) -> Any:
+    """
+    Retrieve users.
+    """
+    users = db.query(sql_models.User).offset(skip).limit(limit).all()
+    return users
+
+@router.post("", response_model=schemas.User)
+def create_user(
+    *,
+    db: Session = Depends(get_db),
+    user_in: schemas.UserCreate,
+    current_user: sql_models.User = Depends(deps.get_current_active_superuser),
+) -> Any:
+    """
+    Create new user.
+    """
+    user = db.query(sql_models.User).filter(sql_models.User.username == user_in.username).first()
+    if user:
+        raise HTTPException(
+            status_code=400,
+            detail="The user with this username already exists in the system.",
+        )
+    
+    hashed_password = security.get_password_hash(user_in.password)
+    db_user = sql_models.User(
+        username=user_in.username,
+        hashed_password=hashed_password,
+        is_active=user_in.is_active,
+        is_superuser=user_in.is_superuser,
+    )
+    db.add(db_user)
+    db.commit()
+    db.refresh(db_user)
+    return db_user
+
+@router.put("/{user_id}", response_model=schemas.User)
+def update_user(
+    *,
+    db: Session = Depends(get_db),
+    user_id: int,
+    user_in: schemas.UserUpdate,
+    current_user: sql_models.User = Depends(deps.get_current_active_superuser),
+) -> Any:
+    """
+    Update a user.
+    """
+    user = db.query(sql_models.User).filter(sql_models.User.id == user_id).first()
+    if not user:
+        raise HTTPException(
+            status_code=404,
+            detail="The user with this id does not exist in the system",
+        )
+    
+    # Check if username collision if logic allowed changing username (but UserUpdate doesn't have username)
+    # So we just update fields
+    
+    if user_in.password:
+        user.hashed_password = security.get_password_hash(user_in.password)
+    if user_in.is_active is not None:
+        user.is_active = user_in.is_active
+    if user_in.is_superuser is not None:
+        user.is_superuser = user_in.is_superuser
+        
+    db.add(user)
+    db.commit()
+    db.refresh(user)
+    return user
+
+@router.delete("/{user_id}", response_model=schemas.User)
+def delete_user(
+    *,
+    db: Session = Depends(get_db),
+    user_id: int,
+    current_user: sql_models.User = Depends(deps.get_current_active_superuser),
+) -> Any:
+    """
+    Delete a user.
+    """
+    user = db.query(sql_models.User).filter(sql_models.User.id == user_id).first()
+    if not user:
+        raise HTTPException(
+            status_code=404,
+            detail="The user with this id does not exist in the system",
+        )
+    if user.id == current_user.id:
+        raise HTTPException(
+            status_code=400,
+            detail="Users cannot delete themselves",
+        )
+        
+    db.delete(user)
+    db.commit()
+    return user
+

+ 5 - 0
backend/app/core/config.py

@@ -18,6 +18,11 @@ class Settings(BaseModel):
     MYSQL_PORT: str = os.getenv("MYSQL_PORT", "3306")
     MYSQL_DB: str = os.getenv("MYSQL_DB", "ai_watch")
     
+    # Simple Auth
+    SIMPLE_AUTH_APP_ID: str = os.getenv("SIMPLE_AUTH_APP_ID", "app_a55698ff80d360a8")
+    SIMPLE_AUTH_APP_SECRET: str = os.getenv("SIMPLE_AUTH_APP_SECRET", "FEuZXxcheRzuJcXGGsx9d1RIkagHqToW")
+    SIMPLE_AUTH_BASE_URL: str = os.getenv("SIMPLE_AUTH_BASE_URL", "http://api.hnyunzhu.com:8976/api/v1/simple")
+
     @property
     def SQLALCHEMY_DATABASE_URI(self) -> str:
         return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_SERVER}:{self.MYSQL_PORT}/{self.MYSQL_DB}"

+ 22 - 2
backend/app/main.py

@@ -7,6 +7,7 @@ import logging
 import os
 import json
 import pymysql
+from sqlalchemy import text
 
 from backend.app.core.config import settings
 from backend.app.core.database import engine, SessionLocal
@@ -97,6 +98,24 @@ async def startup_event():
     except Exception as e:
         logger.error(f"Error creating tables: {e}")
 
+    # 2.5 Migration: Check for new columns
+    db = SessionLocal()
+    try:
+        logger.info("Checking for pending migrations...")
+        # Check is_superuser
+        try:
+            db.execute(text("SELECT is_superuser FROM users LIMIT 1"))
+        except Exception:
+            logger.info("Column 'is_superuser' missing in users table. Adding it...")
+            db.execute(text("ALTER TABLE users ADD COLUMN is_superuser BOOLEAN DEFAULT FALSE"))
+            db.commit()
+            logger.info("Column 'is_superuser' added.")
+    except Exception as e:
+        logger.error(f"Migration error: {e}")
+        db.rollback()
+    finally:
+        db.close()
+
     # 3. Init Admin User (from reset_admin.py)
     db = SessionLocal()
     try:
@@ -108,12 +127,13 @@ async def startup_event():
             logger.info(f"User '{username}' found. Resetting password...")
             admin.hashed_password = security.get_password_hash(password)
             admin.is_active = True
+            admin.is_superuser = True
             db.commit()
-            logger.info(f"Password for '{username}' has been reset.")
+            logger.info(f"Password for '{username}' has been reset and superuser granted.")
         else:
             logger.info(f"User '{username}' not found. Creating...")
             hashed_pwd = security.get_password_hash(password)
-            user = sql_models.User(username=username, hashed_password=hashed_pwd, is_active=True)
+            user = sql_models.User(username=username, hashed_password=hashed_pwd, is_active=True, is_superuser=True)
             db.add(user)
             db.commit()
             logger.info(f"User '{username}' created.")

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

@@ -10,6 +10,7 @@ class User(Base):
     username = Column(String(50), unique=True, index=True, default="admin")
     hashed_password = Column(String(255))
     is_active = Column(Boolean, default=True)
+    is_superuser = Column(Boolean, default=False)
 
 class Camera(Base):
     __tablename__ = "cameras"

+ 25 - 0
backend/app/schemas/schemas.py

@@ -7,10 +7,35 @@ from apscheduler.triggers.cron import CronTrigger
 class Token(BaseModel):
     access_token: str
     token_type: str
+    is_superuser: bool = False
+    username: str = ""
 
 class TokenData(BaseModel):
     username: Optional[str] = None
 
+class SimpleAuthCallback(BaseModel):
+    ticket: str
+
+# User
+class UserBase(BaseModel):
+    username: str
+    is_active: Optional[bool] = True
+    is_superuser: Optional[bool] = False
+
+class UserCreate(UserBase):
+    password: str
+
+class UserUpdate(BaseModel):
+    password: Optional[str] = None
+    is_active: Optional[bool] = None
+    is_superuser: Optional[bool] = None
+
+class User(UserBase):
+    id: int
+    
+    class Config:
+        from_attributes = True
+
 class UserLogin(BaseModel):
     username: str
     password: str

+ 58 - 0
backend/app/services/simple_auth.py

@@ -0,0 +1,58 @@
+import time
+import hmac
+import hashlib
+import httpx
+from typing import Dict, Any, Optional
+from backend.app.core.config import settings
+
+class SimpleAuthService:
+    @staticmethod
+    def generate_signature(params: Dict[str, Any], secret: str) -> str:
+        """
+        Generate signature based on Simple Auth Guide.
+        1. Filter out 'sign' and None values.
+        2. Sort by key.
+        3. Concat as query string.
+        4. HMAC-SHA256.
+        """
+        data = {k: v for k, v in params.items() if k != "sign" and v is not None}
+        sorted_keys = sorted(data.keys())
+        query_string = "&".join([f"{k}={data[k]}" for k in sorted_keys])
+        
+        signature = hmac.new(
+            secret.encode('utf-8'),
+            query_string.encode('utf-8'),
+            hashlib.sha256
+        ).hexdigest()
+        return signature
+
+    @staticmethod
+    async def validate_ticket(ticket: str) -> Optional[Dict[str, Any]]:
+        """
+        Validate ticket with the auth server.
+        """
+        timestamp = int(time.time())
+        params = {
+            "app_id": settings.SIMPLE_AUTH_APP_ID,
+            "ticket": ticket,
+            "timestamp": timestamp,
+        }
+        
+        # Calculate sign
+        sign = SimpleAuthService.generate_signature(params, settings.SIMPLE_AUTH_APP_SECRET)
+        params["sign"] = sign
+        
+        url = f"{settings.SIMPLE_AUTH_BASE_URL}/validate"
+        
+        async with httpx.AsyncClient() as client:
+            try:
+                response = await client.post(url, json=params)
+                if response.status_code == 200:
+                    result = response.json()
+                    if result.get("valid") is True:
+                        return result
+                return None
+            except Exception as e:
+                print(f"Error validating ticket: {e}")
+                return None
+

+ 33 - 0
backend/app/update_db_schema.py

@@ -0,0 +1,33 @@
+import sys
+import os
+
+# Add parent directory to path so we can import app modules
+sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+from sqlalchemy import text
+from backend.app.core.database import SessionLocal, engine
+
+def add_is_superuser_column():
+    print(f"Connecting to database at: {engine.url}")
+    db = SessionLocal()
+    try:
+        # Check if column exists
+        result = db.execute(text("SHOW COLUMNS FROM users LIKE 'is_superuser'"))
+        if result.fetchone():
+            print("Column 'is_superuser' already exists.")
+        else:
+            print("Adding 'is_superuser' column...")
+            db.execute(text("ALTER TABLE users ADD COLUMN is_superuser BOOLEAN DEFAULT FALSE"))
+            # Make the first user (likely admin) a superuser
+            db.execute(text("UPDATE users SET is_superuser = TRUE WHERE id = 1"))
+            db.commit()
+            print("Column added and admin updated.")
+    except Exception as e:
+        print(f"Error: {e}")
+        db.rollback()
+    finally:
+        db.close()
+
+if __name__ == "__main__":
+    add_is_superuser_column()
+

+ 4 - 0
frontend/src/App.vue

@@ -69,6 +69,10 @@ const handleMenuSelect = (index: string) => {
               <el-icon><Document /></el-icon>
               <span>值班报告</span>
             </el-menu-item>
+            <el-menu-item index="/users" v-if="authStore.isSuperuser">
+              <el-icon><User /></el-icon>
+              <span>用户管理</span>
+            </el-menu-item>
           </el-menu>
         </el-aside>
         <el-main class="main-content">

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

@@ -7,6 +7,7 @@ import Logs from '../views/Logs.vue'
 import Cameras from '../views/Cameras.vue'
 import Models from '../views/Models.vue'
 import Reports from '../views/Reports.vue'
+import Users from '../views/Users.vue'
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -57,6 +58,12 @@ const router = createRouter({
       name: 'reports',
       component: Reports,
       meta: { requiresAuth: true }
+    },
+    {
+      path: '/users',
+      name: 'users',
+      component: Users,
+      meta: { requiresAuth: true, requiresAdmin: true }
     }
   ]
 })
@@ -64,8 +71,38 @@ const router = createRouter({
 
 router.beforeEach((to, _from, next) => {
   const authStore = useAuthStore()
+
+  // Handle external auth callback (e.g. from Simple Auth)
+  if (to.query.token && typeof to.query.token === 'string') {
+    try {
+      const token = decodeURIComponent(to.query.token)
+      const rawUsername = (to.query.username as string) || ''
+      const username = decodeURIComponent(rawUsername)
+      const isSuperuser = to.query.superuser === 'true'
+      
+      console.log('External auth callback:', { token: '***', username, isSuperuser })
+      
+      authStore.setToken(token, username, isSuperuser)
+      
+      // Remove query params to clean URL
+      const query = { ...to.query }
+      delete query.token
+      delete query.username
+      delete query.superuser
+      
+      next({ path: to.path, query, replace: true })
+      return
+    } catch (e) {
+      console.error('Error parsing auth params:', e)
+      next('/login')
+      return
+    }
+  }
+
   if (to.meta.requiresAuth && !authStore.token) {
     next('/login')
+  } else if (to.meta.requiresAdmin && !authStore.isSuperuser) {
+    next('/') // Redirect non-admins to dashboard
   } else {
     next()
   }

+ 7 - 2
frontend/src/stores/auth.ts

@@ -4,21 +4,26 @@ import { ref } from 'vue'
 export const useAuthStore = defineStore('auth', () => {
   const token = ref(localStorage.getItem('token') || '')
   const username = ref(localStorage.getItem('username') || '')
+  const isSuperuser = ref(localStorage.getItem('isSuperuser') === 'true')
 
-  function setToken(newToken: string, newUsername: string) {
+  function setToken(newToken: string, newUsername: string, newIsSuperuser: boolean) {
     token.value = newToken
     username.value = newUsername
+    isSuperuser.value = newIsSuperuser
     localStorage.setItem('token', newToken)
     localStorage.setItem('username', newUsername)
+    localStorage.setItem('isSuperuser', String(newIsSuperuser))
   }
 
   function logout() {
     token.value = ''
     username.value = ''
+    isSuperuser.value = false
     localStorage.removeItem('token')
     localStorage.removeItem('username')
+    localStorage.removeItem('isSuperuser')
   }
 
-  return { token, username, setToken, logout }
+  return { token, username, isSuperuser, setToken, logout }
 })
 

+ 1 - 1
frontend/src/views/Login.vue

@@ -23,7 +23,7 @@ const handleLogin = async () => {
     formData.append('password', form.value.password)
     
     const res = await api.post('/login', formData)
-    authStore.setToken(res.data.access_token, form.value.username)
+    authStore.setToken(res.data.access_token, form.value.username, res.data.is_superuser)
     ElMessage.success('登录成功')
     router.push('/')
   } catch (error) {

+ 162 - 0
frontend/src/views/Users.vue

@@ -0,0 +1,162 @@
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import api from '../api'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+interface User {
+  id: number
+  username: string
+  is_active: boolean
+  is_superuser: boolean
+}
+
+const users = ref<User[]>([])
+const dialogVisible = ref(false)
+const isEdit = ref(false)
+const editId = ref<number | null>(null)
+
+const form = ref({
+  username: '',
+  password: '',
+  is_active: true,
+  is_superuser: false
+})
+
+const fetchUsers = async () => {
+  try {
+    const res = await api.get('/users')
+    users.value = res.data
+  } catch (e) {
+    ElMessage.error('获取用户列表失败')
+  }
+}
+
+const resetForm = () => {
+  form.value = { username: '', password: '', is_active: true, is_superuser: false }
+  isEdit.value = false
+  editId.value = null
+}
+
+const handleAdd = () => {
+  resetForm()
+  dialogVisible.value = true
+}
+
+const handleEdit = (row: User) => {
+  isEdit.value = true
+  editId.value = row.id
+  form.value = {
+    username: row.username,
+    password: '', // Don't fill password
+    is_active: row.is_active,
+    is_superuser: row.is_superuser
+  }
+  dialogVisible.value = true
+}
+
+const handleSave = async () => {
+  try {
+    if (isEdit.value && editId.value) {
+      // Remove password if empty to avoid resetting it
+      const data: any = { ...form.value }
+      if (!data.password) delete data.password
+      
+      await api.put(`/users/${editId.value}`, data)
+      ElMessage.success('更新成功')
+    } else {
+      if (!form.value.username || !form.value.password) {
+        ElMessage.warning('用户名和密码必填')
+        return
+      }
+      await api.post('/users', form.value)
+      ElMessage.success('添加成功')
+    }
+    dialogVisible.value = false
+    fetchUsers()
+  } catch (e: any) {
+    const msg = e.response?.data?.detail || (isEdit.value ? '更新失败' : '添加失败')
+    ElMessage.error(msg)
+  }
+}
+
+const handleDelete = (id: number) => {
+  ElMessageBox.confirm('确认删除该用户?', '警告', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning',
+  }).then(async () => {
+    try {
+      await api.delete(`/users/${id}`)
+      ElMessage.success('删除成功')
+      fetchUsers()
+    } catch (e: any) {
+      const msg = e.response?.data?.detail || '删除失败'
+      ElMessage.error(msg)
+    }
+  })
+}
+
+onMounted(fetchUsers)
+</script>
+
+<template>
+  <div class="users-view">
+    <div class="toolbar">
+      <el-button type="primary" @click="handleAdd">添加用户</el-button>
+    </div>
+    <el-table :data="users" style="width: 100%">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="username" label="用户名" />
+      <el-table-column label="状态">
+        <template #default="scope">
+          <el-tag :type="scope.row.is_active ? 'success' : 'danger'">
+            {{ scope.row.is_active ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="角色">
+        <template #default="scope">
+          <el-tag :type="scope.row.is_superuser ? 'warning' : 'info'">
+            {{ scope.row.is_superuser ? '管理员' : '普通用户' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200">
+        <template #default="scope">
+          <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
+          <el-button type="danger" size="small" @click="handleDelete(scope.row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '添加用户'">
+      <el-form :model="form" label-width="80px">
+        <el-form-item label="用户名">
+          <el-input v-model="form.username" :disabled="isEdit" />
+        </el-form-item>
+        <el-form-item label="密码">
+          <el-input v-model="form.password" type="password" placeholder="不修改请留空" show-password />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
+        </el-form-item>
+        <el-form-item label="管理员">
+          <el-switch v-model="form.is_superuser" active-text="是" inactive-text="否" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="handleSave">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.toolbar {
+  margin-bottom: 20px;
+}
+</style>
+