feat(settings):增加系统Logo系统设置项,并支持根据上传的图片显示系统logo
1. 系统设置中增加系统Logo设置项 2. 系统标题增加logo的显示,并可自动判断,是否上传logo,若上传则显示 3. 完善系统设置功能,支持上传图片,支持设置值为布尔值或列表 4. 增加上传和获取图片的接口 5. 完善nginx,支持图片的接口代理
This commit is contained in:
parent
81972f1328
commit
0545d68018
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,10 +7,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
# 前端构建产物
|
|
||||||
frontend/dist/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Redis 数据
|
# Redis 数据
|
||||||
redis/data/
|
redis/data/
|
||||||
redis/logs/
|
redis/logs/
|
||||||
|
|||||||
@ -94,9 +94,9 @@ async def get_public_setting(
|
|||||||
key: str = None,
|
key: str = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""公开接口:获取系统设置(允许获取id为1/2或key为"系统标题"/"IPC内容"的内容)"""
|
"""公开接口:获取系统设置(允许获取id为1/2/3或key为"系统标题"/"页脚信息"/"IPC信息"/"系统Logo"的内容)"""
|
||||||
ALLOWED_IDS = {1, 2, 3}
|
ALLOWED_IDS = {1, 2, 3, 4, 5, 6}
|
||||||
ALLOWED_KEYS = {"系统标题", "页脚信息", "IPC信息"}
|
ALLOWED_KEYS = {"系统标题", "页脚信息", "IPC信息", "系统Logo"}
|
||||||
|
|
||||||
if setting_id is not None:
|
if setting_id is not None:
|
||||||
if setting_id not in ALLOWED_IDS:
|
if setting_id not in ALLOWED_IDS:
|
||||||
|
|||||||
537
backend/auth/apps/users/urls (1).py
Normal file
537
backend/auth/apps/users/urls (1).py
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||||
|
from fastapi.responses import Response, HTMLResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_
|
||||||
|
from fastcrud import FastCRUD
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
import json
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models import User, SystemSetting
|
||||||
|
from schemas import (
|
||||||
|
UserCreate,
|
||||||
|
UserResponse,
|
||||||
|
UserLogin,
|
||||||
|
Token,
|
||||||
|
UserCreateRequest,
|
||||||
|
UserUpdate,
|
||||||
|
PhoneSendCodeRequest,
|
||||||
|
PhoneSendCodeResponse,
|
||||||
|
PhoneRegisterRequest,
|
||||||
|
EmailRegisterInitRequest,
|
||||||
|
EmailRegisterInitResponse,
|
||||||
|
RegisterSuccessResponse,
|
||||||
|
)
|
||||||
|
from utils.password import verify_password, get_password_hash
|
||||||
|
from utils.jwt import create_access_token, decode_token
|
||||||
|
from config import app_settings
|
||||||
|
from utils.redis_client import (
|
||||||
|
get_redis, set_token_in_redis, delete_token_from_redis,
|
||||||
|
save_sms_code, get_and_delete_sms_code,
|
||||||
|
save_pending_registration, get_and_delete_pending_registration,
|
||||||
|
)
|
||||||
|
from utils.captcha import create_captcha, validate_captcha
|
||||||
|
from middleware import get_current_user
|
||||||
|
from utils.like_filter import ilike_contains
|
||||||
|
from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log
|
||||||
|
from utils.sms import generate_sms_code, send_sms_code
|
||||||
|
from utils.email import send_verification_email
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
# 创建FastCRUD实例
|
||||||
|
user_crud = FastCRUD(User, UserResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/captcha")
|
||||||
|
async def get_captcha(redis_conn: redis.Redis = Depends(get_redis)):
|
||||||
|
"""获取验证码图片"""
|
||||||
|
# 创建验证码
|
||||||
|
captcha_id, image_bytes = await create_captcha(redis_conn)
|
||||||
|
|
||||||
|
# 返回验证码图片,同时在响应头中返回验证码ID
|
||||||
|
response = Response(content=image_bytes, media_type="image/png")
|
||||||
|
response.headers["X-Captcha-ID"] = captcha_id
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
user_data: UserLogin,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""用户登录"""
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
ua = get_user_agent(request)
|
||||||
|
|
||||||
|
# 验证验证码
|
||||||
|
is_captcha_valid = await validate_captcha(
|
||||||
|
redis_conn, user_data.captcha_id, user_data.captcha_code
|
||||||
|
)
|
||||||
|
if not is_captcha_valid:
|
||||||
|
await save_login_log(db, user_data.username, ip, ua, "failure", "验证码错误或已过期")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="验证码错误或已过期",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查找用户(支持用户名/邮箱/手机号)
|
||||||
|
login_id = user_data.username
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).filter(
|
||||||
|
or_(
|
||||||
|
User.username == login_id,
|
||||||
|
User.email == login_id,
|
||||||
|
User.phone == login_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 验证用户是否存在且密码正确
|
||||||
|
if not user or not verify_password(user_data.password, user.password_hash):
|
||||||
|
await save_login_log(db, login_id, ip, ua, "failure", "用户名或密码错误")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建访问令牌
|
||||||
|
access_token_expires = timedelta(minutes=app_settings.access_token_expire_minutes)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(user.id), "username": user.username},
|
||||||
|
expires_delta=access_token_expires,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 将令牌存储到Redis
|
||||||
|
expire_seconds = int(access_token_expires.total_seconds())
|
||||||
|
await set_token_in_redis(redis_conn, user.id, access_token, expire_seconds)
|
||||||
|
|
||||||
|
# 更新用户最后登录时间
|
||||||
|
user.lastlogintime = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 记录登录成功日志
|
||||||
|
await save_login_log(db, user.username, ip, ua, "success", None)
|
||||||
|
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserResponse)
|
||||||
|
async def create_user(
|
||||||
|
user_data: UserCreateRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""创建用户"""
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
result = await db.execute(select(User).filter(User.username == user_data.username))
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
# 生成密码哈希值
|
||||||
|
hashed_password = get_password_hash(user_data.password)
|
||||||
|
|
||||||
|
# 准备创建数据
|
||||||
|
create_data = user_data.model_dump()
|
||||||
|
create_data["password_hash"] = hashed_password
|
||||||
|
del create_data["password"] # 删除明文密码
|
||||||
|
# 转换为UserCreate模型
|
||||||
|
user_data_create = UserCreate(**create_data)
|
||||||
|
|
||||||
|
# 使用FastCRUD创建用户
|
||||||
|
created_user = await user_crud.create(
|
||||||
|
db, user_data_create, schema_to_select=UserResponse, return_as_model=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录操作日志
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "create", "user",
|
||||||
|
created_user.id, created_user.username,
|
||||||
|
{"username": user_data.username, "nickname": user_data.nickname},
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return created_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search", response_model=list[UserResponse])
|
||||||
|
async def search_users(
|
||||||
|
query: dict = {},
|
||||||
|
request: Request = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""分页列表用户(POST方式);支持登录名、昵称、邮箱、手机的模糊查询与组合查询(AND)"""
|
||||||
|
stmt = select(User)
|
||||||
|
filters = []
|
||||||
|
for col, key in (
|
||||||
|
(User.username, "username"),
|
||||||
|
(User.nickname, "nickname"),
|
||||||
|
(User.email, "email"),
|
||||||
|
(User.phone, "phone"),
|
||||||
|
):
|
||||||
|
raw = query.get(key)
|
||||||
|
cond = ilike_contains(col, raw)
|
||||||
|
if cond is not None:
|
||||||
|
filters.append(cond)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(and_(*filters))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
|
||||||
|
# 记录查询日志
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "search", "user",
|
||||||
|
None, None, {"filters": {k: v for k, v in query.items() if v}},
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return [UserResponse.model_validate(u) for u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_current_user_info(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取当前用户信息(须声明在 /{user_id} 之前,否则会被当成 id)"""
|
||||||
|
# 记录查询日志
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "search", "user",
|
||||||
|
current_user.id, current_user.username, None,
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取单个用户"""
|
||||||
|
user = await user_crud.get(db, id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 记录查询日志
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "search", "user",
|
||||||
|
user_id, user.username, None,
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
user_data: UserUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""更新用户"""
|
||||||
|
# 获取原用户数据用于记录变更
|
||||||
|
result = await db.execute(select(User).filter(User.id == user_id))
|
||||||
|
old_user = result.scalar_one_or_none()
|
||||||
|
if not old_user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 检查用户名是否被其他用户使用
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).filter(User.username == user_data.username, User.id != user_id)
|
||||||
|
)
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
# 生成密码哈希值
|
||||||
|
hashed_password = (
|
||||||
|
get_password_hash(user_data.password) if user_data.password else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 准备更新数据
|
||||||
|
update_data = user_data.model_dump()
|
||||||
|
if hashed_password:
|
||||||
|
update_data["password_hash"] = hashed_password
|
||||||
|
del update_data["password"] # 删除明文密码
|
||||||
|
|
||||||
|
# 使用FastCRUD更新用户
|
||||||
|
updated_user = await user_crud.update(
|
||||||
|
db, update_data, id=user_id, schema_to_select=UserResponse, return_as_model=True
|
||||||
|
)
|
||||||
|
if not updated_user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 记录操作日志(计算变更字段)
|
||||||
|
changed = {}
|
||||||
|
for field in ["username", "nickname", "email", "phone", "role_id"]:
|
||||||
|
old_val = getattr(old_user, field, None)
|
||||||
|
new_val = update_data.get(field)
|
||||||
|
if old_val != new_val:
|
||||||
|
changed[field] = {"old": old_val, "new": new_val}
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "update", "user",
|
||||||
|
user_id, old_user.username, changed if changed else None,
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return updated_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""删除用户"""
|
||||||
|
# 检查用户是否存在
|
||||||
|
result = await db.execute(select(User).filter(User.id == user_id))
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if not existing_user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
elif existing_user.id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="不能删除当前登录用户")
|
||||||
|
elif existing_user.role_id == 1:
|
||||||
|
raise HTTPException(status_code=400, detail="不能删除管理员用户")
|
||||||
|
else:
|
||||||
|
# 记录操作日志(删除前记录被删用户信息)
|
||||||
|
await create_operation_log(
|
||||||
|
db, current_user.id, current_user.username, "delete", "user",
|
||||||
|
user_id, existing_user.username,
|
||||||
|
{"username": existing_user.username, "nickname": existing_user.nickname},
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
)
|
||||||
|
# 使用FastCRUD删除用户
|
||||||
|
deleted = await user_crud.delete(db, id=user_id)
|
||||||
|
return {"message": "用户删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# 用户注册
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register/send-code", response_model=PhoneSendCodeResponse)
|
||||||
|
async def send_register_sms_code(
|
||||||
|
req: PhoneSendCodeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""发送手机注册验证码"""
|
||||||
|
# 检查短信注册开关
|
||||||
|
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册"))
|
||||||
|
sms_setting = result.scalar_one_or_none()
|
||||||
|
if not sms_setting or sms_setting.value != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="手机短信注册功能未启用")
|
||||||
|
|
||||||
|
# 检查手机号是否已被注册
|
||||||
|
result = await db.execute(select(User).filter(User.phone == req.phone))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="该手机号已被注册")
|
||||||
|
|
||||||
|
# 频率限制:60秒内不能重复发送
|
||||||
|
ttl = await redis_conn.ttl(f"sms_code:{req.phone}")
|
||||||
|
if ttl > 240:
|
||||||
|
raise HTTPException(status_code=429, detail="验证码已发送,请60秒后再试")
|
||||||
|
|
||||||
|
# 生成验证码并存入 Redis
|
||||||
|
code = generate_sms_code()
|
||||||
|
await save_sms_code(redis_conn, req.phone, code)
|
||||||
|
|
||||||
|
# 发送短信
|
||||||
|
await send_sms_code(req.phone, code)
|
||||||
|
|
||||||
|
return PhoneSendCodeResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register/phone", response_model=RegisterSuccessResponse)
|
||||||
|
async def register_by_phone(
|
||||||
|
req: PhoneRegisterRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""通过手机号注册"""
|
||||||
|
# 检查短信注册开关
|
||||||
|
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用手机短信注册"))
|
||||||
|
sms_setting = result.scalar_one_or_none()
|
||||||
|
if not sms_setting or sms_setting.value != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="手机短信注册功能未启用")
|
||||||
|
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
result = await db.execute(select(User).filter(User.username == req.username))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
# 检查手机号是否已被注册
|
||||||
|
result = await db.execute(select(User).filter(User.phone == req.phone))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="该手机号已被注册")
|
||||||
|
|
||||||
|
# 验证短信验证码
|
||||||
|
stored_code = await get_and_delete_sms_code(redis_conn, req.phone)
|
||||||
|
if stored_code is None:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码已过期,请重新发送")
|
||||||
|
if stored_code != req.code:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码错误")
|
||||||
|
|
||||||
|
# 创建用户
|
||||||
|
hashed_pw = get_password_hash(req.password)
|
||||||
|
new_user = User(
|
||||||
|
username=req.username,
|
||||||
|
nickname=req.nickname,
|
||||||
|
phone=req.phone,
|
||||||
|
password_hash=hashed_pw,
|
||||||
|
role_id=2,
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_user)
|
||||||
|
|
||||||
|
return RegisterSuccessResponse(
|
||||||
|
id=new_user.id, username=new_user.username, nickname=new_user.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register/email/init", response_model=EmailRegisterInitResponse)
|
||||||
|
async def init_email_registration(
|
||||||
|
req: EmailRegisterInitRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""初始化邮箱注册(发送验证邮件)"""
|
||||||
|
# 检查邮箱注册开关
|
||||||
|
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册"))
|
||||||
|
email_setting = result.scalar_one_or_none()
|
||||||
|
if not email_setting or email_setting.value != "true":
|
||||||
|
raise HTTPException(status_code=403, detail="邮箱注册功能未启用")
|
||||||
|
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
result = await db.execute(select(User).filter(User.username == req.username))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
# 检查邮箱是否已被注册
|
||||||
|
result = await db.execute(select(User).filter(User.email == req.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="该邮箱已被注册")
|
||||||
|
|
||||||
|
# 哈希密码后存储待注册数据
|
||||||
|
hashed_pw = get_password_hash(req.password)
|
||||||
|
token_id = str(uuid4())
|
||||||
|
pending_data = json.dumps({
|
||||||
|
"username": req.username,
|
||||||
|
"nickname": req.nickname,
|
||||||
|
"email": req.email,
|
||||||
|
"password_hash": hashed_pw,
|
||||||
|
})
|
||||||
|
await save_pending_registration(redis_conn, token_id, pending_data)
|
||||||
|
|
||||||
|
# 生成 JWT 验证令牌
|
||||||
|
jwt_token = create_access_token(
|
||||||
|
data={"sub": token_id, "type": "email_verify", "email": req.email},
|
||||||
|
expires_delta=timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送验证邮件
|
||||||
|
await send_verification_email(req.email, jwt_token)
|
||||||
|
|
||||||
|
return EmailRegisterInitResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify-email")
|
||||||
|
async def verify_email(
|
||||||
|
token: str = Query(..., description="验证令牌"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""验证邮箱并完成注册"""
|
||||||
|
# 检查邮箱注册开关
|
||||||
|
result = await db.execute(select(SystemSetting).filter(SystemSetting.key == "启用邮箱注册"))
|
||||||
|
email_setting = result.scalar_one_or_none()
|
||||||
|
if not email_setting or email_setting.value != "true":
|
||||||
|
return HTMLResponse("<h2>邮箱注册功能未启用</h2>", status_code=403)
|
||||||
|
|
||||||
|
# 解析 JWT
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return HTMLResponse("<h2>验证链接无效或已过期</h2>", status_code=400)
|
||||||
|
if payload.get("type") != "email_verify":
|
||||||
|
return HTMLResponse("<h2>无效的验证链接</h2>", status_code=400)
|
||||||
|
|
||||||
|
token_id = payload.get("sub")
|
||||||
|
pending_json = await get_and_delete_pending_registration(redis_conn, token_id)
|
||||||
|
if pending_json is None:
|
||||||
|
return HTMLResponse("<h2>验证链接已过期或已使用</h2>", status_code=400)
|
||||||
|
|
||||||
|
data = json.loads(pending_json)
|
||||||
|
|
||||||
|
# 再次检查用户名和邮箱唯一性
|
||||||
|
result = await db.execute(select(User).filter(User.username == data["username"]))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return HTMLResponse("<h2>该用户名已被注册</h2>", status_code=400)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).filter(User.email == data["email"]))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return HTMLResponse("<h2>该邮箱已被注册</h2>", status_code=400)
|
||||||
|
|
||||||
|
# 创建用户
|
||||||
|
new_user = User(
|
||||||
|
username=data["username"],
|
||||||
|
nickname=data["nickname"],
|
||||||
|
email=data["email"],
|
||||||
|
password_hash=data["password_hash"],
|
||||||
|
role_id=2,
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_user)
|
||||||
|
|
||||||
|
return HTMLResponse(f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:Arial,sans-serif;text-align:center;padding:60px 20px;">
|
||||||
|
<h1 style="color:#67c23a;">✅ 注册成功</h1>
|
||||||
|
<p>欢迎 <b>{new_user.username}</b>,您的账号已创建完成。</p>
|
||||||
|
<p>请返回应用使用 <b>用户名、邮箱或手机号</b> 登录。</p>
|
||||||
|
</body></html>""")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
redis_conn: redis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""用户登出"""
|
||||||
|
await delete_token_from_redis(redis_conn, current_user.id)
|
||||||
|
|
||||||
|
# 记录登出日志
|
||||||
|
await save_login_log(
|
||||||
|
db, current_user.username,
|
||||||
|
get_client_ip(request), get_user_agent(request),
|
||||||
|
"success", None,
|
||||||
|
)
|
||||||
|
return {"message": "登出成功"}
|
||||||
@ -389,6 +389,12 @@ async def init_system_settings(db: AsyncSession):
|
|||||||
"value": "false",
|
"value": "false",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "是否允许通过邮箱验证链接注册"
|
"description": "是否允许通过邮箱验证链接注册"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "系统Logo",
|
||||||
|
"value": "",
|
||||||
|
"type": "image",
|
||||||
|
"description": "系统Logo图片"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from apps.urls import api_router
|
from apps.urls import api_router
|
||||||
|
from apps.settings.urls import UPLOAD_DIR
|
||||||
from database import engine, get_db
|
from database import engine, get_db
|
||||||
from models import Base
|
from models import Base
|
||||||
from init_data import init_all_data
|
from init_data import init_all_data
|
||||||
@ -60,6 +61,15 @@ app.add_middleware(
|
|||||||
swagger_ui_path = Path(__file__).parent.parent / "swagger-ui"
|
swagger_ui_path = Path(__file__).parent.parent / "swagger-ui"
|
||||||
app.mount("/static", StaticFiles(directory=str(swagger_ui_path)), name="static")
|
app.mount("/static", StaticFiles(directory=str(swagger_ui_path)), name="static")
|
||||||
|
|
||||||
|
# 上传图片通过自定义端点提供(和 settings/urls.py 中的 UPLOAD_DIR 保持一致)
|
||||||
|
@app.get("/uploads/{filename}")
|
||||||
|
async def serve_upload(filename: str):
|
||||||
|
"""提供上传的图片文件"""
|
||||||
|
file_path = UPLOAD_DIR / filename
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
return FileResponse(str(file_path))
|
||||||
|
|
||||||
|
|
||||||
# 自定义Swagger UI页面
|
# 自定义Swagger UI页面
|
||||||
@app.get("/docs", include_in_schema=False)
|
@app.get("/docs", include_in_schema=False)
|
||||||
|
|||||||
BIN
backend/auth/uploads/setting_20260619145207.png
Normal file
BIN
backend/auth/uploads/setting_20260619145207.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
@ -18,6 +18,13 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://auth:8000/uploads/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/dist/assets/AdminLayout-BbmuxEs9.css
vendored
Normal file
1
frontend/dist/assets/AdminLayout-BbmuxEs9.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.admin-root[data-v-627faa7f]{height:100vh}.aside[data-v-627faa7f]{background:#0f172a;color:#e2e8f0}.brand[data-v-627faa7f]{padding:14px 16px;display:flex;align-items:center;gap:10px;border-bottom:1px solid rgba(148,163,184,.2)}.brand-logo[data-v-627faa7f]{width:32px;height:32px;object-fit:contain;flex-shrink:0}.brand-text[data-v-627faa7f]{font-weight:600;letter-spacing:.02em;line-height:1.4}.header[data-v-627faa7f]{display:flex;align-items:center;justify-content:flex-end;border-bottom:1px solid var(--el-border-color-lighter);background:#fff}.right[data-v-627faa7f]{display:flex;align-items:center;gap:12px}.page-title-bar[data-v-627faa7f]{flex-shrink:0;margin-bottom:16px}.page-title[data-v-627faa7f]{margin:0;font-size:20px;font-weight:600;color:var(--el-text-color-primary);line-height:1.4}.nick[data-v-627faa7f]{color:var(--el-text-color-secondary);font-size:14px}.main[data-v-627faa7f]{background:#f8fafc;padding:16px;display:flex;flex-direction:column;min-width:0}.footer[data-v-627faa7f]{flex-shrink:0;margin-top:auto;padding:12px 0;text-align:center;font-size:13px;color:#94a3b8;border-top:1px solid #e2e8f0}
|
||||||
1
frontend/dist/assets/AdminLayout-C8hRi9x-.js
vendored
Normal file
1
frontend/dist/assets/AdminLayout-C8hRi9x-.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{f as K,u as O,p as y,g as Q,E as X,q as d,w as a,k as Y,l as Z,h as f,r as o,i as p,s as ee,v as te,x as ae,d as c,a as n,c as _,y as b,t as l,z as I,F as k,A as N,B as w,b as S,C as T,D as se,o as s,_ as ne}from"./index-ChTrSGCb.js";const oe={class:"brand"},ce=["src"],ie={class:"brand-text"},le={class:"right"},re={class:"nick"},ue={class:"page-title-bar"},de={class:"page-title"},_e={class:"page-content"},fe={key:0,class:"footer"},pe={href:"https://beian.miit.gov.cn",target:"_blank"},me={key:0},ge=K({__name:"AdminLayout",setup(he){const m=Y(),x=Z(),i=O(),L=p(""),g=p(""),h=p(""),v=p(""),V=T(()=>m.path),$=T(()=>m.meta.title||""),R={perm:y(ae),system:y(te),log:y(ee)};function C(r){for(const e in R)if(r.startsWith(e))return R[e];return se}async function D(){let e=await f.get("/api/settings/public?setting_id=1");e.data&&(L.value=e.data.value)}async function E(){try{let e=await f.get("/api/settings/public?setting_id=2");e.data&&(h.value=e.data.value)}catch{}}async function M(){try{let e=await f.get("/api/settings/public?setting_id=3");e.data&&(v.value=e.data.value)}catch{}}async function P(){try{let e=await f.get("/api/settings/public?key=系统Logo");e.data&&e.data.value&&(g.value=e.data.value)}catch{}}Q(async()=>{try{await D(),await P(),await E(),await M(),i.user||await i.fetchProfile(),await i.fetchPermissions()}catch{X.error("获取用户信息失败,请重新登录"),await i.logout(),x.replace({name:"admin-login",query:{redirect:m.fullPath}})}});async function q(){await i.logout(),x.push({name:"admin-login"})}return(r,e)=>{const U=o("el-icon"),B=o("el-menu-item"),F=o("el-sub-menu"),z=o("el-menu"),W=o("el-aside"),j=o("el-button"),G=o("el-header"),H=o("router-view"),J=o("el-main"),A=o("el-container");return s(),d(A,{class:"admin-root"},{default:a(()=>[c(W,{width:"220px",class:"aside"},{default:a(()=>[n("div",oe,[g.value?(s(),_("img",{key:0,src:g.value,class:"brand-logo"},null,8,ce)):b("",!0),n("span",ie,l(L.value),1)]),c(z,{"default-active":V.value,router:"","background-color":"#0f172a","text-color":"#e2e8f0","active-text-color":"#38bdf8"},{default:a(()=>[(s(!0),_(k,null,I(w(i).menu,t=>(s(),_(k,{key:t.id},[t.children&&t.children.length>0?(s(),d(F,{key:0,index:t.route},{title:a(()=>[c(U,null,{default:a(()=>[(s(),d(N(C(t.code))))]),_:2},1024),n("span",null,l(t.name),1)]),default:a(()=>[(s(!0),_(k,null,I(t.children,u=>(s(),d(B,{key:u.id,index:u.route},{default:a(()=>[n("span",null,l(u.name),1)]),_:2},1032,["index"]))),128))]),_:2},1032,["index"])):(s(),d(B,{key:1,index:t.route},{default:a(()=>[c(U,null,{default:a(()=>[(s(),d(N(C(t.code))))]),_:2},1024),n("span",null,l(t.name),1)]),_:2},1032,["index"]))],64))),128))]),_:1},8,["default-active"])]),_:1}),c(A,null,{default:a(()=>[c(G,{class:"header"},{default:a(()=>{var t,u;return[n("div",le,[n("span",re,l(((t=w(i).user)==null?void 0:t.nickname)||((u=w(i).user)==null?void 0:u.username)),1),c(j,{type:"primary",link:"",onClick:q},{default:a(()=>[...e[0]||(e[0]=[S("退出",-1)])]),_:1})])]}),_:1}),c(J,{class:"main"},{default:a(()=>[n("div",ue,[n("h1",de,l($.value),1)]),n("div",_e,[c(H)]),h.value?(s(),_("div",fe,[S(l(h.value)+" ",1),n("a",pe,[v.value?(s(),_("span",me," | "+l(v.value),1)):b("",!0)])])):b("",!0)]),_:1})]),_:1})]),_:1})}}}),ye=ne(ge,[["__scopeId","data-v-627faa7f"]]);export{ye as default};
|
||||||
1
frontend/dist/assets/LoginLogManage-CQqltd1Y.js
vendored
Normal file
1
frontend/dist/assets/LoginLogManage-CQqltd1Y.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{G as y,O as M,n as w,f as V,H as Y,g as D,c as H,d as n,w as l,F as O,i as p,r as s,I as R,B as E,b as _,E as m,P as L,o as I}from"./index-ChTrSGCb.js";function P(f){return()=>({crudOptions:{pagination:{show:!1},search:{show:!0,options:{layout:"inline"},expand:!1},request:{pageRequest:async e=>{const{data:t}=await w.post("/login-logs/search",e);return t},transformQuery:({form:e})=>{const t={};return e!=null&&e.username&&(t.username=e.username),e!=null&&e.status&&(t.status=e.status),t},transformRes:({res:e})=>{const t=Array.isArray(e)?e:[];return{currentPage:1,pageSize:Math.max(t.length,1),total:t.length,records:t}}},rowHandle:{show:!1},toolbar:{buttons:{clean:{text:"清理旧日志",type:"warning",click:()=>f.onClean()}}},columns:{id:{title:"ID",key:"id",type:"number",column:{width:70},form:{show:!1}},username:{title:"用户名",key:"username",type:"text",search:{show:!0}},status:{title:"状态",key:"status",type:"dict-select",search:{show:!0},dict:M({data:[{value:"success",label:"成功"},{value:"failure",label:"失败"}],value:"value",label:"label"}),column:{width:80}},failure_reason:{title:"失败原因",key:"failure_reason",type:"text",column:{width:160}},ip_address:{title:"IP地址",key:"ip_address",type:"text",column:{width:150}},user_agent:{title:"User-Agent",key:"user_agent",type:"text",column:{width:300}},login_time:{title:"登录时间",key:"login_time",type:"text",column:{width:170},form:{show:!1},valueBuilder({value:e,row:t,key:u}){!t||e==null||(t[u]=y(e).format("YYYY-MM-DD HH:mm:ss"))}}}}})}const S=V({__name:"LoginLogManage",setup(f){const{crudBinding:e,crudRef:t,crudExpose:u}=Y({createCrudOptions:P({onClean:()=>{o.value=!0}})}),o=p(!1),r=p(),d=p(!1);async function h(){if(!r.value){m.warning("请选择要清理的日期");return}try{await L.confirm(`确定要清理 ${y(r.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有登录日志吗?此操作不可撤销。`,"确认清理",{confirmButtonText:"确认清理",cancelButtonText:"取消",type:"warning"})}catch{return}d.value=!0;try{const{data:c}=await w.delete("/login-logs/clean",{data:{before_date:r.value.toISOString()}});m.success(c.message||"清理成功"),o.value=!1,u.doRefresh()}catch{m.error("清理失败")}finally{d.value=!1}}return D(()=>{u.doRefresh()}),(c,a)=>{const v=s("fs-crud"),x=s("fs-page"),k=s("el-date-picker"),b=s("el-form-item"),C=s("el-form"),g=s("el-button"),B=s("el-dialog");return I(),H(O,null,[n(x,null,{default:l(()=>[n(v,R({ref_key:"crudRef",ref:t},E(e)),null,16)]),_:1}),n(B,{modelValue:o.value,"onUpdate:modelValue":a[2]||(a[2]=i=>o.value=i),title:"清理旧日志",width:"420px"},{footer:l(()=>[n(g,{onClick:a[1]||(a[1]=i=>o.value=!1)},{default:l(()=>[...a[3]||(a[3]=[_("取消",-1)])]),_:1}),n(g,{type:"warning",loading:d.value,onClick:h},{default:l(()=>[...a[4]||(a[4]=[_("确认清理",-1)])]),_:1},8,["loading"])]),default:l(()=>[n(C,null,{default:l(()=>[n(b,{label:"清理此日期之前的日志"},{default:l(()=>[n(k,{modelValue:r.value,"onUpdate:modelValue":a[0]||(a[0]=i=>r.value=i),type:"datetime",placeholder:"选择日期时间",style:{width:"100%"}},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)}}});export{S as default};
|
||||||
1
frontend/dist/assets/LoginView-CL6FqBfI.js
vendored
Normal file
1
frontend/dist/assets/LoginView-CL6FqBfI.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{f as q,u as I,g as j,c as h,d as o,w as r,h as k,E as g,r as u,i,j as B,k as D,l as E,o as w,m as M,a as v,b as N,t as O,n as P,_ as $}from"./index-ChTrSGCb.js";const A={class:"login-wrap"},W={class:"card-title"},X={class:"captcha-row"},z=["src"],F={key:1,class:"captcha-placeholder"},G=q({__name:"LoginView",setup(H){const x=D(),R=E(),m=I(),b=i(),_=i(!1),l=i(""),p=i(""),y=i(""),V="/api",a=B({username:"",password:"",captcha_code:""}),U={username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}],captcha_code:[{required:!0,message:"请输入验证码",trigger:"blur"}]};async function C(){let t=await k.get(`${V}/settings/public?setting_id=1`);t.data&&(y.value=t.data.value)}async function f(){l.value.startsWith("blob:")&&URL.revokeObjectURL(l.value),l.value="",p.value="";const t=await k.get(`${V}/users/captcha`,{responseType:"blob",headers:{}}),e=t.headers["x-captcha-id"]||t.headers["X-Captcha-ID"];if(!e){g.error("未获取到验证码 ID,请检查后端 CORS expose_headers");return}p.value=e,l.value=URL.createObjectURL(t.data),a.captcha_code=""}async function L(){var e,n;if(!p.value){g.warning("请先加载验证码");return}const t=b.value;if(t){try{await t.validate()}catch{return}_.value=!0;try{const{data:s}=await P.post("/users/login",{username:a.username,password:a.password,captcha_id:p.value,captcha_code:a.captcha_code});m.setToken(s.access_token),await m.fetchProfile(),await m.fetchPermissions();const c=x.query.redirect||"/admin";await R.replace(c)}catch(s){const c=(n=(e=s==null?void 0:s.response)==null?void 0:e.data)==null?void 0:n.detail;g.error(typeof c=="string"?c:"登录失败"),await f()}finally{_.value=!1}}}return j(()=>{f(),C()}),(t,e)=>{const n=u("el-input"),s=u("el-form-item"),c=u("el-button"),S=u("el-form"),T=u("el-card");return w(),h("div",A,[o(T,{class:"card",shadow:"hover"},{header:r(()=>[v("span",W,O(y.value),1)]),default:r(()=>[o(S,{ref_key:"formRef",ref:b,model:a,rules:U,"label-width":"88px",onSubmit:e[3]||(e[3]=M(()=>{},["prevent"]))},{default:r(()=>[o(s,{label:"用户名",prop:"username"},{default:r(()=>[o(n,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=d=>a.username=d),autocomplete:"username"},null,8,["modelValue"])]),_:1}),o(s,{label:"密码",prop:"password"},{default:r(()=>[o(n,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=d=>a.password=d),type:"password","show-password":"",autocomplete:"current-password"},null,8,["modelValue"])]),_:1}),o(s,{label:"验证码",prop:"captcha_code"},{default:r(()=>[v("div",X,[o(n,{modelValue:a.captcha_code,"onUpdate:modelValue":e[2]||(e[2]=d=>a.captcha_code=d),maxlength:"8",placeholder:"请输入验证码"},null,8,["modelValue"]),v("div",{class:"captcha-img",onClick:f},[l.value?(w(),h("img",{key:0,src:l.value,alt:"captcha"},null,8,z)):(w(),h("span",F,"点击获取"))])])]),_:1}),o(s,null,{default:r(()=>[o(c,{type:"primary",loading:_.value,style:{width:"100%"},onClick:L},{default:r(()=>[...e[4]||(e[4]=[N(" 登录 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])]),_:1})])}}}),K=$(G,[["__scopeId","data-v-3d2542ad"]]);export{K as default};
|
||||||
1
frontend/dist/assets/LoginView-CvZfAeZ0.css
vendored
Normal file
1
frontend/dist/assets/LoginView-CvZfAeZ0.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.login-wrap[data-v-3d2542ad]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(160deg,#0f172a,#1e3a5f,#0f172a);padding:24px}.card[data-v-3d2542ad]{width:100%;max-width:420px;border-radius:12px}.card-title[data-v-3d2542ad]{font-weight:600;font-size:16px}.captcha-row[data-v-3d2542ad]{display:flex;gap:12px;align-items:center;width:100%}.captcha-img[data-v-3d2542ad]{flex-shrink:0;width:120px;height:40px;border:1px solid var(--el-border-color);border-radius:4px;cursor:pointer;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#f8fafc}.captcha-img img[data-v-3d2542ad]{width:100%;height:100%;object-fit:cover}.captcha-placeholder[data-v-3d2542ad]{font-size:12px;color:var(--el-text-color-secondary)}
|
||||||
1
frontend/dist/assets/MenuManage-J689-0Rz.js
vendored
Normal file
1
frontend/dist/assets/MenuManage-J689-0Rz.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{G as l,E as t,n as u,f as y,H as h,g as w,q as _,w as x,r as m,d as k,I as q,B as E,o as M}from"./index-ChTrSGCb.js";import{t as D}from"./list-query-4ZZvVtjn.js";const R=()=>({crudOptions:{pagination:{show:!1},search:{show:!0,options:{layout:"inline"},expand:!1},tree:{enable:!0,childrenKey:"children",parentKey:"parentid",hasChildField:"children"},request:{transformQuery:({form:e})=>D(e,["name","code"]),pageRequest:async e=>{const{data:s}=await u.get("/permissions/tree");return s},transformRes:({res:e})=>{const s=Array.isArray(e)?e:[];return{currentPage:1,pageSize:Math.max(s.length,1),total:s.length,records:s}},addRequest:async({form:e})=>{await u.post("/permissions/",e),t.success({message:"添加成功",duration:5e3})},editRequest:async({form:e,row:s})=>{s&&(await u.put(`/permissions/${s.id}`,e),t.success({message:"修改成功",duration:5e3}))},delRequest:async({row:e})=>{e&&(await u.delete(`/permissions/${e.id}`),t.success({message:"删除成功",duration:5e3}))},onSuccess:({action:e})=>{e==="add"?t.success({message:"添加成功",duration:5e3}):e==="edit"&&t.success({message:"修改成功",duration:5e3})},onError:e=>{var r;let s="操作失败";if((r=e.response)!=null&&r.data){const a=e.response.data;a.message?s=a.message:a.detail&&(s=typeof a.detail=="string"?a.detail:"操作失败")}t.error({message:s,duration:5e3})}},table:{remove:{showSuccessNotification:!1}},rowHandle:{buttons:{edit:{text:"编辑"},remove:{text:"删除"}}},columns:{id:{title:"ID",key:"id",type:"number",column:{width:70},form:{show:!1}},name:{title:"名称",key:"name",type:"text",search:{show:!0},form:{rules:[{required:!0,message:"必填"}]}},code:{title:"编码",key:"code",type:"text",search:{show:!0},form:{rules:[{required:!0,message:"必填"}]}},parentid:{title:"父级ID",key:"parentid",type:"number",form:{value:0,rules:[{required:!0,message:"必填"}]}},description:{title:"描述",key:"description",type:"textarea",column:{show:!1}},route:{title:"路由",key:"route",type:"text",column:{width:200},form:{placeholder:"如:/admin/users"}},createtime:{title:"创建时间",key:"createtime",type:"text",form:{show:!1},column:{width:170},valueBuilder({value:e,row:s,key:r}){!s||e==null||(s[r]=l(e).format("YYYY-MM-DD HH:mm:ss"))}},updatetime:{title:"更新时间",key:"updatetime",type:"text",form:{show:!1},column:{width:170},valueBuilder({value:e,row:s,key:r}){!s||e==null||(s[r]=l(e).format("YYYY-MM-DD HH:mm:ss"))}}}}}),B=y({__name:"MenuManage",setup(e){const{crudBinding:s,crudRef:r,crudExpose:a}=h({createCrudOptions:R}),p=()=>{t.success({message:"添加成功",duration:5e3})},f=()=>{t.success({message:"修改成功",duration:5e3})},g=()=>{t.success({message:"删除成功",duration:5e3})},d=c=>{var i;let n="操作失败";if((i=c.response)!=null&&i.data){const o=c.response.data;o.message?n=o.message:o.detail&&(n=typeof o.detail=="string"?o.detail:"操作失败")}t.error({message:n,duration:5e3})};return w(()=>{a.doRefresh()}),(c,n)=>{const i=m("fs-crud"),o=m("fs-page");return M(),_(o,null,{default:x(()=>[k(i,q({ref_key:"crudRef",ref:r},E(s),{onAddSuccess:p,onEditSuccess:f,onDelSuccess:g,onAddError:d,onEditError:d,onDelError:d}),null,16)]),_:1})}}});export{B as default};
|
||||||
1
frontend/dist/assets/OperationLogManage-CrJsIKz-.js
vendored
Normal file
1
frontend/dist/assets/OperationLogManage-CrJsIKz-.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{G as w,O as f,n as h,f as M,H as V,g as O,c as Y,d as l,w as n,F as H,i as p,r as o,I,B as R,b as g,E as m,P as E,o as P}from"./index-ChTrSGCb.js";function A(_){return()=>({crudOptions:{pagination:{show:!1},search:{show:!0,options:{layout:"inline"},expand:!1},request:{pageRequest:async e=>{const{data:t}=await h.post("/operation-logs/search",e);return t},transformQuery:({form:e})=>{const t={};return e!=null&&e.username&&(t.username=e.username),e!=null&&e.action_type&&(t.action_type=e.action_type),e!=null&&e.target_type&&(t.target_type=e.target_type),t},transformRes:({res:e})=>{const t=Array.isArray(e)?e:[];return{currentPage:1,pageSize:Math.max(t.length,1),total:t.length,records:t}}},rowHandle:{show:!1},toolbar:{buttons:{clean:{text:"清理旧日志",type:"warning",click:()=>_.onClean()}}},columns:{id:{title:"ID",key:"id",type:"number",column:{width:70},form:{show:!1}},user_id:{title:"用户ID",key:"user_id",type:"number",column:{width:80},form:{show:!1}},username:{title:"操作人",key:"username",type:"text",search:{show:!0}},action_type:{title:"操作类型",key:"action_type",type:"dict-select",search:{show:!0},dict:f({data:[{value:"create",label:"创建"},{value:"update",label:"更新"},{value:"delete",label:"删除"}],value:"value",label:"label"}),column:{width:90}},target_type:{title:"目标类型",key:"target_type",type:"dict-select",search:{show:!0},dict:f({data:[{value:"user",label:"用户"},{value:"role",label:"角色"},{value:"permission",label:"菜单"},{value:"setting",label:"系统设置"}],value:"value",label:"label"}),column:{width:100}},target_name:{title:"目标名称",key:"target_name",type:"text",column:{width:140}},target_id:{title:"目标ID",key:"target_id",type:"number",column:{width:80},form:{show:!1}},detail:{title:"变更详情",key:"detail",type:"text",column:{width:200},form:{show:!1}},ip_address:{title:"IP地址",key:"ip_address",type:"text",column:{width:150}},user_agent:{title:"User-Agent",key:"user_agent",type:"text",column:{width:300}},createtime:{title:"操作时间",key:"createtime",type:"text",column:{width:170},form:{show:!1},valueBuilder({value:e,row:t,key:u}){!t||e==null||(t[u]=w(e).format("YYYY-MM-DD HH:mm:ss"))}}}}})}const S=M({__name:"OperationLogManage",setup(_){const{crudBinding:e,crudRef:t,crudExpose:u}=V({createCrudOptions:A({onClean:()=>{s.value=!0}})}),s=p(!1),r=p(),d=p(!1);async function v(){if(!r.value){m.warning("请选择要清理的日期");return}try{await E.confirm(`确定要清理 ${w(r.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有操作日志吗?此操作不可撤销。`,"确认清理",{confirmButtonText:"确认清理",cancelButtonText:"取消",type:"warning"})}catch{return}d.value=!0;try{const{data:c}=await h.delete("/operation-logs/clean",{data:{before_date:r.value.toISOString()}});m.success(c.message||"清理成功"),s.value=!1,u.doRefresh()}catch{m.error("清理失败")}finally{d.value=!1}}return O(()=>{u.doRefresh()}),(c,a)=>{const b=o("fs-crud"),k=o("fs-page"),x=o("el-date-picker"),C=o("el-form-item"),B=o("el-form"),y=o("el-button"),D=o("el-dialog");return P(),Y(H,null,[l(k,null,{default:n(()=>[l(b,I({ref_key:"crudRef",ref:t},R(e)),null,16)]),_:1}),l(D,{modelValue:s.value,"onUpdate:modelValue":a[2]||(a[2]=i=>s.value=i),title:"清理旧日志",width:"420px"},{footer:n(()=>[l(y,{onClick:a[1]||(a[1]=i=>s.value=!1)},{default:n(()=>[...a[3]||(a[3]=[g("取消",-1)])]),_:1}),l(y,{type:"warning",loading:d.value,onClick:v},{default:n(()=>[...a[4]||(a[4]=[g("确认清理",-1)])]),_:1},8,["loading"])]),default:n(()=>[l(B,null,{default:n(()=>[l(C,{label:"清理此日期之前的日志"},{default:n(()=>[l(x,{modelValue:r.value,"onUpdate:modelValue":a[0]||(a[0]=i=>r.value=i),type:"datetime",placeholder:"选择日期时间",style:{width:"100%"}},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)}}});export{S as default};
|
||||||
1
frontend/dist/assets/PublicHome-BE6848HM.js
vendored
Normal file
1
frontend/dist/assets/PublicHome-BE6848HM.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{_ as n,c as r,a as s,b as t,d as l,w as d,r as i,o as a}from"./index-ChTrSGCb.js";const p={},u={class:"public-wrap"},m={class:"hint"};function f(_,o){const e=i("router-link");return a(),r("div",u,[o[5]||(o[5]=s("h1",null,"BookSystem",-1)),s("p",m,[o[1]||(o[1]=t(" 此处预留为",-1)),o[2]||(o[2]=s("strong",null,"无需登录",-1)),o[3]||(o[3]=t("的站点首页(图书浏览、门户等)。管理功能请前往 ",-1)),l(e,{to:"/admin/login"},{default:d(()=>[...o[0]||(o[0]=[t("管理后台",-1)])]),_:1}),o[4]||(o[4]=t("。 ",-1))])])}const x=n(p,[["render",f],["__scopeId","data-v-f5876933"]]);export{x as default};
|
||||||
1
frontend/dist/assets/PublicHome-DJV5lKWp.css
vendored
Normal file
1
frontend/dist/assets/PublicHome-DJV5lKWp.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.public-wrap[data-v-f5876933]{max-width:640px;margin:10vh auto;padding:0 24px;font-family:system-ui,-apple-system,sans-serif}.hint[data-v-f5876933]{line-height:1.6;color:#555}
|
||||||
1
frontend/dist/assets/RoleManage-C7cZ0SSm.js
vendored
Normal file
1
frontend/dist/assets/RoleManage-C7cZ0SSm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/RoleManage-DVUY3tTJ.css
vendored
Normal file
1
frontend/dist/assets/RoleManage-DVUY3tTJ.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.dlg-role[data-v-0e80fdd4]{margin:0 0 12px;font-size:14px}.perm-tags[data-v-0e80fdd4]{display:flex;flex-wrap:wrap;gap:8px}.perm-tag[data-v-0e80fdd4]{margin:0}.perm-tree[data-v-0e80fdd4]{max-height:440px;overflow:auto;padding:8px 4px;border:1px solid var(--el-border-color-lighter);border-radius:8px;background:var(--el-fill-color-blank)}
|
||||||
1
frontend/dist/assets/SettingManage-CAoqayco.css
vendored
Normal file
1
frontend/dist/assets/SettingManage-CAoqayco.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.setting-container[data-v-a71db493]{padding:20px}.header[data-v-a71db493]{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header h2[data-v-a71db493]{margin:0;font-size:18px}.add-btn[data-v-a71db493]{background:#409eff;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer}.add-btn[data-v-a71db493]:hover{background:#66b1ff}.settings-table[data-v-a71db493]{width:100%;border-collapse:collapse}.settings-table th[data-v-a71db493],.settings-table td[data-v-a71db493]{border:1px solid #ebeef5;padding:12px;text-align:left}.settings-table th[data-v-a71db493]{background:#f5f7fa;font-weight:600}.value-cell[data-v-a71db493]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cell-image[data-v-a71db493]{width:60px;height:60px;object-fit:contain;border-radius:4px;background:#f5f7fa}.actions[data-v-a71db493]{display:flex;gap:8px}.edit-btn[data-v-a71db493]{background:#67c23a;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer}.edit-btn[data-v-a71db493]:hover{background:#85ce61}.modal-overlay[data-v-a71db493]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;justify-content:center;align-items:center;z-index:1000}.modal-content[data-v-a71db493]{background:#fff;padding:24px;border-radius:8px;width:480px}.modal-content h3[data-v-a71db493]{margin:0 0 20px;font-size:16px}.form-group[data-v-a71db493]{margin-bottom:16px}.form-group label[data-v-a71db493]{display:block;margin-bottom:8px;font-weight:500}.form-group input[data-v-a71db493],.form-group textarea[data-v-a71db493],.form-select[data-v-a71db493]{width:100%;padding:8px;border:1px solid #dcdfe6;border-radius:4px;box-sizing:border-box}.form-group textarea[data-v-a71db493]{resize:vertical}.form-select[data-v-a71db493]{height:36px}.image-upload-area[data-v-a71db493]{display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px dashed #dcdfe6;border-radius:4px;background:#fafafa}.preview-image[data-v-a71db493]{max-width:200px;max-height:120px;object-fit:contain;border-radius:4px}.no-image[data-v-a71db493]{color:#c0c4cc;font-size:13px;padding:12px}.upload-btn[data-v-a71db493]{background:#409eff;color:#fff;border:none;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px}.upload-btn[data-v-a71db493]:hover{background:#66b1ff}.upload-hint[data-v-a71db493]{color:#909399;font-size:13px}.modal-footer[data-v-a71db493]{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}.cancel-btn[data-v-a71db493]{background:#f5f7fa;color:#606266;border:none;padding:8px 16px;border-radius:4px;cursor:pointer}.cancel-btn[data-v-a71db493]:hover{background:#e4e7ed}.submit-btn[data-v-a71db493]{background:#409eff;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer}.submit-btn[data-v-a71db493]:hover{background:#66b1ff}
|
||||||
1
frontend/dist/assets/SettingManage-DbOShH1j.js
vendored
Normal file
1
frontend/dist/assets/SettingManage-DbOShH1j.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{f as I,c as n,a as e,F as L,z as B,m as T,t as u,L as p,M as b,N as M,y as C,i as r,n as f,E as i,o,_ as R}from"./index-ChTrSGCb.js";const j={class:"setting-container"},z={class:"settings-table"},A={class:"value-cell"},H=["src"],Y={key:1},q={class:"actions"},G=["onClick"],J={class:"modal-content"},K={class:"form-group"},O={class:"form-group"},P={class:"form-group"},Q={key:0,class:"image-upload-area"},W=["src"],X={key:1,class:"no-image"},Z={key:2,class:"upload-hint"},ee={class:"form-group"},te={class:"modal-footer"},le=I({__name:"SettingManage",setup(ae){const D=s=>({string:"字符串",image:"图片",select:"下拉列表",boolean:"布尔值"})[s]||s,_=s=>{const t=new Date(s);if(isNaN(t.getTime()))return"-";const l=t.getFullYear(),c=String(t.getMonth()+1).padStart(2,"0"),y=String(t.getDate()).padStart(2,"0"),k=String(t.getHours()).padStart(2,"0"),F=String(t.getMinutes()).padStart(2,"0"),N=String(t.getSeconds()).padStart(2,"0");return`${l}-${c}-${y} ${k}:${F}:${N}`},h=r([]),v=r(!1),d=r(!1),g=r(!1),S=r(),a=r({id:0,key:"",value:"",type:"string",description:""}),w=async()=>{try{const{data:s}=await f.get("/settings/");h.value=s}catch{i.error("加载失败")}},$=()=>{d.value=!1,a.value={id:0,key:"",value:"",type:"string",description:""},v.value=!0},x=s=>{d.value=!0,a.value={id:s.id,key:s.key,value:s.value,type:s.type||"string",description:s.description},v.value=!0},m=()=>{v.value=!1,g.value=!1},V=()=>{var s;(s=S.value)==null||s.click()},U=async s=>{var c;const t=s.target,l=(c=t.files)==null?void 0:c[0];if(l){g.value=!0;try{const y=new FormData;y.append("file",l);const{data:k}=await f.post("/settings/upload",y);a.value.value=k.url,i.success("图片上传成功")}catch{i.error("图片上传失败")}finally{g.value=!1,t.value=""}}},E=async()=>{if(!a.value.key){i.warning("请输入设置键名");return}try{d.value?(await f.put(`/settings/${a.value.id}`,a.value),i.success("修改成功")):(await f.post("/settings/",a.value),i.success("添加成功")),m(),await w()}catch{i.error("操作失败")}};return w(),(s,t)=>(o(),n("div",j,[e("div",{class:"header"},[t[5]||(t[5]=e("h2",null,"系统设置管理",-1)),e("button",{class:"add-btn",onClick:$},"添加设置")]),e("table",z,[t[6]||(t[6]=e("thead",null,[e("tr",null,[e("th",null,"ID"),e("th",null,"设置键名"),e("th",null,"类型"),e("th",null,"设置值"),e("th",null,"描述"),e("th",null,"更新时间"),e("th",null,"创建时间"),e("th",null,"操作")])],-1)),e("tbody",null,[(o(!0),n(L,null,B(h.value,l=>(o(),n("tr",{key:l.id},[e("td",null,u(l.id),1),e("td",null,u(l.key),1),e("td",null,u(D(l.type)),1),e("td",A,[l.type==="image"&&l.value?(o(),n("img",{key:0,src:l.value,class:"cell-image"},null,8,H)):(o(),n("span",Y,u(l.value),1))]),e("td",null,u(l.description||"-"),1),e("td",null,u(_(l.updatetime)),1),e("td",null,u(_(l.createtime)),1),e("td",q,[e("button",{class:"edit-btn",onClick:c=>x(l)},"编辑",8,G)])]))),128))])]),v.value?(o(),n("div",{key:0,class:"modal-overlay",onClick:T(m,["self"])},[e("div",J,[e("h3",null,u(d.value?"编辑设置":"添加设置"),1),e("div",K,[t[7]||(t[7]=e("label",null,"设置键名 *",-1)),p(e("input",{"onUpdate:modelValue":t[0]||(t[0]=l=>a.value.key=l),type:"text",placeholder:"请输入设置键名"},null,512),[[b,a.value.key]])]),e("div",O,[t[9]||(t[9]=e("label",null,"类型",-1)),p(e("select",{"onUpdate:modelValue":t[1]||(t[1]=l=>a.value.type=l),class:"form-select"},[...t[8]||(t[8]=[e("option",{value:"string"},"字符串",-1),e("option",{value:"image"},"图片",-1),e("option",{value:"select"},"下拉列表",-1),e("option",{value:"boolean"},"布尔值",-1)])],512),[[M,a.value.type]])]),e("div",P,[t[11]||(t[11]=e("label",null,"设置值",-1)),a.value.type==="image"?(o(),n("div",Q,[a.value.value?(o(),n("img",{key:0,src:a.value.value,class:"preview-image"},null,8,W)):(o(),n("div",X,"暂无图片")),e("input",{ref_key:"fileInputRef",ref:S,type:"file",accept:"image/png,image/jpeg,image/gif,image/webp",style:{display:"none"},onChange:U},null,544),e("button",{class:"upload-btn",onClick:V},"选择图片"),g.value?(o(),n("span",Z,"上传中...")):C("",!0)])):a.value.type==="boolean"?p((o(),n("select",{key:1,"onUpdate:modelValue":t[2]||(t[2]=l=>a.value.value=l),class:"form-select"},[...t[10]||(t[10]=[e("option",{value:"true"},"是",-1),e("option",{value:"false"},"否",-1)])],512)),[[M,a.value.value]]):p((o(),n("textarea",{key:2,"onUpdate:modelValue":t[3]||(t[3]=l=>a.value.value=l),rows:"3",placeholder:"请输入设置值"},null,512)),[[b,a.value.value]])]),e("div",ee,[t[12]||(t[12]=e("label",null,"描述",-1)),p(e("textarea",{"onUpdate:modelValue":t[4]||(t[4]=l=>a.value.description=l),rows:"2",placeholder:"请输入描述"},null,512),[[b,a.value.description]])]),e("div",te,[e("button",{class:"cancel-btn",onClick:m},"取消"),e("button",{class:"submit-btn",onClick:E},u(d.value?"保存修改":"确认添加"),1)])])])):C("",!0)]))}}),ne=R(le,[["__scopeId","data-v-a71db493"]]);export{ne as default};
|
||||||
1
frontend/dist/assets/UserManage-DExq8OEE.js
vendored
Normal file
1
frontend/dist/assets/UserManage-DExq8OEE.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{G as n,O as c,n as i,E as o,f as d,H as p,g as f,q as h,w as g,r as u,d as y,I as w,B as x,o as _}from"./index-ChTrSGCb.js";import{t as k}from"./list-query-4ZZvVtjn.js";const Y=()=>({crudOptions:{pagination:{show:!1},search:{show:!0},searchFormOptions:{inline:!0,labelWidth:"80px",col:{span:0}},request:{transformQuery:({form:e})=>k(e,["username","nickname","email","phone"]),pageRequest:async e=>{const{data:s}=await i.post("/users/search",e);return s},transformRes:({res:e})=>{const s=Array.isArray(e)?e:[];return{currentPage:1,pageSize:Math.max(s.length,1),total:s.length,records:s}},onError:e=>{var t;let s="操作失败";if((t=e.response)!=null&&t.data){const a=e.response.data;if(a.errors&&Array.isArray(a.errors))s=a.errors.map(r=>r.message).join(";");else if(a.message)s=a.message;else if(a.detail){const r=a.detail;typeof r=="string"&&(r.includes("at least 6 characters")?s="密码长度至少为6个字符":s=r)}}else e.message&&(s=e.message);throw o.error({message:s,duration:5e3}),e},addRequest:async({form:e})=>{await i.post("/users/",e),o.success({message:"添加成功",duration:5e3})},editRequest:async({form:e,row:s})=>{if(!s)return;const t={...e};t.password||delete t.password,await i.put(`/users/${s.id}`,t),o.success({message:"修改成功",duration:5e3})},delRequest:async({row:e})=>{e&&(await i.delete(`/users/${e.id}`),o.success({message:"删除成功",duration:5e3}))},onSuccess:({action:e})=>{e==="add"?o.success({message:"添加成功",duration:5e3}):e==="edit"&&o.success({message:"修改成功",duration:5e3})}},table:{remove:{showSuccessNotification:!1}},rowHandle:{buttons:{edit:{text:"编辑"},remove:{text:"删除",show:!0}}},columns:{id:{title:"ID",key:"id",type:"number",column:{width:70},form:{show:!1}},username:{title:"登录名",key:"username",type:"text",search:{show:!0},form:{rules:[{required:!0,message:"请输入登录名",trigger:"blur"},{min:3,max:50,message:"用户名长度必须在3-50个字符之间",trigger:"blur"}]}},nickname:{title:"昵称",key:"nickname",type:"text",search:{show:!0},form:{rules:[{required:!0,message:"必填"}]}},password:{title:"密码",key:"password",type:"password",column:{show:!1},form:{show:!1},addForm:{show:!0,rules:[{required:!0,message:"请输入密码",trigger:"blur"},{min:6,max:50,message:"密码长度必须在6-50个字符之间",trigger:"blur"}]},editForm:{show:!0,component:{placeholder:"不修改请留空"},rules:[{min:6,max:50,message:"密码长度必须在6-50个字符之间",trigger:"blur"}]}},email:{title:"邮箱",key:"email",type:"text",search:{show:!0}},phone:{title:"手机",key:"phone",type:"text",search:{show:!0}},wx_openid:{title:"微信OpenID",key:"wx_openid",type:"text",column:{show:!1}},avatar:{title:"头像",key:"avatar",type:"text",column:{show:!1}},role_id:{title:"角色",key:"role_id",type:"dict-select",dict:c({getData:async()=>{const{data:e}=await i.post("/roles/search",{});return e},value:"id",label:"name"}),column:{width:120,dict:!0},form:{rules:[{required:!0,message:"必选"}],value:2}},createtime:{title:"创建时间",key:"createtime",type:"text",form:{show:!1},column:{width:170},valueBuilder({value:e,row:s,key:t}){!s||e==null||(s[t]=n(e).format("YYYY-MM-DD HH:mm:ss"))}},updatetime:{title:"更新时间",key:"updatetime",type:"text",form:{show:!1},column:{width:170},valueBuilder({value:e,row:s,key:t}){!s||e==null||(s[t]=n(e).format("YYYY-MM-DD HH:mm:ss"))}},lastlogintime:{title:"最后登录",key:"lastlogintime",type:"text",form:{show:!1},column:{width:170},valueBuilder({value:e,row:s,key:t}){!s||e==null||(s[t]=n(e).format("YYYY-MM-DD HH:mm:ss"))}}}}}),D=d({__name:"UserManage",setup(e){const{crudBinding:s,crudRef:t,crudExpose:a}=p({createCrudOptions:Y});return f(()=>{a.doRefresh()}),(r,b)=>{const l=u("fs-crud"),m=u("fs-page");return _(),h(m,null,{default:g(()=>[y(l,w({ref_key:"crudRef",ref:t},x(s)),null,16)]),_:1})}}});export{D as default};
|
||||||
105
frontend/dist/assets/index-64865240-CievhkpU.js
vendored
Normal file
105
frontend/dist/assets/index-64865240-CievhkpU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
123
frontend/dist/assets/index-ChTrSGCb.js
vendored
Normal file
123
frontend/dist/assets/index-ChTrSGCb.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-Cn04Rt7y.css
vendored
Normal file
1
frontend/dist/assets/index-Cn04Rt7y.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/list-query-4ZZvVtjn.js
vendored
Normal file
1
frontend/dist/assets/list-query-4ZZvVtjn.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
function c(o,i){const r={};for(const n of i){const t=o[n];t!=null&&String(t).trim()!==""&&(r[n]=String(t).trim())}return r}export{c as t};
|
||||||
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BookSystem</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-ChTrSGCb.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Cn04Rt7y.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="admin-root">
|
<el-container class="admin-root">
|
||||||
<el-aside width="220px" class="aside">
|
<el-aside width="220px" class="aside">
|
||||||
<div class="brand">{{sysTitle}}</div>
|
<div class="brand">
|
||||||
|
<img v-if="sysLogo" :src="sysLogo" class="brand-logo" />
|
||||||
|
<span class="brand-text">{{sysTitle}}</span>
|
||||||
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="active"
|
:default-active="active"
|
||||||
router
|
router
|
||||||
@ -77,6 +80,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const sysTitle = ref("");
|
const sysTitle = ref("");
|
||||||
|
const sysLogo = ref("");
|
||||||
const footerInfo = ref("");
|
const footerInfo = ref("");
|
||||||
const ipcContent = ref("");
|
const ipcContent = ref("");
|
||||||
|
|
||||||
@ -132,9 +136,22 @@ async function getipcContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getSysLogo() {
|
||||||
|
try {
|
||||||
|
const baseURL = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
let res = await axios.get(`${baseURL}/settings/public?key=系统Logo`)
|
||||||
|
if (res.data && res.data.value) {
|
||||||
|
sysLogo.value = res.data.value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logo加载失败不影响页面正常显示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await getSysTitle();
|
await getSysTitle();
|
||||||
|
await getSysLogo();
|
||||||
await getfooterInfo();
|
await getfooterInfo();
|
||||||
await getipcContent();
|
await getipcContent();
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
@ -163,10 +180,24 @@ async function onLogout() {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
padding: 18px 16px;
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>设置键名</th>
|
<th>设置键名</th>
|
||||||
|
<th>类型</th>
|
||||||
<th>设置值</th>
|
<th>设置值</th>
|
||||||
<th>描述</th>
|
<th>描述</th>
|
||||||
<th>更新时间</th>
|
<th>更新时间</th>
|
||||||
@ -21,13 +22,16 @@
|
|||||||
<tr v-for="item in settings" :key="item.id">
|
<tr v-for="item in settings" :key="item.id">
|
||||||
<td>{{ item.id }}</td>
|
<td>{{ item.id }}</td>
|
||||||
<td>{{ item.key }}</td>
|
<td>{{ item.key }}</td>
|
||||||
<td class="value-cell">{{ item.value }}</td>
|
<td>{{ typeLabel(item.type) }}</td>
|
||||||
|
<td class="value-cell">
|
||||||
|
<img v-if="item.type === 'image' && item.value" :src="item.value" class="cell-image" />
|
||||||
|
<span v-else>{{ item.value }}</span>
|
||||||
|
</td>
|
||||||
<td>{{ item.description || '-' }}</td>
|
<td>{{ item.description || '-' }}</td>
|
||||||
<td>{{ formatDateTime(item.updatetime) }}</td>
|
<td>{{ formatDateTime(item.updatetime) }}</td>
|
||||||
<td>{{ formatDateTime(item.createtime) }}</td>
|
<td>{{ formatDateTime(item.createtime) }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="edit-btn" @click="openEditModal(item)">编辑</button>
|
<button class="edit-btn" @click="openEditModal(item)">编辑</button>
|
||||||
<!-- <button class="delete-btn" @click="handleDelete(item)">删除</button> -->
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -43,9 +47,33 @@
|
|||||||
<input v-model="formData.key" type="text" placeholder="请输入设置键名" />
|
<input v-model="formData.key" type="text" placeholder="请输入设置键名" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>类型</label>
|
||||||
|
<select v-model="formData.type" class="form-select">
|
||||||
|
<option value="string">字符串</option>
|
||||||
|
<option value="image">图片</option>
|
||||||
|
<option value="select">下拉列表</option>
|
||||||
|
<option value="boolean">布尔值</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>设置值</label>
|
<label>设置值</label>
|
||||||
<textarea v-model="formData.value" rows="3" placeholder="请输入设置值"></textarea>
|
<!-- 图片类型:显示预览 + 上传按钮 -->
|
||||||
|
<div v-if="formData.type === 'image'" class="image-upload-area">
|
||||||
|
<img v-if="formData.value" :src="formData.value" class="preview-image" />
|
||||||
|
<div v-else class="no-image">暂无图片</div>
|
||||||
|
<input ref="fileInputRef" type="file" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none" @change="onFileChange" />
|
||||||
|
<button class="upload-btn" @click="triggerUpload">选择图片</button>
|
||||||
|
<span v-if="uploadLoading" class="upload-hint">上传中...</span>
|
||||||
|
</div>
|
||||||
|
<!-- 布尔类型:下拉选择 -->
|
||||||
|
<select v-else-if="formData.type === 'boolean'" v-model="formData.value" class="form-select">
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
|
<!-- 其他类型:文本输入 -->
|
||||||
|
<textarea v-else v-model="formData.value" rows="3" placeholder="请输入设置值"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -65,23 +93,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
// import { ElMessageBox } from "element-plus";
|
|
||||||
import { http } from "@/modules/admin/api/http";
|
import { http } from "@/modules/admin/api/http";
|
||||||
|
|
||||||
interface SettingItem {
|
interface SettingItem {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
createtime: string;
|
createtime: string;
|
||||||
updatetime: string;
|
updatetime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const typeLabel = (type: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
string: "字符串",
|
||||||
|
image: "图片",
|
||||||
|
select: "下拉列表",
|
||||||
|
boolean: "布尔值",
|
||||||
|
};
|
||||||
|
return map[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string): string => {
|
const formatDateTime = (dateStr: string): string => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) return "-";
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
@ -94,11 +130,14 @@ const formatDateTime = (dateStr: string): string => {
|
|||||||
const settings = ref<SettingItem[]>([]);
|
const settings = ref<SettingItem[]>([]);
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
const isEdit = ref(false);
|
const isEdit = ref(false);
|
||||||
|
const uploadLoading = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
id: 0,
|
id: 0,
|
||||||
key: "",
|
key: "",
|
||||||
value: "",
|
value: "",
|
||||||
|
type: "string",
|
||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,12 +152,7 @@ const loadData = async () => {
|
|||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
formData.value = {
|
formData.value = { id: 0, key: "", value: "", type: "string", description: "" };
|
||||||
id: 0,
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,6 +162,7 @@ const openEditModal = (item: SettingItem) => {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
key: item.key,
|
key: item.key,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
|
type: item.type || "string",
|
||||||
description: item.description,
|
description: item.description,
|
||||||
};
|
};
|
||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
@ -135,18 +170,32 @@ const openEditModal = (item: SettingItem) => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
showModal.value = false;
|
showModal.value = false;
|
||||||
|
uploadLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* const handleDelete = async (item: SettingItem) => {
|
const triggerUpload = () => {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileChange = async (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
uploadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("确定要删除吗?");
|
const form = new FormData();
|
||||||
await http.delete(`/settings/${item.id}`);
|
form.append("file", file);
|
||||||
ElMessage.success("删除成功");
|
const { data } = await http.post<{ url: string }>("/settings/upload", form);
|
||||||
await loadData();
|
formData.value.value = data.url;
|
||||||
} catch (error) {
|
ElMessage.success("图片上传成功");
|
||||||
ElMessage.error("删除失败");
|
} catch {
|
||||||
|
ElMessage.error("图片上传失败");
|
||||||
|
} finally {
|
||||||
|
uploadLoading.value = false;
|
||||||
|
target.value = "";
|
||||||
}
|
}
|
||||||
}; */
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.value.key) {
|
if (!formData.value.key) {
|
||||||
@ -226,6 +275,14 @@ loadData();
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -244,19 +301,6 @@ loadData();
|
|||||||
background: #85ce61;
|
background: #85ce61;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
|
||||||
background: #f56c6c;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-btn:hover {
|
|
||||||
background: #f78989;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -293,7 +337,8 @@ loadData();
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group textarea {
|
.form-group textarea,
|
||||||
|
.form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #dcdfe6;
|
border: 1px solid #dcdfe6;
|
||||||
@ -305,6 +350,53 @@ loadData();
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
background: #409eff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: #66b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
},
|
},
|
||||||
|
"/uploads": {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user