feat: 多用户管理系统,管理员可分配用户

- 新增 users 数据库表,存储用户名、密码哈希、角色
- 启动时自动从 .env 创建初始管理员账号
- 登录改为数据库校验,不再硬编码比对
- 新增 /api/users 管理接口(仅管理员)
- ConfigView 新增用户管理页(创建、改密码、切换角色、删除)
This commit is contained in:
shen 2026-06-08 21:08:16 +08:00
parent 1cecf66d94
commit 48fdca12bd
6 changed files with 327 additions and 10 deletions

View File

@ -34,7 +34,24 @@ def _migrate_db():
pass # 列已存在则忽略
def _seed_admin():
"""初始化管理员账号(从 .env 配置)"""
from .models import User
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
import hashlib
db = SessionLocal()
try:
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
db.commit()
finally:
db.close()
def init_db():
"""初始化数据库"""
Base.metadata.create_all(bind=engine)
_migrate_db()
_seed_admin()

View File

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .database import init_db
from .routers import config, apps, tasks, auth
from .routers import config, apps, tasks, auth, users
from .services.log_streamer import log_streamer
from .config import BACKEND_PORT
@ -38,6 +38,7 @@ app.add_middleware(
# 路由
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(config.router)
app.include_router(apps.router)
app.include_router(tasks.router)

View File

@ -49,3 +49,14 @@ class BuildConfig(Base):
key = Column(String, primary_key=True)
value = Column(String)
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(String, primary_key=True)
username = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow)

View File

@ -2,10 +2,13 @@
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse
from ..config import ADMIN_USERNAME, ADMIN_PASSWORD, JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..deps import get_current_user
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -20,13 +23,21 @@ def _create_token(username: str, is_admin: bool) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""管理员登录"""
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
token = _create_token(request.username, is_admin=True)
return LoginResponse(token=token, username=request.username, is_admin=True)
raise HTTPException(status_code=401, detail="用户名或密码错误")
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = db.query(User).filter(User.username == request.username).first()
if not user or not _verify_password(request.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
@router.get("/me")

103
backend/routers/users.py Normal file
View File

@ -0,0 +1,103 @@
"""用户管理 API仅管理员"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
_require_admin(user)
users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "username": u.username, "is_admin": u.is_admin, "created_at": str(u.created_at) if u.created_at else None} for u in users]
@router.post("")
async def create_user(data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建用户"""
_require_admin(user)
username = data.get("username", "").strip()
password = data.get("password", "").strip()
is_admin = data.get("is_admin", False)
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
db.commit()
return {"message": "用户已创建", "id": new_user.id}
@router.put("/{user_id}/password")
async def change_password(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""修改密码(管理员可改任意用户)"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
target.password_hash = _hash_password(new_password)
db.commit()
return {"message": "密码已更新"}
@router.put("/{user_id}/role")
async def change_role(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""切换管理员状态"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
target.is_admin = data.get("is_admin", not target.is_admin)
db.commit()
return {"message": "角色已更新", "is_admin": target.is_admin}
@router.delete("/{user_id}")
async def delete_user(user_id: str, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除用户"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
if target.username == user["username"]:
raise HTTPException(status_code=400, detail="不能删除自己")
db.delete(target)
db.commit()
return {"message": "用户已删除"}

View File

@ -9,6 +9,7 @@
<div class="sidebar">
<h3>配置管理</h3>
<ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
@ -19,6 +20,42 @@
</ul>
</div>
<div class="main-content">
<!-- 用户管理 -->
<div v-if="tab === 'users'">
<h2>用户管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理员可以创建和管理普通用户账号</p>
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openUserModal()">+ 新增用户</button>
<table class="config-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.username }}</strong></td>
<td>
<span :class="['build-type-badge', u.is_admin ? 'badge-appstore' : 'badge-adhoc']">
{{ u.is_admin ? '管理员' : '普通用户' }}
</span>
</td>
<td>{{ u.created_at || '-' }}</td>
<td class="action-btns">
<button class="action-btn" @click="openPasswordModal(u)">改密码</button>
<button class="action-btn" @click="toggleUserRole(u)">{{ u.is_admin ? '取消管理员' : '设为管理员' }}</button>
<button class="action-btn delete" @click="deleteUser(u)">删除</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="4" style="text-align: center; color: #999;">暂无用户</td>
</tr>
</tbody>
</table>
</div>
<!-- 服务器环境配置 -->
<div v-if="tab === 'servers'">
<h2>服务器环境配置</h2>
@ -275,6 +312,52 @@
</div>
</div>
<!-- 新增用户弹窗 -->
<div v-if="showUserModal" class="modal-overlay" @click.self="showUserModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>新增用户</h3>
<button class="modal-close" @click="showUserModal = false">&times;</button>
</div>
<div class="form-group">
<label>用户名 *</label>
<input v-model="userForm.username" type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.is_admin" style="width: 16px; height: 16px;">
管理员权限
</label>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showUserModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="createUser">创建</button>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3>修改密码 - {{ passwordTarget?.username }}</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 6 位">
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="changePassword">确认</button>
</div>
</div>
</div>
<!-- 服务器环境编辑弹窗 -->
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
<div class="modal-content">
@ -449,7 +532,15 @@ const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('servers')
const tab = ref('users')
//
const users = ref([])
const showUserModal = ref(false)
const userForm = ref({ username: '', password: '', is_admin: false })
const showPasswordModal = ref(false)
const passwordTarget = ref(null)
const newPassword = ref('')
// fetch
const authFetch = (url, options = {}) => {
@ -486,7 +577,7 @@ onMounted(async () => {
})
const loadData = async () => {
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes] = await Promise.all([
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),
authFetch('/api/config/servers'),
@ -495,6 +586,7 @@ const loadData = async () => {
authFetch('/api/config/upload'),
authFetch('/api/config'),
authFetch('/api/config/versions'),
authFetch('/api/users'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
@ -504,6 +596,88 @@ const loadData = async () => {
uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
versions.value = await versionsRes.json()
if (usersRes.ok) users.value = await usersRes.json()
}
//
const openUserModal = () => {
userForm.value = { username: '', password: '', is_admin: false }
showUserModal.value = true
}
const createUser = async () => {
if (!userForm.value.username || !userForm.value.password) {
alert('请填写用户名和密码')
return
}
try {
const res = await authFetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userForm.value),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '创建失败')
}
showUserModal.value = false
await loadData()
} catch (e) {
alert('创建失败: ' + e.message)
}
}
const openPasswordModal = (u) => {
passwordTarget.value = u
newPassword.value = ''
showPasswordModal.value = true
}
const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 6) {
alert('密码至少 6 位')
return
}
try {
const res = await authFetch(`/api/users/${passwordTarget.value.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword.value }),
})
if (!res.ok) throw new Error('修改失败')
showPasswordModal.value = false
alert('密码已更新')
} catch (e) {
alert('修改失败: ' + e.message)
}
}
const toggleUserRole = async (u) => {
try {
const res = await authFetch(`/api/users/${u.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: !u.is_admin }),
})
if (!res.ok) throw new Error('操作失败')
await loadData()
} catch (e) {
alert('操作失败: ' + e.message)
}
}
const deleteUser = async (u) => {
if (!confirm(`确定删除用户「${u.username}」?`)) return
try {
const res = await authFetch(`/api/users/${u.id}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '删除失败')
}
await loadData()
} catch (e) {
alert('删除失败: ' + e.message)
}
}
//