feat(settings):增加系统Logo系统设置项,并支持根据上传的图片显示系统logo

1. 系统设置中增加系统Logo设置项
2. 系统标题增加logo的显示,并可自动判断,是否上传logo,若上传则显示
3. 完善系统设置功能,支持上传图片,支持设置值为布尔值或列表
4. 增加上传和获取图片的接口
5. 完善nginx,支持图片的接口代理
This commit is contained in:
jayhgq 2026-06-19 23:17:13 +08:00
parent 81972f1328
commit 0545d68018
29 changed files with 991 additions and 51 deletions

4
.gitignore vendored
View File

@ -7,10 +7,6 @@
__pycache__/
*.egg-info/
# 前端构建产物
frontend/dist/
dist/
# Redis 数据
redis/data/
redis/logs/

View File

@ -94,9 +94,9 @@ async def get_public_setting(
key: str = None,
db: AsyncSession = Depends(get_db),
):
"""公开接口获取系统设置允许获取id为1/2或key为"系统标题"/"IPC内容"的内容)"""
ALLOWED_IDS = {1, 2, 3}
ALLOWED_KEYS = {"系统标题", "页脚信息", "IPC信息"}
"""公开接口获取系统设置允许获取id为1/2/3或key为"系统标题"/"页脚信息"/"IPC信息"/"系统Logo"的内容)"""
ALLOWED_IDS = {1, 2, 3, 4, 5, 6}
ALLOWED_KEYS = {"系统标题", "页脚信息", "IPC信息", "系统Logo"}
if setting_id is not None:
if setting_id not in ALLOWED_IDS:

View 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": "登出成功"}

View File

@ -389,6 +389,12 @@ async def init_system_settings(db: AsyncSession):
"value": "false",
"type": "boolean",
"description": "是否允许通过邮箱验证链接注册"
},
{
"key": "系统Logo",
"value": "",
"type": "image",
"description": "系统Logo图片"
}
]

View File

@ -1,10 +1,11 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.exceptions import RequestValidationError
from contextlib import asynccontextmanager
from apps.urls import api_router
from apps.settings.urls import UPLOAD_DIR
from database import engine, get_db
from models import Base
from init_data import init_all_data
@ -60,6 +61,15 @@ app.add_middleware(
swagger_ui_path = Path(__file__).parent.parent / "swagger-ui"
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页面
@app.get("/docs", include_in_schema=False)

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@ -18,6 +18,13 @@ server {
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 / {
try_files $uri $uri/ /index.html;
}

View 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}

View 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};

View 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};

View 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};

View 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)}

View 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};

View 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};

View 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};

View 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}

File diff suppressed because one or more lines are too long

View 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)}

View 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}

View 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};

View 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};

File diff suppressed because one or more lines are too long

123
frontend/dist/assets/index-ChTrSGCb.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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
View 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>

View File

@ -1,7 +1,10 @@
<template>
<el-container class="admin-root">
<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
:default-active="active"
router
@ -77,6 +80,7 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const sysTitle = ref("");
const sysLogo = ref("");
const footerInfo = 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 () => {
try {
await getSysTitle();
await getSysLogo();
await getfooterInfo();
await getipcContent();
if (!auth.user) {
@ -163,10 +180,24 @@ async function onLogout() {
color: #e2e8f0;
}
.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;
letter-spacing: 0.02em;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
line-height: 1.4;
}
.header {
display: flex;

View File

@ -10,6 +10,7 @@
<tr>
<th>ID</th>
<th>设置键名</th>
<th>类型</th>
<th>设置值</th>
<th>描述</th>
<th>更新时间</th>
@ -21,13 +22,16 @@
<tr v-for="item in settings" :key="item.id">
<td>{{ item.id }}</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>{{ formatDateTime(item.updatetime) }}</td>
<td>{{ formatDateTime(item.createtime) }}</td>
<td class="actions">
<button class="edit-btn" @click="openEditModal(item)">编辑</button>
<!-- <button class="delete-btn" @click="handleDelete(item)">删除</button> -->
</td>
</tr>
</tbody>
@ -43,9 +47,33 @@
<input v-model="formData.key" type="text" placeholder="请输入设置键名" />
</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">
<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 class="form-group">
@ -65,23 +93,31 @@
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
// import { ElMessageBox } from "element-plus";
import { http } from "@/modules/admin/api/http";
interface SettingItem {
id: number;
key: string;
value: string;
type: string;
description: string;
createtime: 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 date = new Date(dateStr);
if (isNaN(date.getTime())) {
return "-";
}
if (isNaN(date.getTime())) return "-";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).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 showModal = ref(false);
const isEdit = ref(false);
const uploadLoading = ref(false);
const fileInputRef = ref<HTMLInputElement>();
const formData = ref({
id: 0,
key: "",
value: "",
type: "string",
description: "",
});
@ -113,12 +152,7 @@ const loadData = async () => {
const openAddModal = () => {
isEdit.value = false;
formData.value = {
id: 0,
key: "",
value: "",
description: "",
};
formData.value = { id: 0, key: "", value: "", type: "string", description: "" };
showModal.value = true;
};
@ -128,6 +162,7 @@ const openEditModal = (item: SettingItem) => {
id: item.id,
key: item.key,
value: item.value,
type: item.type || "string",
description: item.description,
};
showModal.value = true;
@ -135,18 +170,32 @@ const openEditModal = (item: SettingItem) => {
const closeModal = () => {
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 {
await ElMessageBox.confirm("确定要删除吗?");
await http.delete(`/settings/${item.id}`);
ElMessage.success("删除成功");
await loadData();
} catch (error) {
ElMessage.error("删除失败");
const form = new FormData();
form.append("file", file);
const { data } = await http.post<{ url: string }>("/settings/upload", form);
formData.value.value = data.url;
ElMessage.success("图片上传成功");
} catch {
ElMessage.error("图片上传失败");
} finally {
uploadLoading.value = false;
target.value = "";
}
}; */
};
const handleSubmit = async () => {
if (!formData.value.key) {
@ -226,6 +275,14 @@ loadData();
white-space: nowrap;
}
.cell-image {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: 4px;
background: #f5f7fa;
}
.actions {
display: flex;
gap: 8px;
@ -244,19 +301,6 @@ loadData();
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 {
position: fixed;
top: 0;
@ -293,7 +337,8 @@ loadData();
}
.form-group input,
.form-group textarea {
.form-group textarea,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid #dcdfe6;
@ -305,6 +350,53 @@ loadData();
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 {
display: flex;
justify-content: flex-end;

View File

@ -21,6 +21,10 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
"/uploads": {
target: proxyTarget,
changeOrigin: true,
},
},
},
};