添加登录功能:JWT认证、注册、登录、前端登录页面

This commit is contained in:
cnbugs
2026-06-01 15:54:29 +08:00
parent 188edfa287
commit 0d6c9d26c0
5 changed files with 289 additions and 23 deletions
Binary file not shown.
+18
View File
@@ -1,5 +1,23 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func
from database import Base from database import Base
import hashlib
class User(Base):
"""用户表"""
__tablename__ = "user"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True, comment="用户名")
password_hash = Column(String(128), nullable=False, comment="密码哈希")
nickname = Column(String(50), nullable=True, comment="昵称")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
def set_password(self, password):
self.password_hash = hashlib.sha256(password.encode()).hexdigest()
def check_password(self, password):
return self.password_hash == hashlib.sha256(password.encode()).hexdigest()
class Inventory(Base): class Inventory(Base):
+2
View File
@@ -4,3 +4,5 @@ sqlalchemy==2.0.35
openpyxl==3.1.5 openpyxl==3.1.5
python-multipart==0.0.12 python-multipart==0.0.12
pydantic==2.9.2 pydantic==2.9.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
+90 -12
View File
@@ -1,20 +1,96 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from typing import Optional from typing import Optional
import io import io
from datetime import datetime from datetime import datetime, timedelta
from urllib.parse import quote from urllib.parse import quote
from jose import JWTError, jwt
from passlib.context import CryptContext
from database import get_db from database import get_db
from models import Inventory, TransactionLog from models import Inventory, TransactionLog, User
from schemas import InventoryCreate, InventoryUpdate, StockOperation from schemas import InventoryCreate, InventoryUpdate, StockOperation
import openpyxl import openpyxl
# JWT配置
SECRET_KEY = "inventory-management-secret-key-2024"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
router = APIRouter() router = APIRouter()
def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=401,
detail="无法验证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
# ===== 认证相关 =====
@router.post("/auth/register")
def register(username: str, password: str, nickname: str = "", db: Session = Depends(get_db)):
"""用户注册"""
existing = db.query(User).filter(User.username == username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(username=username, nickname=nickname)
user.set_password(password)
db.add(user)
db.commit()
return {"message": "注册成功"}
@router.post("/auth/login")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
"""用户登录"""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not user.check_password(form_data.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
access_token = create_access_token(
data={"sub": user.username},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
return {"access_token": access_token, "token_type": "bearer", "username": user.username, "nickname": user.nickname}
@router.get("/auth/me")
def get_me(current_user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return {"username": current_user.username, "nickname": current_user.nickname}
def inventory_to_dict(item: Inventory) -> dict: def inventory_to_dict(item: Inventory) -> dict:
"""将Inventory模型转为字典""" """将Inventory模型转为字典"""
return { return {
@@ -50,7 +126,8 @@ def list_inventory(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = None, search: Optional[str] = None,
db: Session = Depends(get_db) db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
): ):
"""获取库存列表,支持分页和搜索""" """获取库存列表,支持分页和搜索"""
query = db.query(Inventory) query = db.query(Inventory)
@@ -71,7 +148,7 @@ def list_inventory(
@router.post("/inventory") @router.post("/inventory")
def create_inventory(data: InventoryCreate, db: Session = Depends(get_db)): def create_inventory(data: InventoryCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""新增库存""" """新增库存"""
item = Inventory(**data.model_dump()) item = Inventory(**data.model_dump())
db.add(item) db.add(item)
@@ -81,7 +158,7 @@ def create_inventory(data: InventoryCreate, db: Session = Depends(get_db)):
@router.put("/inventory/{item_id}") @router.put("/inventory/{item_id}")
def update_inventory(item_id: int, data: InventoryUpdate, db: Session = Depends(get_db)): def update_inventory(item_id: int, data: InventoryUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""更新库存""" """更新库存"""
item = db.query(Inventory).filter(Inventory.id == item_id).first() item = db.query(Inventory).filter(Inventory.id == item_id).first()
if not item: if not item:
@@ -95,7 +172,7 @@ def update_inventory(item_id: int, data: InventoryUpdate, db: Session = Depends(
@router.delete("/inventory/{item_id}") @router.delete("/inventory/{item_id}")
def delete_inventory(item_id: int, db: Session = Depends(get_db)): def delete_inventory(item_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""删除库存""" """删除库存"""
item = db.query(Inventory).filter(Inventory.id == item_id).first() item = db.query(Inventory).filter(Inventory.id == item_id).first()
if not item: if not item:
@@ -108,7 +185,7 @@ def delete_inventory(item_id: int, db: Session = Depends(get_db)):
# ===== 出入库 ===== # ===== 出入库 =====
@router.post("/stock/operation") @router.post("/stock/operation")
def stock_operation(op: StockOperation, db: Session = Depends(get_db)): def stock_operation(op: StockOperation, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""出入库操作""" """出入库操作"""
item = db.query(Inventory).filter(Inventory.id == op.inventory_id).first() item = db.query(Inventory).filter(Inventory.id == op.inventory_id).first()
if not item: if not item:
@@ -138,7 +215,7 @@ def stock_operation(op: StockOperation, db: Session = Depends(get_db)):
@router.delete("/stock/logs") @router.delete("/stock/logs")
def clear_stock_logs(db: Session = Depends(get_db)): def clear_stock_logs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""清空所有出入库记录""" """清空所有出入库记录"""
count = db.query(TransactionLog).delete() count = db.query(TransactionLog).delete()
db.commit() db.commit()
@@ -150,7 +227,8 @@ def get_stock_logs(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = None, search: Optional[str] = None,
db: Session = Depends(get_db) db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
): ):
"""获取出入库记录""" """获取出入库记录"""
query = db.query(TransactionLog) query = db.query(TransactionLog)
@@ -170,7 +248,7 @@ def get_stock_logs(
# ===== Excel 导入导出 ===== # ===== Excel 导入导出 =====
@router.get("/inventory/export") @router.get("/inventory/export")
def export_inventory(db: Session = Depends(get_db)): def export_inventory(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""导出库存为Excel""" """导出库存为Excel"""
items = db.query(Inventory).order_by(Inventory.id.asc()).all() items = db.query(Inventory).order_by(Inventory.id.asc()).all()
@@ -232,7 +310,7 @@ def export_inventory(db: Session = Depends(get_db)):
@router.post("/inventory/import") @router.post("/inventory/import")
def import_inventory(file: UploadFile = File(...), db: Session = Depends(get_db)): def import_inventory(file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""从Excel导入库存""" """从Excel导入库存"""
if not file.filename.endswith(('.xlsx', '.xls')): if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(status_code=400, detail="只支持 .xlsx 或 .xls 文件") raise HTTPException(status_code=400, detail="只支持 .xlsx 或 .xls 文件")
@@ -381,7 +459,7 @@ def import_inventory(file: UploadFile = File(...), db: Session = Depends(get_db)
# ===== 导出出入库记录 ===== # ===== 导出出入库记录 =====
@router.get("/stock/export") @router.get("/stock/export")
def export_stock_logs(db: Session = Depends(get_db)): def export_stock_logs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""导出出入库记录为Excel""" """导出出入库记录为Excel"""
items = db.query(TransactionLog).order_by(TransactionLog.id.desc()).all() items = db.query(TransactionLog).order_by(TransactionLog.id.desc()).all()
+179 -11
View File
@@ -134,11 +134,56 @@
库存管理系统 库存管理系统
</div> </div>
<div class="header-right"> <div class="header-right">
<template v-if="isLoggedIn">
<span>欢迎,{{ currentUser }}</span>
<el-button type="danger" size="small" @click="logout">退出</el-button>
</template>
<template v-else>
<el-button type="primary" size="small" @click="loginDialogVisible = true">登录</el-button>
</template>
<span>{{ currentTime }}</span> <span>{{ currentTime }}</span>
</div> </div>
</div> </div>
<div class="main-container"> <!-- 未登录显示登录框 -->
<div v-if="!isLoggedIn" style="display:flex;justify-content:center;align-items:center;min-height:calc(100vh - 60px);">
<div style="background:#fff;padding:40px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);width:400px;text-align:center;">
<h2 style="margin-bottom:30px;color:#1a1a2e;">{{ isRegister ? '用户注册' : '用户登录' }}</h2>
<el-form v-if="!isRegister" :model="loginForm" @submit.prevent="doLogin">
<el-form-item>
<el-input v-model="loginForm.username" placeholder="用户名" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" @keyup.enter="doLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%;" :loading="loginLoading" @click="doLogin">登录</el-button>
</el-form-item>
<div style="margin-top:20px;">
还没有账号?<a href="#" @click.prevent="isRegister=true" style="color:#409eff;">立即注册</a>
</div>
</el-form>
<el-form v-else :model="registerForm" @submit.prevent="doRegister">
<el-form-item>
<el-input v-model="registerForm.username" placeholder="用户名" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="registerForm.password" type="password" placeholder="密码" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="registerForm.nickname" placeholder="昵称(可选)" size="large" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%;" :loading="loginLoading" @click="doRegister">注册</el-button>
</el-form-item>
<div style="margin-top:20px;">
已有账号?<a href="#" @click.prevent="isRegister=false" style="color:#409eff;">立即登录</a>
</div>
</el-form>
</div>
</div>
<div v-else class="main-container">
<div class="stat-cards"> <div class="stat-cards">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon blue">📋</div> <div class="stat-icon blue">📋</div>
@@ -320,6 +365,94 @@
const app = createApp({ const app = createApp({
setup() { setup() {
const API = '/api'; const API = '/api';
// 登录状态
const token = ref(localStorage.getItem('token') || '');
const currentUser = ref(localStorage.getItem('username') || '');
const isLoggedIn = ref(!!token.value);
const loginDialogVisible = ref(false);
const loginForm = reactive({ username: '', password: '' });
const registerForm = reactive({ username: '', password: '', nickname: '' });
const loginLoading = ref(false);
const isRegister = ref(false);
// 带认证的fetch
const authFetch = async (url, options = {}) => {
const headers = { ...options.headers };
if (token.value) {
headers['Authorization'] = 'Bearer ' + token.value;
}
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
logout();
throw new Error('登录已过期,请重新登录');
}
return res;
};
// 登录
const doLogin = async () => {
if (!loginForm.username || !loginForm.password) {
ElementPlus.ElMessage.error('请输入用户名和密码');
return;
}
loginLoading.value = true;
try {
const formData = new FormData();
formData.append('username', loginForm.username);
formData.append('password', loginForm.password);
const res = await fetch(API + '/auth/login', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || '登录失败');
token.value = data.access_token;
currentUser.value = data.username;
localStorage.setItem('token', data.access_token);
localStorage.setItem('username', data.username);
isLoggedIn.value = true;
loginDialogVisible.value = false;
loginForm.username = '';
loginForm.password = '';
ElementPlus.ElMessage.success('登录成功');
loadStats();
loadInventory();
loadLogs();
} catch (e) {
ElementPlus.ElMessage.error(e.message);
} finally {
loginLoading.value = false;
}
};
// 注册
const doRegister = async () => {
if (!registerForm.username || !registerForm.password) {
ElementPlus.ElMessage.error('请输入用户名和密码');
return;
}
loginLoading.value = true;
try {
const res = await fetch(API + '/auth/register?username=' + encodeURIComponent(registerForm.username) + '&password=' + encodeURIComponent(registerForm.password) + '&nickname=' + encodeURIComponent(registerForm.nickname || ''), { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || '注册失败');
ElementPlus.ElMessage.success('注册成功,请登录');
isRegister.value = false;
} catch (e) {
ElementPlus.ElMessage.error(e.message);
} finally {
loginLoading.value = false;
}
};
// 登出
const logout = () => {
token.value = '';
currentUser.value = '';
localStorage.removeItem('token');
localStorage.removeItem('username');
isLoggedIn.value = false;
ElementPlus.ElMessage.success('已退出登录');
};
const activeTab = ref('inventory'); const activeTab = ref('inventory');
const loading = ref(false); const loading = ref(false);
const currentTime = ref(''); const currentTime = ref('');
@@ -370,7 +503,7 @@
try { try {
const params = new URLSearchParams({ page: page.value, page_size: pageSize.value }); const params = new URLSearchParams({ page: page.value, page_size: pageSize.value });
if (searchKeyword.value) params.set('search', searchKeyword.value); if (searchKeyword.value) params.set('search', searchKeyword.value);
const res = await fetch(API + '/inventory?' + params); const res = await authFetch(API + '/inventory?' + params);
const data = await res.json(); const data = await res.json();
inventoryList.value = data.items || []; inventoryList.value = data.items || [];
total.value = data.total || 0; total.value = data.total || 0;
@@ -391,7 +524,7 @@
try { try {
const params = new URLSearchParams({ page: logPage.value, page_size: logPageSize.value }); const params = new URLSearchParams({ page: logPage.value, page_size: logPageSize.value });
if (logSearch.value) params.set('search', logSearch.value); if (logSearch.value) params.set('search', logSearch.value);
const res = await fetch(API + '/stock/logs?' + params); const res = await authFetch(API + '/stock/logs?' + params);
const data = await res.json(); const data = await res.json();
logList.value = data.items || []; logList.value = data.items || [];
logTotal.value = data.total || 0; logTotal.value = data.total || 0;
@@ -455,7 +588,7 @@
'确认删除', '确认删除',
{ type: 'warning' } { type: 'warning' }
); );
const res = await fetch(API + '/inventory/' + row.id, { method: 'DELETE' }); const res = await authFetch(API + '/inventory/' + row.id, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败'); if (!res.ok) throw new Error('删除失败');
ElementPlus.ElMessage.success('删除成功'); ElementPlus.ElMessage.success('删除成功');
loadInventory(); loadInventory();
@@ -479,7 +612,7 @@
} }
stockLoading.value = true; stockLoading.value = true;
try { try {
const res = await fetch(API + '/stock/operation', { const res = await authFetch(API + '/stock/operation', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -502,8 +635,36 @@
} }
}; };
const exportInventory = () => { window.open(API + '/inventory/export', '_blank'); }; const exportInventory = async () => {
const exportLogs = () => { window.open(API + '/stock/export', '_blank'); }; try {
const res = await authFetch(API + '/inventory/export');
if (!res.ok) throw new Error('导出失败');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '库存数据_' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
ElementPlus.ElMessage.error(e.message);
}
};
const exportLogs = async () => {
try {
const res = await authFetch(API + '/stock/export');
if (!res.ok) throw new Error('导出失败');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '出入库记录_' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
ElementPlus.ElMessage.error(e.message);
}
};
const clearLogs = async () => { const clearLogs = async () => {
try { try {
@@ -512,7 +673,7 @@
'确认清空', '确认清空',
{ type: 'warning' } { type: 'warning' }
); );
const res = await fetch(API + '/stock/logs', { method: 'DELETE' }); const res = await authFetch(API + '/stock/logs', { method: 'DELETE' });
if (!res.ok) throw new Error('清空失败'); if (!res.ok) throw new Error('清空失败');
const data = await res.json(); const data = await res.json();
ElementPlus.ElMessage.success(data.message); ElementPlus.ElMessage.success(data.message);
@@ -530,7 +691,7 @@
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
try { try {
const res = await fetch(API + '/inventory/import', { method: 'POST', body: formData }); const res = await authFetch(API + '/inventory/import', { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail || '导入失败'); if (!res.ok) throw new Error(data.detail || '导入失败');
ElementPlus.ElMessage.success(data.message); ElementPlus.ElMessage.success(data.message);
@@ -547,13 +708,20 @@
}; };
onMounted(() => { onMounted(() => {
loadInventory(); if (isLoggedIn.value) {
loadLogs(); loadInventory();
loadLogs();
}
updateTime(); updateTime();
setInterval(updateTime, 1000); setInterval(updateTime, 1000);
}); });
return { return {
// 登录状态
token, currentUser, isLoggedIn, loginDialogVisible,
loginForm, registerForm, loginLoading, isRegister,
doLogin, doRegister, logout,
// 业务
activeTab, loading, currentTime, activeTab, loading, currentTime,
inventoryList, page, pageSize, total, searchKeyword, inventoryList, page, pageSize, total, searchKeyword,
logList, logPage, logPageSize, logTotal, logSearch, logLoading, logList, logPage, logPageSize, logTotal, logSearch, logLoading,