Template
feat: SMB文件服务器管理后台 - 完整版本
- 用户管理:增删改、批量导入(Excel模板)、admin保护 - 部门管理:增删改(含回收站恢复机制)、删除保护(有用户不可删) - 权限管理:部门内角色(admin/readwrite/readonly/none) - 监控页面:CPU/内存/磁盘实时监控 - 日志页面:操作日志查询 - SMB权限:用户文件夹700(member)/750(admin)、ACL继承 - admin跨目录访问:通过setfacl ACL实现
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# SMB File Server API
|
||||
@@ -0,0 +1,25 @@
|
||||
"""数据库初始化"""
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
engine = None
|
||||
async_session_factory = None
|
||||
|
||||
|
||||
async def init_db(db_path: str):
|
||||
global engine, async_session_factory
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
connect_str = f"sqlite+aiosqlite:///{db_path}"
|
||||
engine = create_async_engine(connect_str, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
from app.models import Department, User, UserDepartment, OperationLog, DiskUsageSnapshot
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session():
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>部门管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item active" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>👥 部门管理</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ 新建部门</button>
|
||||
<button class="btn btn-ghost" onclick="loadDepts()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>部门名称</th><th>文件夹路径</th><th>用户数</th><th>已用空间</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="deptTable"><tr><td colspan="7" class="table-empty">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建部门弹窗 -->
|
||||
<div id="createModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">新建部门</span><button class="modal-close" onclick="closeModal('createModal')">×</button></div>
|
||||
<div class="form-group"><label class="form-label">部门名称</label><input class="form-input" id="deptName" placeholder="如:销售部" maxlength="20"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('createModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="createDept()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
let depts=[];
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
depts = await api('/departments');
|
||||
render();
|
||||
}
|
||||
function render(){
|
||||
if(!depts.length){
|
||||
document.getElementById('deptTable').innerHTML='<tr><td colspan="7" class="table-empty">暂无部门,点击右上角新建</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('deptTable').innerHTML=depts.map(d=>`
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td><strong>${d.name}</strong></td>
|
||||
<td><code style="font-size:12px;color:var(--text3)">${d.folder_path}</code></td>
|
||||
<td>${d.user_count}</td>
|
||||
<td>${formatBytes(d.used_space)}</td>
|
||||
<td>${formatDate(d.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteDept(${d.id},'${d.name}')">删除</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
async function deleteDept(id,name){
|
||||
const pwd = prompt('删除部门需确认,请输入管理员密码:');
|
||||
if(!pwd) return;
|
||||
try{
|
||||
await api(`/departments/${id}`,{method:'DELETE',body:JSON.stringify({password:pwd})});
|
||||
toast('部门【' + name + '】已删除');
|
||||
loadDepts();
|
||||
}catch(e){
|
||||
toast(e.message || '删除失败:' + JSON.stringify(e),'error');
|
||||
}
|
||||
}
|
||||
async function createDept(){
|
||||
const name = document.getElementById('deptName').value.trim();
|
||||
if(!name){toast('请输入部门名称','error');return;}
|
||||
try{
|
||||
await api('/departments',{method:'POST',body:JSON.stringify({name})});
|
||||
toast('部门【' + name + '】创建成功,已添加到列表');
|
||||
closeModal('createModal');
|
||||
document.getElementById('deptName').value='';
|
||||
loadDepts();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
function openCreateModal(){document.getElementById('createModal').classList.add('active');}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('active');}
|
||||
async function loadDepts(){depts=await api('/departments');render();}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
"""部门管理路由"""
|
||||
import os
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Department, UserDepartment, OperationLog
|
||||
from app.schemas import DepartmentCreate, DepartmentUpdate, DepartmentOut
|
||||
from app.services.linux_service import create_department_group, create_department_folders
|
||||
from app.services.samba_service import generate_smb_conf, apply_smb_config
|
||||
|
||||
router = APIRouter()
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
async def log_operation(session: AsyncSession, username: str, action: str,
|
||||
target_type: str, target_name: str, details: dict, ip: str):
|
||||
log = OperationLog(
|
||||
operator_username=username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
details=json.dumps(details, ensure_ascii=False),
|
||||
ip_address=ip
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DepartmentOut])
|
||||
async def list_departments(
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.is_deleted == False)
|
||||
)
|
||||
departments = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for dept in departments:
|
||||
# 统计用户数
|
||||
count_result = await session.execute(
|
||||
select(func.count(UserDepartment.id))
|
||||
.join(Department, UserDepartment.department_id == Department.id)
|
||||
.where(Department.id == dept.id)
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
|
||||
# 计算磁盘使用
|
||||
dept_path = dept.folder_path
|
||||
used_space = 0
|
||||
if os.path.exists(dept_path):
|
||||
rc, stdout, _ = __import__("app.utils.helpers", fromlist=["run_cmd"]).run_cmd(
|
||||
["du", "-sb", dept_path], timeout=60
|
||||
)
|
||||
if rc == 0:
|
||||
used_space = int(stdout.split()[0])
|
||||
|
||||
out.append(DepartmentOut(
|
||||
id=dept.id,
|
||||
name=dept.name,
|
||||
folder_path=dept.folder_path,
|
||||
user_count=user_count,
|
||||
used_space=used_space,
|
||||
is_deleted=dept.is_deleted,
|
||||
created_at=dept.created_at
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{dept_id}", response_model=DepartmentOut)
|
||||
async def get_department(dept_id: int, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
count_result = await session.execute(
|
||||
select(func.count(UserDepartment.id))
|
||||
.join(Department, UserDepartment.department_id == Department.id)
|
||||
.where(Department.id == dept.id)
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
|
||||
return DepartmentOut(
|
||||
id=dept.id,
|
||||
name=dept.name,
|
||||
folder_path=dept.folder_path,
|
||||
user_count=user_count,
|
||||
used_space=0,
|
||||
is_deleted=dept.is_deleted,
|
||||
created_at=dept.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_department(
|
||||
body: DepartmentCreate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
# 检查是否已存在
|
||||
existing = await session.execute(
|
||||
select(Department).where(Department.name == body.name, Department.is_deleted == False)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="部门已存在")
|
||||
|
||||
folder_path = os.path.join(DATA_DIR, f"dept-{body.name}")
|
||||
|
||||
# 检查同名文件夹是否已存在(软删除后重建场景)
|
||||
if os.path.exists(folder_path):
|
||||
# 已有文件夹,直接关联
|
||||
pass
|
||||
else:
|
||||
# 创建 Linux 组
|
||||
group_name = f"group_{body.name}"
|
||||
create_department_group(group_name)
|
||||
# 创建部门文件夹
|
||||
create_department_folders(body.name)
|
||||
|
||||
dept = Department(
|
||||
name=body.name,
|
||||
folder_path=folder_path
|
||||
)
|
||||
session.add(dept)
|
||||
await session.commit()
|
||||
await session.refresh(dept)
|
||||
|
||||
# 更新 Samba 配置
|
||||
await _reload_samba(session)
|
||||
|
||||
await log_operation(session, current_user.username, "CREATE",
|
||||
"DEPARTMENT", body.name, {"folder": folder_path},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"id": dept.id, "name": dept.name, "folder_path": dept.folder_path}
|
||||
|
||||
|
||||
@router.put("/{dept_id}")
|
||||
async def update_department(
|
||||
dept_id: int,
|
||||
body: DepartmentUpdate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
old_name = dept.name
|
||||
dept.name = body.name
|
||||
await session.commit()
|
||||
|
||||
await log_operation(session, current_user.username, "UPDATE",
|
||||
"DEPARTMENT", body.name,
|
||||
{"old_name": old_name, "new_name": body.name},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"message": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/{dept_id}")
|
||||
async def delete_department(
|
||||
dept_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from app.utils.password_hash import verify_password
|
||||
if not verify_password(body.get("password", ""), current_user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="密码错误")
|
||||
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
# 检查部门是否有关联用户
|
||||
users_result = await session.execute(
|
||||
select(func.count(UserDepartment.id)).where(UserDepartment.department_id == dept_id)
|
||||
)
|
||||
user_count = users_result.scalar() or 0
|
||||
if user_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该部门还有 {user_count} 个用户,无法删除。请先删除或转移所有用户。")
|
||||
|
||||
dept.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
await _reload_samba(session)
|
||||
|
||||
await log_operation(session, current_user.username, "DELETE",
|
||||
"DEPARTMENT", dept.name, {},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"message": "Deleted"}
|
||||
|
||||
|
||||
async def _reload_samba(session: AsyncSession):
|
||||
"""重新生成并应用 Samba 配置"""
|
||||
from app.models import User
|
||||
depts_result = await session.execute(
|
||||
select(Department).where(Department.is_deleted == False)
|
||||
)
|
||||
depts = [{"id": d.id, "name": d.name, "folder_path": d.folder_path, "is_deleted": d.is_deleted}
|
||||
for d in depts_result.scalars().all()]
|
||||
users_result = await session.execute(
|
||||
select(User).where(User.is_deleted == False)
|
||||
)
|
||||
users = [{"id": u.id, "username": u.username} for u in users_result.scalars().all()]
|
||||
config = generate_smb_conf(depts, users)
|
||||
apply_smb_config(config)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""FastAPI 主应用入口"""
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
DB_DIR = os.environ.get("DB_DIR", "/data/db")
|
||||
DB_PATH = os.path.join(DB_DIR, "app.db")
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TEMPLATES_DIR = os.path.join(_BASE_DIR, "templates")
|
||||
STATIC_DIR = os.path.join(_BASE_DIR, "static")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
from app.database import init_db
|
||||
await init_db(DB_PATH)
|
||||
# 确保交换站目录存在
|
||||
from app.services.samba_service import ensure_exchange_folder
|
||||
ensure_exchange_folder()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SMB 文件服务器",
|
||||
description="基于 Docker Compose 的 SMB 共享文件服务器管理后台",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from app.routers import auth, departments, users, permissions, monitor, logs
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
app.include_router(departments.router, prefix="/api/departments", tags=["部门管理"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["用户管理"])
|
||||
app.include_router(permissions.router, prefix="/api/permissions", tags=["权限管理"])
|
||||
app.include_router(monitor.router, prefix="/api/monitor", tags=["监控"])
|
||||
app.include_router(logs.router, prefix="/api/logs", tags=["日志"])
|
||||
|
||||
# 静态文件(css/js 从 templates 目录暴露)
|
||||
CSS_DIR = os.path.join(TEMPLATES_DIR, "css")
|
||||
JS_DIR = os.path.join(TEMPLATES_DIR, "js")
|
||||
if os.path.exists(CSS_DIR):
|
||||
app.mount("/css", StaticFiles(directory=CSS_DIR), name="css")
|
||||
if os.path.exists(JS_DIR):
|
||||
app.mount("/js", StaticFiles(directory=JS_DIR), name="js")
|
||||
if os.path.exists(STATIC_DIR):
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
def read_html(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "SMB File Server"}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "login.html")))
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "dashboard.html")))
|
||||
|
||||
|
||||
@app.get("/departments", response_class=HTMLResponse)
|
||||
async def departments_page():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "departments.html")))
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "users.html")))
|
||||
|
||||
|
||||
@app.get("/permissions", response_class=HTMLResponse)
|
||||
async def permissions_page():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "permissions.html")))
|
||||
|
||||
|
||||
@app.get("/monitor", response_class=HTMLResponse)
|
||||
async def monitor_page():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "monitor.html")))
|
||||
|
||||
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page():
|
||||
return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "logs.html")))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""SQLAlchemy 数据模型"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
folder_path = Column(String(500), nullable=False)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
users = relationship("UserDepartment", back_populates="department")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(100), unique=True, nullable=False)
|
||||
display_name = Column(String(100), nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
departments = relationship("UserDepartment", back_populates="user")
|
||||
|
||||
|
||||
class UserDepartment(Base):
|
||||
__tablename__ = "user_departments"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
department_id = Column(Integer, ForeignKey("departments.id"), nullable=False)
|
||||
role_in_dept = Column(String(20), default="member")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="departments")
|
||||
department = relationship("Department", back_populates="users")
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
__tablename__ = "operation_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
operator_username = Column(String(100), nullable=True)
|
||||
action = Column(String(50), nullable=False)
|
||||
target_type = Column(String(50), nullable=False)
|
||||
target_name = Column(String(200), nullable=False)
|
||||
details = Column(Text, nullable=True)
|
||||
ip_address = Column(String(50), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class DiskUsageSnapshot(Base):
|
||||
__tablename__ = "disk_usage_snapshots"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
total_space = Column(Integer, nullable=False)
|
||||
used_space = Column(Integer, nullable=False)
|
||||
free_space = Column(Integer, nullable=False)
|
||||
usage_percent = Column(Float, nullable=False)
|
||||
dept_usages = Column(Text, nullable=True)
|
||||
snapshot_time = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""监控路由"""
|
||||
import json
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import DiskUsageSnapshot
|
||||
from app.models import Department
|
||||
from app.schemas import DiskUsageResponse
|
||||
from app.services.disk_monitor import get_disk_usage, get_dept_usages
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/disk", response_model=DiskUsageResponse)
|
||||
async def get_disk(
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
usage = get_disk_usage()
|
||||
dept_usages = get_dept_usages()
|
||||
|
||||
# 保存快照
|
||||
snapshot = DiskUsageSnapshot(
|
||||
total_space=usage["total_space"],
|
||||
used_space=usage["used_space"],
|
||||
free_space=usage["free_space"],
|
||||
usage_percent=usage["usage_percent"],
|
||||
dept_usages=json.dumps(dept_usages, ensure_ascii=False)
|
||||
)
|
||||
session.add(snapshot)
|
||||
await session.commit()
|
||||
|
||||
return DiskUsageResponse(
|
||||
total_space=usage["total_space"],
|
||||
used_space=usage["used_space"],
|
||||
free_space=usage["free_space"],
|
||||
usage_percent=usage["usage_percent"],
|
||||
dept_usages=dept_usages
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>权限管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item active" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>🔒 权限管理</h1></div>
|
||||
<div class="content">
|
||||
|
||||
<!-- 说明卡片 -->
|
||||
<div class="card" style="margin-bottom:16px;background:linear-gradient(135deg,rgba(0,200,150,0.08),rgba(0,150,255,0.08));border:1px solid rgba(0,200,150,0.2)">
|
||||
<div style="padding:4px 0">
|
||||
<div style="font-size:13px;color:var(--text2);line-height:1.8">
|
||||
<p style="margin:0 0 8px 0"><strong style="color:var(--accent)">🔒 权限说明</strong></p>
|
||||
<p style="margin:0 0 4px 0">本系统的「权限」指 <strong>SMB 文件共享权限</strong>。每个部门对应一个共享文件夹,部门内的用户可以访问该文件夹。</p>
|
||||
<p style="margin:0 0 4px 0">🔗 <strong>Windows 访问方式:</strong>在文件管理器地址栏输入 <code style="background:rgba(0,0,0,0.3);padding:1px 6px;border-radius:3px">\\192.168.110.8\部门名</code> ,然后输入账号密码登录。</p>
|
||||
<p style="margin:0">🔗 <strong>Mac 访问方式:</strong>在 Finder - 前往 - 连接服务器,输入 <code style="background:rgba(0,0,0,0.3);padding:1px 6px;border-radius:3px">smb://192.168.110.8/部门名</code> 。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-header">
|
||||
<div class="view-title">👥 部门 - 用户权限树</div>
|
||||
<button class="btn btn-ghost" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
|
||||
<div id="tree"><div class="loading"><div class="spinner"></div><div>加载中...</div></div></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
const [tree, depts] = await Promise.all([
|
||||
api('/permissions/tree'),
|
||||
api('/departments')
|
||||
]);
|
||||
render(tree, depts);
|
||||
}
|
||||
|
||||
function render(tree, depts){
|
||||
if(!tree.length){
|
||||
document.getElementById('tree').innerHTML='<div class="table-empty">暂无部门数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 建立部门ID→SMB路径映射
|
||||
const deptMap = {};
|
||||
depts.forEach(d => { deptMap[d.id] = d; });
|
||||
|
||||
document.getElementById('tree').innerHTML = tree.map(dept => {
|
||||
const smbPath = '\\\\192.168.110.8\\' + dept.department_name;
|
||||
const total = (dept.admins||[]).length + (dept.members||[]).length;
|
||||
return `
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="tree-node-header" style="padding:12px 16px;border-bottom:1px solid rgba(255,255,255,0.06)">
|
||||
<span class="tree-node-title">👥 <strong>${dept.department_name}</strong></span>
|
||||
<span class="badge badge-info">${total} 人</span>
|
||||
<span class="badge" style="background:rgba(0,200,150,0.15);color:#00c896;font-size:11px;margin-left:4px">
|
||||
🔗 SMB: ${smbPath}
|
||||
</span>
|
||||
</div>
|
||||
<div style="padding:10px 16px">
|
||||
${(dept.admins||[]).length > 0 ? `
|
||||
<div style="margin-bottom:8px">
|
||||
<span style="font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:0.5px">🔚 部门管理员</span>
|
||||
<div style="margin-top:4px">${dept.admins.map(u =>
|
||||
'<span class="tree-user"><strong>' + u.display_name + '</strong> <span class="badge badge-success" style="font-size:10px">admin</span></span>'
|
||||
).join('')}</div>
|
||||
</div>` : ''}
|
||||
${(dept.members||[]).length > 0 ? `
|
||||
<div>
|
||||
<span style="font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:0.5px">👤 部门成员</span>
|
||||
<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:6px">${dept.members.map(u =>
|
||||
'<span class="tree-user">👤 ' + u.display_name + ' <span style="font-size:10px;color:var(--text3)">(' + u.username + ')</span></span>'
|
||||
).join('')}</div>
|
||||
</div>` : ''}
|
||||
${total === 0 ? '<div style="color:var(--text3);font-size:13px">暂无成员</div>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
"""权限管理路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Department, User, UserDepartment
|
||||
from app.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_permission_tree(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department)
|
||||
.where(Department.is_deleted == False)
|
||||
.options(selectinload(Department.users).selectinload(UserDepartment.user))
|
||||
)
|
||||
departments = result.scalars().all()
|
||||
|
||||
tree = []
|
||||
for dept in departments:
|
||||
admins = []
|
||||
members = []
|
||||
for ud in dept.users:
|
||||
if ud.user and not ud.user.is_deleted:
|
||||
info = {
|
||||
"user_id": ud.user.id,
|
||||
"username": ud.user.username,
|
||||
"display_name": ud.user.display_name,
|
||||
"role_in_dept": ud.role_in_dept
|
||||
}
|
||||
if ud.role_in_dept == "admin":
|
||||
admins.append(info)
|
||||
else:
|
||||
members.append(info)
|
||||
|
||||
tree.append({
|
||||
"department_id": dept.id,
|
||||
"department_name": dept.name,
|
||||
"admins": admins,
|
||||
"members": members
|
||||
})
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
@router.put("/{user_id}/{dept_id}")
|
||||
async def update_user_dept_role(
|
||||
user_id: int,
|
||||
dept_id: int,
|
||||
body: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
new_role = body.get("role_in_dept")
|
||||
if new_role not in ("admin", "member"):
|
||||
raise HTTPException(status_code=400, detail="角色必须是 admin 或 member")
|
||||
|
||||
result = await session.execute(
|
||||
select(UserDepartment).where(
|
||||
UserDepartment.user_id == user_id,
|
||||
UserDepartment.department_id == dept_id
|
||||
)
|
||||
)
|
||||
ud = result.scalar_one_or_none()
|
||||
if not ud:
|
||||
raise HTTPException(status_code=404, detail="用户-部门关联不存在")
|
||||
|
||||
ud.role_in_dept = new_role
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Role updated"}
|
||||
@@ -0,0 +1 @@
|
||||
# routers __init__.py
|
||||
@@ -0,0 +1,92 @@
|
||||
"""认证路由"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import User
|
||||
from app.schemas import LoginRequest, LoginResponse
|
||||
from app.utils.password_hash import verify_password, hash_password
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
SECRET_KEY = "smb-file-server-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 1
|
||||
|
||||
|
||||
def create_token(username: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
payload = {"sub": username, "exp": expire}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == username, User.is_deleted == False)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == body.username, User.is_deleted == False)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 首次登录:检查是否有管理员,没有则创建默认管理员
|
||||
if not user and body.username == "admin":
|
||||
from app.services.samba_service import ensure_exchange_folder
|
||||
ensure_exchange_folder()
|
||||
from app.services.linux_service import create_linux_user
|
||||
create_linux_user("admin", body.password)
|
||||
password_hash = hash_password(body.password)
|
||||
user = User(
|
||||
username="admin",
|
||||
display_name="系统管理员",
|
||||
password_hash=password_hash,
|
||||
is_deleted=False
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = create_token(user.username)
|
||||
return LoginResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"display_name": current_user.display_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: User = Depends(get_current_user)):
|
||||
return {"message": "Logged out"}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""部门管理路由"""
|
||||
import os
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Department, UserDepartment, OperationLog
|
||||
from app.schemas import DepartmentCreate, DepartmentUpdate, DepartmentOut
|
||||
from app.services.linux_service import create_department_group, create_department_folders
|
||||
from app.services.samba_service import generate_smb_conf, apply_smb_config
|
||||
|
||||
router = APIRouter()
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
async def log_operation(session: AsyncSession, username: str, action: str,
|
||||
target_type: str, target_name: str, details: dict, ip: str):
|
||||
log = OperationLog(
|
||||
operator_username=username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_name=target_name,
|
||||
details=json.dumps(details, ensure_ascii=False),
|
||||
ip_address=ip
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DepartmentOut])
|
||||
async def list_departments(
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.is_deleted == False)
|
||||
)
|
||||
departments = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for dept in departments:
|
||||
# 统计用户数
|
||||
count_result = await session.execute(
|
||||
select(func.count(UserDepartment.id))
|
||||
.join(Department, UserDepartment.department_id == Department.id)
|
||||
.where(Department.id == dept.id)
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
|
||||
# 计算磁盘使用
|
||||
dept_path = dept.folder_path
|
||||
used_space = 0
|
||||
if os.path.exists(dept_path):
|
||||
rc, stdout, _ = __import__("app.utils.helpers", fromlist=["run_cmd"]).run_cmd(
|
||||
["du", "-sb", dept_path], timeout=60
|
||||
)
|
||||
if rc == 0:
|
||||
used_space = int(stdout.split()[0])
|
||||
|
||||
out.append(DepartmentOut(
|
||||
id=dept.id,
|
||||
name=dept.name,
|
||||
folder_path=dept.folder_path,
|
||||
user_count=user_count,
|
||||
used_space=used_space,
|
||||
is_deleted=dept.is_deleted,
|
||||
created_at=dept.created_at
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{dept_id}", response_model=DepartmentOut)
|
||||
async def get_department(dept_id: int, session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
count_result = await session.execute(
|
||||
select(func.count(UserDepartment.id))
|
||||
.join(Department, UserDepartment.department_id == Department.id)
|
||||
.where(Department.id == dept.id)
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
|
||||
return DepartmentOut(
|
||||
id=dept.id,
|
||||
name=dept.name,
|
||||
folder_path=dept.folder_path,
|
||||
user_count=user_count,
|
||||
used_space=0,
|
||||
is_deleted=dept.is_deleted,
|
||||
created_at=dept.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_department(
|
||||
body: DepartmentCreate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
# 检查是否已存在(未删除)
|
||||
existing_active = await session.execute(
|
||||
select(Department).where(Department.name == body.name, Department.is_deleted == False)
|
||||
)
|
||||
if existing_active.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="部门已存在")
|
||||
|
||||
# 检查是否软删除后重建
|
||||
existing_deleted = await session.execute(
|
||||
select(Department).where(Department.name == body.name, Department.is_deleted == True)
|
||||
)
|
||||
old_dept = existing_deleted.scalar_one_or_none()
|
||||
|
||||
folder_path = os.path.join(DATA_DIR, f"dept-{body.name}")
|
||||
|
||||
if old_dept:
|
||||
# 软删除后重建:恢复记录
|
||||
old_dept.is_deleted = False
|
||||
old_dept.folder_path = folder_path
|
||||
dept = old_dept
|
||||
else:
|
||||
# 新建部门
|
||||
if os.path.exists(folder_path):
|
||||
pass # 已有文件夹
|
||||
else:
|
||||
group_name = f"group_{body.name}"
|
||||
create_department_group(group_name)
|
||||
create_department_folders(body.name)
|
||||
dept = Department(
|
||||
name=body.name,
|
||||
folder_path=folder_path
|
||||
)
|
||||
session.add(dept)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(dept)
|
||||
|
||||
# 更新 Samba 配置
|
||||
await _reload_samba(session)
|
||||
|
||||
await log_operation(session, current_user.username, "CREATE",
|
||||
"DEPARTMENT", body.name, {"folder": folder_path},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"id": dept.id, "name": dept.name, "folder_path": dept.folder_path}
|
||||
|
||||
|
||||
@router.put("/{dept_id}")
|
||||
async def update_department(
|
||||
dept_id: int,
|
||||
body: DepartmentUpdate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
old_name = dept.name
|
||||
dept.name = body.name
|
||||
await session.commit()
|
||||
|
||||
await log_operation(session, current_user.username, "UPDATE",
|
||||
"DEPARTMENT", body.name,
|
||||
{"old_name": old_name, "new_name": body.name},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"message": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/{dept_id}")
|
||||
async def delete_department(
|
||||
dept_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from app.utils.password_hash import verify_password
|
||||
if not verify_password(body.get("password", ""), current_user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="密码错误")
|
||||
|
||||
result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id)
|
||||
)
|
||||
dept = result.scalar_one_or_none()
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
# 检查部门是否有关联用户
|
||||
users_result = await session.execute(
|
||||
select(func.count(UserDepartment.id)).where(UserDepartment.department_id == dept_id)
|
||||
)
|
||||
user_count = users_result.scalar() or 0
|
||||
if user_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该部门还有 {user_count} 个用户,无法删除。请先删除或转移所有用户。")
|
||||
|
||||
dept.is_deleted = True
|
||||
await session.commit()
|
||||
|
||||
await _reload_samba(session)
|
||||
|
||||
await log_operation(session, current_user.username, "DELETE",
|
||||
"DEPARTMENT", dept.name, {},
|
||||
request.client.host if request.client else "unknown")
|
||||
|
||||
return {"message": "Deleted"}
|
||||
|
||||
|
||||
async def _reload_samba(session: AsyncSession):
|
||||
"""重新生成并应用 Samba 配置"""
|
||||
from app.models import User
|
||||
depts_result = await session.execute(
|
||||
select(Department).where(Department.is_deleted == False)
|
||||
)
|
||||
depts = [{"id": d.id, "name": d.name, "folder_path": d.folder_path, "is_deleted": d.is_deleted}
|
||||
for d in depts_result.scalars().all()]
|
||||
users_result = await session.execute(
|
||||
select(User).where(User.is_deleted == False)
|
||||
)
|
||||
users = [{"id": u.id, "username": u.username} for u in users_result.scalars().all()]
|
||||
config = generate_smb_conf(depts, users)
|
||||
apply_smb_config(config)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""日志路由"""
|
||||
import csv
|
||||
import io
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import OperationLog
|
||||
from app.schemas import LogOut
|
||||
from app.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[LogOut])
|
||||
async def list_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
action: str = None,
|
||||
target_type: str = None,
|
||||
current_user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
query = select(OperationLog).order_by(OperationLog.created_at.desc())
|
||||
|
||||
if action:
|
||||
query = query.where(OperationLog.action == action)
|
||||
if target_type:
|
||||
query = query.where(OperationLog.target_type == target_type)
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await session.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
LogOut(
|
||||
id=log.id,
|
||||
operator_name=log.operator_username or "system",
|
||||
target_type=log.target_type,
|
||||
target_name=log.target_name,
|
||||
action=log.action,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
created_at=log.created_at
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_logs(
|
||||
current_user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(OperationLog).order_by(OperationLog.created_at.desc()).limit(10000)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["时间", "操作者", "操作类型", "对象类型", "对象名称", "详情", "IP地址"])
|
||||
for log in logs:
|
||||
writer.writerow([
|
||||
log.created_at.isoformat() if log.created_at else "",
|
||||
log.operator_username or "system",
|
||||
log.action,
|
||||
log.target_type,
|
||||
log.target_name,
|
||||
log.details or "",
|
||||
log.ip_address or ""
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=logs.csv"}
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""监控路由"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import DiskUsageSnapshot
|
||||
from app.models import Department
|
||||
from app.schemas import DiskUsageResponse
|
||||
from app.services.disk_monitor import get_disk_usage, get_dept_usages
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/disk", response_model=DiskUsageResponse)
|
||||
async def get_disk(
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
usage = get_disk_usage()
|
||||
dept_usages = get_dept_usages()
|
||||
|
||||
# 保存快照
|
||||
snapshot = DiskUsageSnapshot(
|
||||
total_space=usage["total_space"],
|
||||
used_space=usage["used_space"],
|
||||
free_space=usage["free_space"],
|
||||
usage_percent=usage["usage_percent"],
|
||||
dept_usages=str(dept_usages)
|
||||
)
|
||||
session.add(snapshot)
|
||||
await session.commit()
|
||||
|
||||
return DiskUsageResponse(
|
||||
total_space=usage["total_space"],
|
||||
used_space=usage["used_space"],
|
||||
free_space=usage["free_space"],
|
||||
usage_percent=usage["usage_percent"],
|
||||
dept_usages=dept_usages
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""权限管理路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Department, User, UserDepartment
|
||||
from app.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_permission_tree(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Department)
|
||||
.where(Department.is_deleted == False)
|
||||
.options(selectinload(Department.users).selectinload(UserDepartment.user))
|
||||
)
|
||||
departments = result.scalars().all()
|
||||
|
||||
tree = []
|
||||
for dept in departments:
|
||||
admins = []
|
||||
members = []
|
||||
for ud in dept.users:
|
||||
if ud.user and not ud.user.is_deleted:
|
||||
info = {
|
||||
"user_id": ud.user.id,
|
||||
"username": ud.user.username,
|
||||
"display_name": ud.user.display_name,
|
||||
"role_in_dept": ud.role_in_dept
|
||||
}
|
||||
if ud.role_in_dept == "admin":
|
||||
admins.append(info)
|
||||
else:
|
||||
members.append(info)
|
||||
|
||||
tree.append({
|
||||
"department_id": dept.id,
|
||||
"department_name": dept.name,
|
||||
"admins": admins,
|
||||
"members": members
|
||||
})
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
@router.put("/{user_id}/{dept_id}")
|
||||
async def update_user_dept_role(
|
||||
user_id: int,
|
||||
dept_id: int,
|
||||
body: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
new_role = body.get("role_in_dept")
|
||||
if new_role not in ("admin", "member"):
|
||||
raise HTTPException(status_code=400, detail="角色必须是 admin 或 member")
|
||||
|
||||
result = await session.execute(
|
||||
select(UserDepartment).where(
|
||||
UserDepartment.user_id == user_id,
|
||||
UserDepartment.department_id == dept_id
|
||||
)
|
||||
)
|
||||
ud = result.scalar_one_or_none()
|
||||
if not ud:
|
||||
raise HTTPException(status_code=404, detail="用户-部门关联不存在")
|
||||
|
||||
ud.role_in_dept = new_role
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Role updated"}
|
||||
@@ -0,0 +1,464 @@
|
||||
"""用户管理路由"""
|
||||
import os
|
||||
import json
|
||||
import openpyxl
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import User, Department, UserDepartment, OperationLog
|
||||
from app.schemas import UserCreate, UserUpdate, UserOut, ImportResult
|
||||
from app.utils.pinyin import cn_to_pinyin
|
||||
from app.utils.password_hash import hash_password, verify_password
|
||||
from app.services.linux_service import create_linux_user, delete_linux_user, add_user_to_group
|
||||
from app.services.samba_service import add_samba_user, delete_samba_user
|
||||
from app.routers.departments import _reload_samba
|
||||
|
||||
router = APIRouter()
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def generate_username(display_name: str) -> str:
|
||||
"""生成全拼用户名(查重)"""
|
||||
base = cn_to_pinyin(display_name)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
dept_id: int = None,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from sqlalchemy.orm import selectinload
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.is_deleted == False)
|
||||
.options(selectinload(User.departments).selectinload(UserDepartment.department))
|
||||
)
|
||||
if dept_id:
|
||||
query = query.join(UserDepartment).where(UserDepartment.department_id == dept_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for user in users:
|
||||
depts = []
|
||||
for ud in user.departments:
|
||||
depts.append({
|
||||
"department_id": ud.department_id,
|
||||
"department_name": ud.department.name if ud.department else "",
|
||||
"role_in_dept": ud.role_in_dept
|
||||
})
|
||||
out.append(UserOut(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
is_deleted=user.is_deleted,
|
||||
departments=depts,
|
||||
created_at=user.created_at
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
username = generate_username(body.display_name)
|
||||
|
||||
# 查重:如果用户名已存在,追加数字后缀
|
||||
counter = 1
|
||||
original_username = username
|
||||
while True:
|
||||
existing = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
break
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# 检查所有部门都存在
|
||||
for dept_info in body.departments:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_info["department_id"])
|
||||
)
|
||||
if not dept_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"部门 {dept_info['department_id']} 不存在")
|
||||
|
||||
# 创建 Linux 系统用户
|
||||
linux_result = create_linux_user(username, body.password)
|
||||
if not linux_result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=f"创建系统用户失败: {linux_result.get('error')}")
|
||||
|
||||
# 创建 SMB 用户
|
||||
if not add_samba_user(username, body.password):
|
||||
# Samba用户失败不影响主流程
|
||||
pass
|
||||
|
||||
# 创建数据库用户
|
||||
password_hash = hash_password(body.password)
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=body.display_name,
|
||||
password_hash=password_hash
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
# 创建用户-部门关联和文件夹
|
||||
for dept_info in body.departments:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_info["department_id"])
|
||||
)
|
||||
dept = dept_result.scalar_one()
|
||||
|
||||
role = dept_info.get("role_in_dept", "member")
|
||||
|
||||
# 加入部门关联
|
||||
ud = UserDepartment(
|
||||
user_id=user.id,
|
||||
department_id=dept.id,
|
||||
role_in_dept=role
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
# 创建个人文件夹
|
||||
folder_path = os.path.join(DATA_DIR, f"dept-{dept.name}", username)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
# 设置权限:用户加入部门组,组可访问父目录
|
||||
try:
|
||||
import pwd, grp, subprocess
|
||||
uid = pwd.getpwnam(username).pw_uid
|
||||
# 部门组名用拼音(Linux不支持中文组名)
|
||||
group_name = f"group_{cn_to_pinyin(dept.name)}"
|
||||
try:
|
||||
grp.getgrnam(group_name)
|
||||
except KeyError:
|
||||
subprocess.run(["groupadd", "-f", group_name], check=False, capture_output=True)
|
||||
subprocess.run(["usermod", "-aG", group_name, username], check=False, capture_output=True)
|
||||
gid = grp.getgrnam(group_name).gr_gid
|
||||
# 部门父目录设 SGID
|
||||
dept_path = os.path.dirname(folder_path)
|
||||
os.chown(dept_path, -1, gid)
|
||||
os.chmod(dept_path, 0o2770) # 部门共享,所有成员可读写
|
||||
# 个人文件夹:member=700(仅本人),admin=750(部门admin可通过ACL访问)
|
||||
os.chown(folder_path, uid, gid)
|
||||
if role == "admin":
|
||||
os.chmod(folder_path, 0o750)
|
||||
# admin 需要能访问所有成员的文件夹,加 ACL
|
||||
import subprocess as sub
|
||||
sub.run(["setfacl", "-m", f"u:{username}:rwx", dept_path], check=False)
|
||||
# 遍历同部门其他用户的文件夹加 ACL
|
||||
for other_user_dir in os.listdir(dept_path):
|
||||
other_path = os.path.join(dept_path, other_user_dir)
|
||||
if os.path.isdir(other_path) and other_user_dir != username:
|
||||
sub.run(["setfacl", "-m", f"u:{username}:rwx", other_path], check=False)
|
||||
else:
|
||||
os.chmod(folder_path, 0o700)
|
||||
except Exception as ex:
|
||||
os.chmod(folder_path, 0o700)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# 更新 Samba 配置
|
||||
await _reload_samba(session)
|
||||
|
||||
# 记录日志
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="CREATE",
|
||||
target_type="USER",
|
||||
target_name=body.display_name,
|
||||
details=json.dumps({"username": username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if body.display_name:
|
||||
user.display_name = body.display_name
|
||||
|
||||
# 更新部门及角色
|
||||
if body.departments is not None:
|
||||
# 删除旧关联
|
||||
old_uds = await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
for ud in old_uds.scalars().all():
|
||||
await session.delete(ud)
|
||||
# 重建关联
|
||||
for dept_info in body.departments:
|
||||
dept_id = dept_info.get("department_id")
|
||||
role = dept_info.get("role_in_dept", "member")
|
||||
if dept_id:
|
||||
from app.models import Department
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id, Department.is_deleted == False)
|
||||
)
|
||||
dept = dept_result.scalar_one_or_none()
|
||||
if dept:
|
||||
ud = UserDepartment(
|
||||
user_id=user_id,
|
||||
department_id=dept_id,
|
||||
role_in_dept=role
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
# 如果是 admin 角色,给该用户加 ACL 读写权限
|
||||
if role == "admin":
|
||||
import os, subprocess as sub
|
||||
dept_folder = f"/data/smb/dept-{dept.name}"
|
||||
try:
|
||||
# 部门共享文件夹本身也要可读写
|
||||
sub.run(["setfacl", "-m", f"u:{user.username}:rwx", dept_folder], check=False)
|
||||
# 同部门其他人的个人文件夹也要可读写
|
||||
for other_dir in os.listdir(dept_folder):
|
||||
other_path = os.path.join(dept_folder, other_dir)
|
||||
if os.path.isdir(other_path) and other_dir != user.username:
|
||||
sub.run(["setfacl", "-m", f"u:{user.username}:rwx", other_path], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
# 禁止删除 admin 用户
|
||||
if user_id == 1:
|
||||
raise HTTPException(status_code=403, detail="禁止删除系统管理员")
|
||||
|
||||
if not verify_password(body.get("password", ""), current_user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="密码错误")
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 软删除
|
||||
user.is_deleted = True
|
||||
|
||||
# 从 Linux 系统删除用户
|
||||
delete_linux_user(user.username)
|
||||
|
||||
# 从 Samba 删除用户
|
||||
delete_samba_user(user.username)
|
||||
|
||||
# 删除所有部门关联
|
||||
await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
ud_result = await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
for ud in ud_result.scalars().all():
|
||||
await session.delete(ud)
|
||||
|
||||
await session.commit()
|
||||
await _reload_samba(session)
|
||||
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="DELETE",
|
||||
target_type="USER",
|
||||
target_name=user.display_name,
|
||||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Deleted"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
new_password = body.get("new_password", "")
|
||||
if len(new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="密码至少6位")
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
await session.commit()
|
||||
|
||||
# 更新 Samba 密码
|
||||
add_samba_user(user.username, new_password)
|
||||
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="RESET_PASSWORD",
|
||||
target_type="USER",
|
||||
target_name=user.display_name,
|
||||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Password reset"}
|
||||
|
||||
|
||||
@router.get("/import-template")
|
||||
async def download_import_template():
|
||||
"""下载用户批量导入模板(Excel .xlsx)"""
|
||||
import io
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "用户导入"
|
||||
# 表头
|
||||
headers = ["姓名*", "密码*", "部门1", "部门2", "部门3", "部门1角色", "部门2角色", "部门3角色"]
|
||||
ws.append(headers)
|
||||
# 示例行
|
||||
ws.append(["张三", "123456", "销售部", "", "", "member", "", ""])
|
||||
ws.append(["李四", "123456", "技术部", "", "", "admin", "", ""])
|
||||
# 设置列宽
|
||||
ws.column_dimensions["A"].width = 12
|
||||
ws.column_dimensions["B"].width = 10
|
||||
ws.column_dimensions["C"].width = 10
|
||||
ws.column_dimensions["D"].width = 10
|
||||
ws.column_dimensions["E"].width = 10
|
||||
ws.column_dimensions["F"].width = 10
|
||||
ws.column_dimensions["G"].width = 10
|
||||
ws.column_dimensions["H"].width = 10
|
||||
# 加粗表头
|
||||
for cell in ws[1]:
|
||||
cell.font = openpyxl.styles.Font(bold=True)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=smb_user_import_template.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_users(
|
||||
file: UploadFile = File(...),
|
||||
request: Request = None,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
contents = await file.read()
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
failed_rows = []
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(contents)
|
||||
ws = wb.active
|
||||
|
||||
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
||||
display_name, password = row[0], row[1]
|
||||
if not display_name or not password:
|
||||
failed_rows.append({"row": i, "error": "缺少姓名或密码"})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
username = generate_username(str(display_name))
|
||||
counter = 1
|
||||
original_username = username
|
||||
while True:
|
||||
existing = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
break
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=str(display_name),
|
||||
password_hash=hash_password(str(password))
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
# 处理部门
|
||||
for col_idx, role_field in [(2, "role1"), (3, "role2"), (4, "role3")]:
|
||||
dept_name = row[col_idx] if len(row) > col_idx else None
|
||||
role = row[col_idx + 3] if len(row) > col_idx + 3 else "member"
|
||||
|
||||
if dept_name:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.name == str(dept_name).strip())
|
||||
)
|
||||
dept = dept_result.scalar_one_or_none()
|
||||
if dept:
|
||||
ud = UserDepartment(
|
||||
user_id=user.id,
|
||||
department_id=dept.id,
|
||||
role_in_dept=role or "member"
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
# 创建 Linux 和 Samba 用户
|
||||
create_linux_user(username, str(password))
|
||||
add_samba_user(username, str(password))
|
||||
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
failed_rows.append({"row": i, "error": str(e)})
|
||||
fail_count += 1
|
||||
|
||||
await session.commit()
|
||||
await _reload_samba(session)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Excel解析失败: {str(e)}")
|
||||
|
||||
return ImportResult(
|
||||
success_count=success_count,
|
||||
fail_count=fail_count,
|
||||
failed_rows=failed_rows
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Samba 配置生成服务"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.utils.helpers import run_cmd
|
||||
|
||||
|
||||
SMB_CONF_PATH = "/etc/samba/smb.conf"
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def generate_smb_conf(departments: list, users: list) -> str:
|
||||
"""生成 Samba 配置文件内容"""
|
||||
lines = [
|
||||
"[global]",
|
||||
" workgroup = WORKGROUP",
|
||||
" server string = SMB File Server",
|
||||
" security = user",
|
||||
" map to guest = bad user",
|
||||
" unix charset = UTF-8",
|
||||
" dos charset = cp936",
|
||||
" disable spoolss = yes",
|
||||
"",
|
||||
"# 共享交换站",
|
||||
"[exchange]",
|
||||
" path = /data/smb/exchange",
|
||||
" browseable = yes",
|
||||
" writable = yes",
|
||||
" guest ok = yes",
|
||||
" create mask = 0666",
|
||||
" directory mask = 0777",
|
||||
"",
|
||||
]
|
||||
|
||||
# 按部门生成配置
|
||||
for dept in departments:
|
||||
if dept.get("is_deleted"):
|
||||
continue
|
||||
dept_name = dept["name"]
|
||||
# 安全的共享名:去除中文,只保留字母数字下划线
|
||||
share_name = f"dept_{dept_name}"
|
||||
path = dept.get("folder_path", f"/data/smb/dept-{dept_name}")
|
||||
|
||||
lines.append(f"# 部门:{dept_name}")
|
||||
lines.append(f"[{share_name}]")
|
||||
lines.append(f" path = {path}")
|
||||
lines.append(f" browseable = yes")
|
||||
lines.append(f" writable = yes")
|
||||
lines.append(f" create mask = 0750")
|
||||
lines.append(f" directory mask = 0750")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_smb_config(config_content: str) -> dict:
|
||||
"""写入并应用 Samba 配置"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(SMB_CONF_PATH), exist_ok=True)
|
||||
with open(SMB_CONF_PATH, "w") as f:
|
||||
f.write(config_content)
|
||||
|
||||
# 验证语法
|
||||
rc, stdout, stderr = run_cmd(["testparm", "-s"])
|
||||
if rc != 0:
|
||||
return {"success": False, "error": f"Config invalid: {stderr}"}
|
||||
|
||||
# 重载配置
|
||||
run_cmd(["smbcontrol", "all", "reload-config"], timeout=5)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def add_samba_user(username: str, password: str) -> bool:
|
||||
"""添加 Samba 用户"""
|
||||
proc = subprocess.run(
|
||||
["smbpasswd", "-s", "-a", username],
|
||||
input=f"{password}\n{password}\n",
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def delete_samba_user(username: str) -> bool:
|
||||
"""删除 Samba 用户"""
|
||||
rc, _, _ = run_cmd(["pdbedit", "-x", username])
|
||||
return rc == 0
|
||||
|
||||
|
||||
def ensure_exchange_folder():
|
||||
"""确保交换站文件夹存在"""
|
||||
exchange_path = os.path.join(DATA_DIR, "exchange")
|
||||
os.makedirs(exchange_path, exist_ok=True)
|
||||
os.chmod(exchange_path, 0o777)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""Pydantic 数据校验"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ========== 认证 ==========
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=100)
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
# ========== 部门 ==========
|
||||
class DepartmentCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, pattern=r'^[\u4e00-\u9fa5a-zA-Z0-9]+$')
|
||||
|
||||
|
||||
class DepartmentUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, pattern=r'^[\u4e00-\u9fa5a-zA-Z0-9]+$')
|
||||
|
||||
|
||||
class DepartmentOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
folder_path: str
|
||||
user_count: int = 0
|
||||
used_space: int = 0
|
||||
is_deleted: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ========== 用户 ==========
|
||||
class UserCreate(BaseModel):
|
||||
display_name: str = Field(..., min_length=1, max_length=100)
|
||||
password: str = Field(..., min_length=6)
|
||||
departments: List[dict] = Field(..., min_length=1)
|
||||
# 格式: [{"department_id": 1, "role_in_dept": "member"}, ...]
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
departments: Optional[List[dict]] = None
|
||||
role_in_dept: Optional[dict] = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
is_deleted: bool
|
||||
departments: List[dict] = []
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserImportItem(BaseModel):
|
||||
display_name: str
|
||||
password: str
|
||||
department1: Optional[str] = None
|
||||
department2: Optional[str] = None
|
||||
department3: Optional[str] = None
|
||||
role1: str = "member"
|
||||
role2: str = "member"
|
||||
role3: str = "member"
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
success_count: int
|
||||
fail_count: int
|
||||
failed_rows: List[dict] = []
|
||||
|
||||
|
||||
# ========== 权限 ==========
|
||||
class PermissionTreeItem(BaseModel):
|
||||
department_id: int
|
||||
department_name: str
|
||||
admins: List[dict] = []
|
||||
members: List[dict] = []
|
||||
|
||||
|
||||
# ========== 监控 ==========
|
||||
class DiskUsageResponse(BaseModel):
|
||||
total_space: int
|
||||
used_space: int
|
||||
free_space: int
|
||||
usage_percent: float
|
||||
dept_usages: List[dict] = []
|
||||
|
||||
|
||||
# ========== 日志 ==========
|
||||
class LogOut(BaseModel):
|
||||
id: int
|
||||
operator_name: str
|
||||
target_type: str
|
||||
target_name: str
|
||||
action: str
|
||||
details: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PasswordConfirm(BaseModel):
|
||||
password: str = Field(..., min_length=1)
|
||||
@@ -0,0 +1 @@
|
||||
# services __init__.py
|
||||
@@ -0,0 +1,71 @@
|
||||
"""磁盘监控服务"""
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from app.utils.helpers import run_cmd
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def get_disk_usage() -> dict:
|
||||
"""获取总磁盘使用情况"""
|
||||
# df -B1 以字节为单位
|
||||
rc, stdout, _ = run_cmd(["df", "-B1", DATA_DIR or "/data/smb"])
|
||||
if rc != 0 or not stdout.strip():
|
||||
return {
|
||||
"total_space": 0, "used_space": 0, "free_space": 0,
|
||||
"usage_percent": 0.0
|
||||
}
|
||||
|
||||
lines = stdout.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
return {"total_space": 0, "used_space": 0, "free_space": 0, "usage_percent": 0.0}
|
||||
|
||||
fields = lines[1].split()
|
||||
total = int(fields[1])
|
||||
used = int(fields[2])
|
||||
free = int(fields[3])
|
||||
pct = float(fields[4].replace("%", ""))
|
||||
|
||||
return {
|
||||
"total_space": total,
|
||||
"used_space": used,
|
||||
"free_space": free,
|
||||
"usage_percent": pct
|
||||
}
|
||||
|
||||
|
||||
def get_dept_usages() -> list:
|
||||
"""获取各部门磁盘使用"""
|
||||
usages = []
|
||||
if not os.path.exists(DATA_DIR):
|
||||
return usages
|
||||
|
||||
for entry in os.scandir(DATA_DIR):
|
||||
if entry.is_dir() and entry.name.startswith("dept-"):
|
||||
dept_name = entry.name[4:] # 去掉 "dept-" 前缀
|
||||
path = entry.path
|
||||
rc, stdout, _ = run_cmd(["du", "-sb", path], timeout=60)
|
||||
if rc == 0:
|
||||
size = int(stdout.split()[0])
|
||||
else:
|
||||
size = 0
|
||||
usages.append({
|
||||
"dept_name": dept_name,
|
||||
"used_bytes": size
|
||||
})
|
||||
|
||||
# 加上 exchange 目录
|
||||
exchange_path = os.path.join(DATA_DIR, "exchange")
|
||||
if os.path.exists(exchange_path):
|
||||
rc, stdout, _ = run_cmd(["du", "-sb", exchange_path], timeout=60)
|
||||
if rc == 0:
|
||||
size = int(stdout.split()[0])
|
||||
else:
|
||||
size = 0
|
||||
usages.append({
|
||||
"dept_name": "exchange",
|
||||
"used_bytes": size
|
||||
})
|
||||
|
||||
return usages
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Linux 系统用户和文件夹权限管理服务"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.utils.helpers import run_cmd
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def create_linux_user(username: str, password: str) -> dict:
|
||||
"""创建 Linux 系统用户(无登录shell)"""
|
||||
# 先检查是否已存在
|
||||
rc, stdout, _ = run_cmd(["id", username])
|
||||
if rc == 0:
|
||||
return {"success": True, "exists": True}
|
||||
|
||||
# 创建用户(无home目录,无登录shell)
|
||||
rc, _, stderr = run_cmd([
|
||||
"useradd", "-M", "-s", "/usr/sbin/nologin", username
|
||||
])
|
||||
if rc != 0:
|
||||
return {"success": False, "error": f"useradd failed: {stderr}"}
|
||||
|
||||
# 设置密码
|
||||
proc = subprocess.run(
|
||||
["chpasswd"],
|
||||
input=f"{username}:{password}",
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
# 回滚用户创建
|
||||
run_cmd(["userdel", username])
|
||||
return {"success": False, "error": "password set failed"}
|
||||
|
||||
return {"success": True, "exists": False}
|
||||
|
||||
|
||||
def delete_linux_user(username: str) -> dict:
|
||||
"""删除 Linux 系统用户"""
|
||||
rc, _, _ = run_cmd(["id", username])
|
||||
if rc != 0:
|
||||
return {"success": True} # 用户不存在,视为成功
|
||||
|
||||
rc, _, stderr = run_cmd(["userdel", username])
|
||||
if rc != 0:
|
||||
return {"success": False, "error": f"userdel failed: {stderr}"}
|
||||
return {"success": True}
|
||||
|
||||
|
||||
def add_user_to_group(username: str, group_name: str) -> bool:
|
||||
"""将用户加入 Linux 组"""
|
||||
rc, _, _ = run_cmd(["usermod", "-aG", group_name, username])
|
||||
return rc == 0
|
||||
|
||||
|
||||
def remove_user_from_group(username: str, group_name: str) -> bool:
|
||||
"""将用户从 Linux 组移除"""
|
||||
rc, _, _ = run_cmd(["gpasswd", "-d", username, group_name])
|
||||
return rc == 0
|
||||
|
||||
|
||||
def create_department_group(group_name: str) -> dict:
|
||||
"""创建部门 Linux 组"""
|
||||
rc, _, _ = run_cmd(["groupadd", "-f", group_name])
|
||||
return {"success": rc == 0 or rc == 4} # 4=组已存在
|
||||
|
||||
|
||||
def create_user_folder(username: str, dept_name: str, role: str = "member") -> dict:
|
||||
"""创建用户的个人文件夹"""
|
||||
folder_path = os.path.join(DATA_DIR, f"dept-{dept_name}", username)
|
||||
try:
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
# 设置所有者
|
||||
os.chown(folder_path, -1, -1) # 待Samba挂载后修正
|
||||
os.chmod(folder_path, 0o700 if role == "member" else 0o750)
|
||||
return {"success": True, "path": folder_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_folder_owner(username: str, folder_path: str, group_name: str = None) -> bool:
|
||||
"""设置文件夹所有权"""
|
||||
import pwd
|
||||
try:
|
||||
uid = pwd.getpwnam(username).pw_uid
|
||||
gid = pwd.getpwnam(username).pw_gid if not group_name else -1
|
||||
if group_name:
|
||||
import grp
|
||||
gid = grp.getgrnam(group_name).gr_gid
|
||||
os.chown(folder_path, uid, gid)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_department_folders(dept_name: str) -> dict:
|
||||
"""创建部门根文件夹"""
|
||||
dept_path = os.path.join(DATA_DIR, f"dept-{dept_name}")
|
||||
admin_path = os.path.join(dept_path, "admin")
|
||||
try:
|
||||
os.makedirs(admin_path, exist_ok=True)
|
||||
# admin 文件夹允许部门管理员访问
|
||||
os.chmod(dept_path, 0o750)
|
||||
os.chmod(admin_path, 0o700)
|
||||
return {"success": True, "dept_path": dept_path, "admin_path": admin_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def apply_posix_acl(folder_path: str, username: str, perms: str = "rwx") -> bool:
|
||||
"""设置 POSIX ACL 权限"""
|
||||
rc, _, _ = run_cmd(["setfacl", "-m", f"u:{username}:{perms}", folder_path])
|
||||
return rc == 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Samba 配置生成服务"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.utils.helpers import run_cmd
|
||||
|
||||
|
||||
SMB_CONF_PATH = "/etc/samba/smb.conf"
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def generate_smb_conf(departments: list, users: list) -> str:
|
||||
"""生成 Samba 配置文件内容"""
|
||||
lines = [
|
||||
"[global]",
|
||||
" workgroup = WORKGROUP",
|
||||
" server string = SMB File Server",
|
||||
" security = user",
|
||||
" map to guest = bad user",
|
||||
" unix charset = UTF-8",
|
||||
" dos charset = cp936",
|
||||
" tcp nodelay = yes",
|
||||
" disable spoolss = yes",
|
||||
"",
|
||||
"# 共享交换站",
|
||||
"[exchange]",
|
||||
" path = /data/smb/exchange",
|
||||
" browseable = yes",
|
||||
" writable = yes",
|
||||
" guest ok = yes",
|
||||
" create mask = 0666",
|
||||
" directory mask = 0777",
|
||||
"",
|
||||
]
|
||||
|
||||
# 按部门生成配置
|
||||
for dept in departments:
|
||||
if dept.get("is_deleted"):
|
||||
continue
|
||||
dept_name = dept["name"]
|
||||
# 安全的共享名:去除中文,只保留字母数字下划线
|
||||
share_name = f"dept_{dept_name}"
|
||||
path = dept.get("folder_path", f"/data/smb/dept-{dept_name}")
|
||||
|
||||
lines.append(f"# 部门:{dept_name}")
|
||||
lines.append(f"[{share_name}]")
|
||||
lines.append(f" path = {path}")
|
||||
lines.append(f" browseable = yes")
|
||||
lines.append(f" writable = yes")
|
||||
lines.append(f" create mask = 0750")
|
||||
lines.append(f" directory mask = 0750")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_smb_config(config_content: str) -> dict:
|
||||
"""写入并应用 Samba 配置"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(SMB_CONF_PATH), exist_ok=True)
|
||||
with open(SMB_CONF_PATH, "w") as f:
|
||||
f.write(config_content)
|
||||
|
||||
# 验证语法
|
||||
rc, stdout, stderr = run_cmd(["testparm", "-s"])
|
||||
if rc != 0:
|
||||
return {"success": False, "error": f"Config invalid: {stderr}"}
|
||||
|
||||
# 重载配置
|
||||
run_cmd(["smbcontrol", "all", "reload-config"], timeout=5)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def add_samba_user(username: str, password: str) -> bool:
|
||||
"""添加 Samba 用户"""
|
||||
proc = subprocess.run(
|
||||
["smbpasswd", "-s", "-a", username],
|
||||
input=f"{password}\n{password}\n",
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def delete_samba_user(username: str) -> bool:
|
||||
"""删除 Samba 用户"""
|
||||
rc, _, _ = run_cmd(["pdbedit", "-x", username])
|
||||
return rc == 0
|
||||
|
||||
|
||||
def ensure_exchange_folder():
|
||||
"""确保交换站文件夹存在"""
|
||||
exchange_path = os.path.join(DATA_DIR, "exchange")
|
||||
os.makedirs(exchange_path, exist_ok=True)
|
||||
os.chmod(exchange_path, 0o777)
|
||||
@@ -0,0 +1,121 @@
|
||||
/* AI科技风格深色主题 */
|
||||
:root{
|
||||
--bg:#0a0e27;
|
||||
--bg2:#14193c;
|
||||
--bg3:#1a2045;
|
||||
--accent:#00d4ff;
|
||||
--accent2:#0099cc;
|
||||
--text:#ffffff;
|
||||
--text2:rgba(255,255,255,0.7);
|
||||
--text3:rgba(255,255,255,0.4);
|
||||
--border:rgba(0,212,255,0.15);
|
||||
--danger:#ff6b6b;
|
||||
--success:#4ecdc4;
|
||||
--warning:#ffd93d;
|
||||
--radius:10px;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);}
|
||||
|
||||
/* 布局 */
|
||||
.app{display:flex;height:100vh;overflow:hidden}
|
||||
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||||
.sidebar-logo{padding:20px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||
.sidebar-logo span{font-size:20px}
|
||||
.sidebar-logo h2{font-size:14px;font-weight:600;color:var(--accent)}
|
||||
.sidebar-logo small{font-size:11px;color:var(--text3)}
|
||||
.nav{flex:1;padding:12px 0}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 16px;color:var(--text2);text-decoration:none;font-size:13px;border-radius:8px;margin:2px 8px;transition:all 0.2s;cursor:pointer}
|
||||
.nav-item:hover{background:var(--bg3);color:var(--text)}
|
||||
.nav-item.active{background:rgba(0,212,255,0.1);color:var(--accent);border-left:3px solid var(--accent)}
|
||||
.nav-item svg{width:18px;height:18px;flex-shrink:0}
|
||||
.sidebar-user{padding:12px 16px;border-top:1px solid var(--border);font-size:12px;color:var(--text3)}
|
||||
.sidebar-user strong{color:var(--text);display:block}
|
||||
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.topbar{height:56px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 24px;gap:16px;flex-shrink:0}
|
||||
.topbar h1{font-size:16px;font-weight:600}
|
||||
.topbar-actions{display:flex;gap:8px;margin-left:auto}
|
||||
.content{flex:1;overflow-y:auto;padding:24px}
|
||||
|
||||
/* 通用卡片 */
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
||||
.card-title{font-size:14px;font-weight:600;color:var(--text)}
|
||||
|
||||
/* 按钮 */
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all 0.2s;text-decoration:none}
|
||||
.btn-primary{background:var(--accent);color:#000}
|
||||
.btn-primary:hover{background:var(--accent2)}
|
||||
.btn-danger{background:rgba(255,107,107,0.15);color:var(--danger);border:1px solid rgba(255,107,107,0.3)}
|
||||
.btn-danger:hover{background:rgba(255,107,107,0.25)}
|
||||
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)}
|
||||
.btn-ghost:hover{background:var(--bg3);color:var(--text)}
|
||||
.btn-sm{padding:5px 10px;font-size:12px}
|
||||
|
||||
/* 表格 */
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th{text-align:left;padding:10px 12px;color:var(--text3);font-weight:500;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
.table td{padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05)}
|
||||
.table tr:hover td{background:rgba(0,212,255,0.03)}
|
||||
.table-empty{text-align:center;color:var(--text3);padding:40px;font-size:13px}
|
||||
|
||||
/* 表单 */
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-label{display:block;font-size:13px;color:var(--text2);margin-bottom:6px}
|
||||
.form-input,.form-select{width:100%;padding:9px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:all 0.2s}
|
||||
.form-input:focus,.form-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,212,255,0.1)}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
|
||||
/* 状态徽章 */
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
|
||||
.badge-success{background:rgba(78,205,196,0.15);color:var(--success)}
|
||||
.badge-danger{background:rgba(255,107,107,0.15);color:var(--danger)}
|
||||
.badge-warning{background:rgba(255,217,61,0.15);color:var(--warning)}
|
||||
.badge-info{background:rgba(0,212,255,0.15);color:var(--accent)}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-bar{height:8px;background:var(--bg);border-radius:4px;overflow:hidden}
|
||||
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));border-radius:4px;transition:width 0.5s}
|
||||
.progress-label{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:6px}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px}
|
||||
.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
|
||||
.stat-card .value{font-size:24px;font-weight:700;color:var(--accent);margin-bottom:4px}
|
||||
.stat-card .label{font-size:12px;color:var(--text3)}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;display:flex;align-items:center;justify-content:center;display:none}
|
||||
.modal-overlay.active{display:flex}
|
||||
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:24px;width:460px;max-width:90vw;max-height:90vh;overflow-y:auto}
|
||||
.modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
||||
.modal-title{font-size:16px;font-weight:600}
|
||||
.modal-close{background:none;border:none;color:var(--text2);font-size:20px;cursor:pointer}
|
||||
.modal-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
|
||||
|
||||
/* Toast */
|
||||
.toast{position:fixed;bottom:24px;right:24px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px 20px;font-size:13px;z-index:200;display:none;animation:slideIn 0.3s}
|
||||
.toast.success{border-color:var(--success);color:var(--success)}
|
||||
.toast.error{border-color:var(--danger);color:var(--danger)}
|
||||
.toast.active{display:block}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
/* 权限树 */
|
||||
.tree-item{margin-left:24px;margin-top:8px}
|
||||
.tree-node{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:6px}
|
||||
.tree-node-header{display:flex;align-items:center;justify-content:space-between}
|
||||
.tree-node-title{font-weight:600;font-size:13px}
|
||||
.tree-users{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.tree-user{background:var(--bg3);border-radius:20px;padding:3px 10px;font-size:11px;display:flex;align-items:center;gap:4px}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading{text-align:center;padding:40px;color:var(--text3)}
|
||||
.spinner{display:inline-block;width:24px;height:24px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:12px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar{width:6px;height:6px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:rgba(0,212,255,0.3)}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 视图页面通用 */
|
||||
.view-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
||||
.view-title{font-size:18px;font-weight:700}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap}
|
||||
@@ -0,0 +1,55 @@
|
||||
const API = '/api';
|
||||
let _token = localStorage.getItem('smb_token');
|
||||
|
||||
function getToken(){return _token || localStorage.getItem('smb_token');}
|
||||
|
||||
async function api(path, opts={}){
|
||||
const token = getToken();
|
||||
const headers = {'Content-Type':'application/json'};
|
||||
if(token) headers['Authorization']=`Bearer ${token}`;
|
||||
const resp = await fetch(API+path, {...opts, headers:{...headers,...opts.headers}});
|
||||
if(resp.status===401){localStorage.removeItem('smb_token');location.href='/';return null;}
|
||||
if(!resp.ok){const e=await resp.json().catch(()=>({detail:'请求失败'}));throw new Error(e.detail||'请求失败');}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiFile(path, opts={}){
|
||||
const token = getToken();
|
||||
const headers = {};
|
||||
if(token) headers['Authorization']=`Bearer ${token}`;
|
||||
const resp = await fetch(API+path, {...opts, headers:{...headers,...opts.headers}});
|
||||
if(resp.status===401){localStorage.removeItem('smb_token');location.href='/';return null;}
|
||||
if(!resp.ok){const e=await resp.json().catch(()=>({detail:'请求失败'}));throw new Error(e.detail||'请求失败');}
|
||||
return resp.blob();
|
||||
}
|
||||
|
||||
function formatBytes(bytes){
|
||||
if(!bytes) return '0 B';
|
||||
const k=1024;
|
||||
const sizes=['B','KB','MB','GB','TB'];
|
||||
const i=Math.floor(Math.log(bytes)/Math.log(k));
|
||||
return parseFloat((bytes/Math.pow(k,i)).toFixed(2))+' '+sizes[i];
|
||||
}
|
||||
|
||||
function toast(msg,type='success'){
|
||||
const t=document.getElementById('toast');
|
||||
t.textContent=msg;t.className=`toast ${type} active`;
|
||||
setTimeout(()=>t.className='toast',3000);
|
||||
}
|
||||
|
||||
function formatDate(d){
|
||||
if(!d) return '-';
|
||||
const dt=new Date(d);
|
||||
return dt.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
async function confirmAction(title,message){
|
||||
return new Promise(resolve=>{
|
||||
const ov=document.getElementById('confirmOverlay');
|
||||
document.getElementById('confirmTitle').textContent=title;
|
||||
document.getElementById('confirmMsg').textContent=message;
|
||||
document.getElementById('confirmOk').onclick=()=>{ov.classList.remove('active');resolve(true);};
|
||||
document.getElementById('confirmCancel').onclick=()=>{ov.classList.remove('active');resolve(false);};
|
||||
ov.classList.add('active');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - SMB 文件服务器</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0e27;min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||
.bg{position:fixed;inset:0;z-index:0;background:linear-gradient(135deg,#0a0e27 0%,#1a1f4e 50%,#0a0e27 100%)}
|
||||
.particles{position:fixed;inset:0;z-index:1;pointer-events:none}
|
||||
.card{position:relative;z-index:10;background:rgba(20,25,60,0.9);border:1px solid rgba(0,212,255,0.2);border-radius:16px;padding:48px 40px;width:400px;backdrop-filter:blur(20px);box-shadow:0 0 60px rgba(0,212,255,0.1)}
|
||||
.logo{text-align:center;margin-bottom:32px}
|
||||
.logo-icon{font-size:48px;margin-bottom:12px}
|
||||
.logo h1{color:#fff;font-size:24px;font-weight:600}
|
||||
.logo p{color:rgba(255,255,255,0.5);font-size:13px;margin-top:4px}
|
||||
.input-group{margin-bottom:20px}
|
||||
.input-group label{color:rgba(255,255,255,0.7);font-size:13px;display:block;margin-bottom:8px}
|
||||
.input-group input{width:100%;padding:12px 16px;background:rgba(0,0,0,0.3);border:1px solid rgba(0,212,255,0.15);border-radius:8px;color:#fff;font-size:14px;outline:none;transition:all 0.3s}
|
||||
.input-group input:focus{border-color:#00d4ff;box-shadow:0 0 20px rgba(0,212,255,0.2)}
|
||||
.btn-login{width:100%;padding:14px;background:linear-gradient(135deg,#00d4ff,#0099cc);border:none;border-radius:8px;color:#fff;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.3s;margin-top:8px}
|
||||
.btn-login:hover{transform:translateY(-2px);box-shadow:0 8px 30px rgba(0,212,255,0.4)}
|
||||
.btn-login:active{transform:translateY(0)}
|
||||
.error{color:#ff6b6b;font-size:13px;margin-top:12px;text-align:center;display:none}
|
||||
.glow{position:absolute;width:200px;height:200px;border-radius:50%;filter:blur(80px);opacity:0.15;pointer-events:none}
|
||||
.glow-1{background:#00d4ff;top:-80px;left:-80px}
|
||||
.glow-2{background:#9b59b6;bottom:-80px;right:-80px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg"></div>
|
||||
<canvas class="particles" id="particles"></canvas>
|
||||
<div class="card">
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">📁</div>
|
||||
<h1>SMB 文件服务器</h1>
|
||||
<p>部门化文件共享管理后台</p>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>管理员账号</label>
|
||||
<input type="text" id="username" placeholder="请输入管理员账号" value="admin" autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<button class="btn-login" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="error" id="errorMsg"></div>
|
||||
</div>
|
||||
<script>
|
||||
// 粒子背景
|
||||
const canvas = document.getElementById('particles');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const particles = [];
|
||||
for(let i=0;i<60;i++){
|
||||
particles.push({
|
||||
x:Math.random()*canvas.width,
|
||||
y:Math.random()*canvas.height,
|
||||
r:Math.random()*2+0.5,
|
||||
vx:(Math.random()-0.5)*0.5,
|
||||
vy:(Math.random()-0.5)*0.5,
|
||||
alpha:Math.random()*0.5+0.2
|
||||
});
|
||||
}
|
||||
function drawParticles(){
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
particles.forEach(p=>{
|
||||
p.x+=p.vx; p.y+=p.vy;
|
||||
if(p.x<0||p.x>canvas.width)p.vx*=-1;
|
||||
if(p.y<0||p.y>canvas.height)p.vy*=-1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x,p.y,p.r,0,Math.PI*2);
|
||||
ctx.fillStyle=`rgba(0,212,255,${p.alpha})`;
|
||||
ctx.fill();
|
||||
});
|
||||
requestAnimationFrame(drawParticles);
|
||||
}
|
||||
drawParticles();
|
||||
window.onresize=()=>{canvas.width=window.innerWidth;canvas.height=window.innerHeight};
|
||||
|
||||
// 登录
|
||||
let token = localStorage.getItem('smb_token');
|
||||
if(token){location.href='/dashboard';}
|
||||
|
||||
async function doLogin(){
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const error = document.getElementById('errorMsg');
|
||||
error.style.display='none';
|
||||
btn.textContent='登录中...';
|
||||
btn.disabled=true;
|
||||
try{
|
||||
const resp = await fetch('/api/auth/login',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({username,password})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if(resp.ok){
|
||||
localStorage.setItem('smb_token',data.access_token);
|
||||
localStorage.setItem('smb_user',username);
|
||||
location.href='/dashboard';
|
||||
}else{
|
||||
error.textContent=data.detail||'登录失败';
|
||||
error.style.display='block';
|
||||
}
|
||||
}catch(e){
|
||||
error.textContent='网络错误,请稍后重试';
|
||||
error.style.display='block';
|
||||
}
|
||||
btn.textContent='登 录';
|
||||
btn.disabled=false;
|
||||
}
|
||||
document.getElementById('password').addEventListener('keypress',e=>{if(e.key==='Enter')doLogin()});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>仪表盘 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item active" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>🏠 仪表盘</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title">📈 系统总览</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="location.href='/departments'">+ 新建部门</button>
|
||||
<button class="btn btn-ghost" onclick="location.href='/users'">+ 新建用户</button>
|
||||
<button class="btn btn-ghost" onclick="location.href='/logs'">查看日志</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading"><div class="spinner"></div><div>加载中...</div></div>
|
||||
<div id="dashboard" style="display:none">
|
||||
<!-- 磁盘总览 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">💾 磁盘使用总览</span></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="diskPct" style="width:0%"></div></div>
|
||||
<div class="progress-label"><span id="diskText">-</span><span id="diskPctText">-</span></div>
|
||||
</div>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="value" id="statDepts">-</div><div class="label">部门数量</div></div>
|
||||
<div class="stat-card"><div class="value" id="statUsers">-</div><div class="label">用户数量</div></div>
|
||||
<div class="stat-card"><div class="value" id="statTotalSpace">-</div><div class="label">总容量</div></div>
|
||||
<div class="stat-card"><div class="value" id="statUsedSpace">-</div><div class="label">已用空间</div></div>
|
||||
</div>
|
||||
<!-- 部门用量 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">📁 各部门用量</span></div>
|
||||
<div id="deptUsages"><div class="loading" style="padding:20px">加载中...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="confirmOverlay" class="modal-overlay">
|
||||
<div class="modal" style="width:360px">
|
||||
<div class="modal-header"><span class="modal-title" id="confirmTitle">确认操作</span></div>
|
||||
<p id="confirmMsg" style="color:var(--text2);font-size:13px;margin-bottom:16px"></p>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="confirmCancel">取消</button>
|
||||
<button class="btn btn-danger" id="confirmOk">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
async function load(){
|
||||
try{
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name+' ('+me.username+')';
|
||||
const disk = await api('/monitor/disk');
|
||||
const depts = await api('/departments');
|
||||
const users = await api('/users');
|
||||
document.getElementById('statDepts').textContent = depts.length;
|
||||
document.getElementById('statUsers').textContent = users.length;
|
||||
document.getElementById('statTotalSpace').textContent = formatBytes(disk.total_space);
|
||||
document.getElementById('statUsedSpace').textContent = formatBytes(disk.used_space);
|
||||
document.getElementById('diskPct').style.width = disk.usage_percent+'%';
|
||||
document.getElementById('diskText').textContent = '已用: '+formatBytes(disk.used_space)+' / 总计: '+formatBytes(disk.total_space);
|
||||
document.getElementById('diskPctText').textContent = disk.usage_percent.toFixed(1)+'%';
|
||||
// 部门用量
|
||||
if(disk.dept_usages && disk.dept_usages.length){
|
||||
let html='';
|
||||
disk.dept_usages.forEach(d=>{
|
||||
const pct = disk.total_space>0?(d.used_bytes/disk.total_space*100):0;
|
||||
html+=`<div style="margin-bottom:12px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-bottom:4px">
|
||||
<span>${d.dept_name}</span><span>${formatBytes(d.used_bytes)}</span>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${pct.toFixed(1)}%"></div></div>
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('deptUsages').innerHTML=html;
|
||||
} else {
|
||||
document.getElementById('deptUsages').innerHTML='<div class="table-empty">暂无数据</div>';
|
||||
}
|
||||
document.getElementById('loading').style.display='none';
|
||||
document.getElementById('dashboard').style.display='block';
|
||||
} catch(e){ toast('加载失败: '+e.message,'error'); }
|
||||
}
|
||||
function logout(){
|
||||
localStorage.removeItem('smb_token');
|
||||
localStorage.removeItem('smb_user');
|
||||
location.href='/';
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>部门管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item active" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>👥 部门管理</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ 新建部门</button>
|
||||
<button class="btn btn-ghost" onclick="loadDepts()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>部门名称</th><th>文件夹路径</th><th>用户数</th><th>已用空间</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="deptTable"><tr><td colspan="7" class="table-empty">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建部门弹窗 -->
|
||||
<div id="createModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">新建部门</span><button class="modal-close" onclick="closeModal('createModal')">×</button></div>
|
||||
<div class="form-group"><label class="form-label">部门名称</label><input class="form-input" id="deptName" placeholder="如:销售部" maxlength="20"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('createModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="createDept()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
let depts=[];
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
depts = await api('/departments');
|
||||
render();
|
||||
}
|
||||
function render(){
|
||||
if(!depts.length){
|
||||
document.getElementById('deptTable').innerHTML='<tr><td colspan="7" class="table-empty">暂无部门,点击右上角新建</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('deptTable').innerHTML=depts.map(d=>`
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td><strong>${d.name}</strong></td>
|
||||
<td><code style="font-size:12px;color:var(--text3)">${d.folder_path}</code></td>
|
||||
<td>${d.user_count}</td>
|
||||
<td>${formatBytes(d.used_space)}</td>
|
||||
<td>${formatDate(d.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteDept(${d.id},'${d.name}')">删除</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
async function createDept(){
|
||||
const name = document.getElementById('deptName').value.trim();
|
||||
if(!name){toast('请输入部门名称','error');return;}
|
||||
try{
|
||||
await api('/departments',{method:'POST',body:JSON.stringify({name})});
|
||||
toast('部门创建成功');
|
||||
closeModal('createModal');
|
||||
document.getElementById('deptName').value='';
|
||||
loadDepts();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function deleteDept(id,name){
|
||||
const pwd = prompt('删除部门需确认,请输入管理员密码:');
|
||||
if(!pwd) return;
|
||||
try{
|
||||
await api(`/departments/${id}`,{method:'DELETE',body:JSON.stringify({password:pwd})});
|
||||
toast('删除成功');
|
||||
loadDepts();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
function openCreateModal(){document.getElementById('createModal').classList.add('active');}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('active');}
|
||||
async function loadDepts(){depts=await api('/departments');render();}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>操作日志 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item active" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>📋 操作日志</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-ghost" onclick="exportLogs()">📥 导出CSV</button>
|
||||
<button class="btn btn-ghost" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>时间</th><th>操作者</th><th>操作类型</th><th>对象类型</th><th>对象名称</th><th>详情</th><th>IP</th></tr></thead>
|
||||
<tbody id="logTable"><tr><td colspan="7" class="table-empty">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
<div style="padding:12px;display:flex;justify-content:space-between;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" id="prevBtn" onclick="changePage(-1)">上一页</button>
|
||||
<span id="pageInfo" style="font-size:12px;color:var(--text3)">第1页</span>
|
||||
<button class="btn btn-ghost btn-sm" id="nextBtn" onclick="changePage(1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
let page=1,pageSize=20,total=0;
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
const params=new URLSearchParams({page,page_size:pageSize});
|
||||
const logs=await api('/logs?'+params);
|
||||
render(logs);
|
||||
}
|
||||
function render(logs){
|
||||
if(!logs.length){
|
||||
document.getElementById('logTable').innerHTML='<tr><td colspan="7" class="table-empty">暂无日志</td></tr>';
|
||||
return;
|
||||
}
|
||||
const actionColors={'CREATE':'success','DELETE':'danger','UPDATE':'warning','RESET_PASSWORD':'info','IMPORT':'info'};
|
||||
document.getElementById('logTable').innerHTML=logs.map(l=>`
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${formatDate(l.created_at)}</td>
|
||||
<td>${l.operator_name}</td>
|
||||
<td><span class="badge badge-${actionColors[l.action]||'info'}">${l.action}</span></td>
|
||||
<td>${l.target_type}</td>
|
||||
<td>${l.target_name}</td>
|
||||
<td><code style="font-size:11px;color:var(--text3)">${l.details||''}</code></td>
|
||||
<td style="color:var(--text3);font-size:12px">${l.ip_address||'-'}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('pageInfo').textContent=`第${page}页`;
|
||||
document.getElementById('prevBtn').disabled=page<=1;
|
||||
}
|
||||
function changePage(delta){page=Math.max(1,page+delta);load();}
|
||||
async function exportLogs(){
|
||||
try{
|
||||
const blob=await apiFile('/logs/export');
|
||||
const url=URL.createObjectURL(blob);
|
||||
const a=document.createElement('a');a.href=url;a.download='logs.csv';a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast('导出成功');
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>磁盘监控 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item active" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>📈 磁盘监控</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<button class="btn btn-primary" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">💾 总览</span></div>
|
||||
<div class="progress-bar" style="height:20px"><div class="progress-fill" id="mainBar" style="width:0%"></div></div>
|
||||
<div class="progress-label" style="margin-top:10px">
|
||||
<span>已用: <strong id="mainUsed">-</strong> / 总计: <strong id="mainTotal">-</strong></span>
|
||||
<span class="badge badge-info" id="mainPct">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="deptList"><div class="loading"><div class="spinner"></div><div>加载中...</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
const disk = await api('/monitor/disk');
|
||||
document.getElementById('mainBar').style.width=disk.usage_percent+'%';
|
||||
document.getElementById('mainUsed').textContent=formatBytes(disk.used_space);
|
||||
document.getElementById('mainTotal').textContent=formatBytes(disk.total_space);
|
||||
document.getElementById('mainPct').textContent=disk.usage_percent.toFixed(1)+'%';
|
||||
if(!disk.dept_usages.length){
|
||||
document.getElementById('deptList').innerHTML='<div class="table-empty">暂无数据</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('deptList').innerHTML='<div class="card"><div class="card-header"><span class="card-title">📁 各部门用量</span></div>'+
|
||||
disk.dept_usages.map(d=>{
|
||||
const pct=disk.total_space>0?(d.used_bytes/disk.total_space*100):0;
|
||||
return `<div style="margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px">
|
||||
<span style="color:var(--text2)">${d.dept_name}</span>
|
||||
<span style="color:var(--accent)">${formatBytes(d.used_bytes)} <span style="color:var(--text3)">(${pct.toFixed(1)}%)</span></span>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${pct.toFixed(1)}%"></div></div>
|
||||
</div>`;
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>权限管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item active" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>🔒 权限管理</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title">部门-用户权限树</div>
|
||||
<button class="btn btn-ghost" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
<div id="tree"><div class="loading"><div class="spinner"></div><div>加载中...</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
const tree = await api('/permissions/tree');
|
||||
render(tree);
|
||||
}
|
||||
function render(tree){
|
||||
if(!tree.length){
|
||||
document.getElementById('tree').innerHTML='<div class="table-empty">暂无数据</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('tree').innerHTML=tree.map(dept=>`
|
||||
<div class="card">
|
||||
<div class="tree-node">
|
||||
<div class="tree-node-header">
|
||||
<span class="tree-node-title">👥 ${dept.department_name}</span>
|
||||
<span class="badge badge-info">${dept.admins.length} 管理员 / ${dept.members.length} 成员</span>
|
||||
</div>
|
||||
<div class="tree-users">
|
||||
${dept.admins.map(u=>`<span class="tree-user" title="${u.username}">🔚 ${u.display_name} <span class="badge badge-success" style="font-size:10px">admin</span></span>`).join('')}
|
||||
${dept.members.map(u=>`<span class="tree-user" title="${u.username}">👤 ${u.display_name}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>用户管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item active" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>👤 用户管理</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ 新建用户</button>
|
||||
<a class="btn btn-ghost" href="/api/users/import-template" download style="text-decoration:none">📄 下载模板</a>
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('importFile').click()">📥 批量导入</button>
|
||||
<input type="file" id="importFile" accept=".xlsx" style="display:none" onchange="doImport(this)">
|
||||
<button class="btn btn-ghost" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>显示名</th><th>所属部门</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="userTable"><tr><td colspan="6" class="table-empty">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建用户弹窗 -->
|
||||
<div id="createModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">新建用户</span><button class="modal-close" onclick="closeModal('createModal')">×</button></div>
|
||||
<div class="form-group"><label class="form-label">显示姓名</label><input class="form-input" id="userDisplayName" placeholder="如:张三"></div>
|
||||
<div class="form-group"><label class="form-label">初始密码</label><input class="form-input" id="userPassword" type="password" placeholder="至少6位"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">所属部门及角色</label>
|
||||
<div id="deptCheckboxes" style="max-height:200px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('createModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="createUser()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户弹窗 -->
|
||||
<div id="editModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">编辑用户</span><button class="modal-close" onclick="closeModal('editModal')">×</button></div>
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group"><label class="form-label">显示姓名</label><input class="form-input" id="editDisplayName"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">所属部门及角色</label>
|
||||
<div id="editDeptCheckboxes" style="max-height:200px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('editModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doEditUser()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<div id="resetPwdModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">重置密码</span><button class="modal-close" onclick="closeModal('resetPwdModal')">×</button></div>
|
||||
<input type="hidden" id="resetPwdUserId">
|
||||
<div class="form-group"><label class="form-label">新密码</label><input class="form-input" id="newPassword" type="password" placeholder="至少6位"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('resetPwdModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doResetPwd()">确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
let users=[],depts=[];
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
[users,depts]=await Promise.all([api('/users'),api('/departments')]);
|
||||
renderDepts();
|
||||
render();
|
||||
}
|
||||
function renderDepts(){
|
||||
document.getElementById('deptCheckboxes').innerHTML=depts.map(d=>`
|
||||
<label style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px;color:var(--text2);cursor:pointer">
|
||||
<input type="checkbox" value="${d.id}" data-dept="${d.name}">
|
||||
<span>${d.name}</span>
|
||||
</label>`).join('');
|
||||
}
|
||||
function render(){
|
||||
if(!users.length){
|
||||
document.getElementById('userTable').innerHTML='<tr><td colspan="6" class="table-empty">暂无用户</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('userTable').innerHTML=users.map(u=>`
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><code>${u.username}</code></td>
|
||||
<td>${u.display_name}</td>
|
||||
<td>${u.departments.map(d=>`<span class="badge badge-info">${d.department_name||d.department_id} ${d.role_in_dept}</span>`).join(' ')}</td>
|
||||
<td>${formatDate(u.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openEditUser(${u.id})">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openResetPwd(${u.id})">重置密码</button>
|
||||
${u.id !== 1 ? '<button class="btn btn-danger btn-sm" onclick="deleteUser('+u.id+',\''+u.display_name+'\')">删除</button>' : ''}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
function openCreateModal(){document.getElementById('createModal').classList.add('active');}
|
||||
function openResetPwd(id){document.getElementById('resetPwdUserId').value=id;document.getElementById('resetPwdModal').classList.add('active');}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('active');}
|
||||
async function openEditUser(id){
|
||||
const user = users.find(u => u.id === id);
|
||||
if(!user) return;
|
||||
document.getElementById('editUserId').value = id;
|
||||
document.getElementById('editDisplayName').value = user.display_name;
|
||||
// 渲染部门勾选(当前用户的部门已勾选)
|
||||
document.getElementById('editDeptCheckboxes').innerHTML = depts.map(d => {
|
||||
const ud = user.departments && user.departments.find(x => x.department_id === d.id);
|
||||
const checked = ud ? 'checked' : '';
|
||||
const currentRole = ud ? ud.role_in_dept : 'member';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px">
|
||||
<input type="checkbox" id="edept_${d.id}" value="${d.id}" ${checked} onchange="toggleDeptRole(${d.id})">
|
||||
<label for="edept_${d.id}" style="cursor:pointer;min-width:80px;color:var(--text)">${d.name}</label>
|
||||
<select id="erole_${d.id}" style="flex:1;opacity:${checked?1:0.4};pointer-events:${checked?'auto':'none'};background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:3px 6px">
|
||||
<option value="member" ${currentRole==='member'?'selected':''}>member</option>
|
||||
<option value="admin" ${currentRole==='admin'?'selected':''}>admin</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
}
|
||||
async function toggleDeptRole(id){
|
||||
const checked = document.getElementById('edept_'+id).checked;
|
||||
document.getElementById('erole_'+id).style.opacity = checked ? 1 : 0.4;
|
||||
document.getElementById('erole_'+id).style.pointerEvents = checked ? 'auto' : 'none';
|
||||
}
|
||||
async function doEditUser(){
|
||||
const id = document.getElementById('editUserId').value;
|
||||
const display_name = document.getElementById('editDisplayName').value.trim();
|
||||
if(!display_name){toast('请填写显示姓名','error');return;}
|
||||
const checkboxes = document.querySelectorAll('#editDeptCheckboxes input:checked');
|
||||
const departments = Array.from(checkboxes).map(cb => ({
|
||||
department_id: parseInt(cb.value),
|
||||
role_in_dept: document.getElementById('erole_'+cb.value).value
|
||||
}));
|
||||
try{
|
||||
await api(`/users/${id}`,{method:'PUT',body:JSON.stringify({display_name, departments})});
|
||||
toast('用户信息已保存');
|
||||
closeModal('editModal');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function createUser(){
|
||||
const display_name=document.getElementById('userDisplayName').value.trim();
|
||||
const password=document.getElementById('userPassword').value;
|
||||
if(!display_name||!password){toast('请填写完整','error');return;}
|
||||
if(password.length<6){toast('密码至少6位','error');return;}
|
||||
const checkboxes=document.querySelectorAll('#deptCheckboxes input:checked');
|
||||
if(!checkboxes.length){toast('请选择至少一个部门','error');return;}
|
||||
const departments=Array.from(checkboxes).map(cb=>({department_id:parseInt(cb.value),role_in_dept:'member'}));
|
||||
try{
|
||||
await api('/users',{method:'POST',body:JSON.stringify({display_name,password,departments})});
|
||||
toast('用户【' + display_name + '】创建成功,已添加到列表');
|
||||
closeModal('createModal');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function doResetPwd(){
|
||||
const id=document.getElementById('resetPwdUserId').value;
|
||||
const np=document.getElementById('newPassword').value;
|
||||
if(np.length<6){toast('密码至少6位','error');return;}
|
||||
try{
|
||||
await api(`/users/${id}/reset-password`,{method:'POST',body:JSON.stringify({new_password:np})});
|
||||
toast('密码重置成功');
|
||||
closeModal('resetPwdModal');
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function deleteUser(id,name){
|
||||
const pwd=prompt(`删除用户【${name}】需确认,请输入管理员密码:`);
|
||||
if(!pwd) return;
|
||||
try{
|
||||
await api(`/users/${id}`,{method:'DELETE',body:JSON.stringify({password:pwd})});
|
||||
toast('删除成功');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function doImport(fileEl){
|
||||
const file=fileEl.files[0];
|
||||
if(!file) return;
|
||||
const form=new FormData();
|
||||
form.append('file',file);
|
||||
const token=getToken();
|
||||
try{
|
||||
const resp=await fetch('/api/users/import',{method:'POST',headers:{Authorization:`Bearer ${token}`},body:form});
|
||||
const data=await resp.json();
|
||||
if(resp.ok){
|
||||
toast(`导入完成:成功${data.success_count},失败${data.fail_count}`);
|
||||
load();
|
||||
}else{toast(data.detail||'导入失败','error');}
|
||||
}catch(e){toast(e.message,'error');}
|
||||
fileEl.value='';
|
||||
}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>用户管理 - SMB 文件服务器</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/views.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-logo"><span>📁</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item" href="/dashboard">🏠 仪表盘</a>
|
||||
<a class="nav-item" href="/departments">👥 部门管理</a>
|
||||
<a class="nav-item active" href="/users">👤 用户管理</a>
|
||||
<a class="nav-item" href="/permissions">🔒 权限管理</a>
|
||||
<a class="nav-item" href="/monitor">📈 磁盘监控</a>
|
||||
<a class="nav-item" href="/logs">📋 操作日志</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<strong id="currentUser">-</strong>
|
||||
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">🚪 退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="topbar"><h1>👤 用户管理</h1></div>
|
||||
<div class="content">
|
||||
<div class="view-header">
|
||||
<div class="view-title"></div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ 新建用户</button>
|
||||
<a class="btn btn-ghost" href="/api/users/import-template" download style="text-decoration:none">📄 下载模板</a>
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('importFile').click()">📥 批量导入</button>
|
||||
<input type="file" id="importFile" accept=".xlsx" style="display:none" onchange="doImport(this)">
|
||||
<button class="btn btn-ghost" onclick="load()">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>用户名</th><th>显示名</th><th>所属部门</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="userTable"><tr><td colspan="6" class="table-empty">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建用户弹窗 -->
|
||||
<div id="createModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">新建用户</span><button class="modal-close" onclick="closeModal('createModal')">×</button></div>
|
||||
<div class="form-group"><label class="form-label">显示姓名</label><input class="form-input" id="userDisplayName" placeholder="如:张三"></div>
|
||||
<div class="form-group"><label class="form-label">初始密码</label><input class="form-input" id="userPassword" type="password" placeholder="至少6位"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">所属部门及角色</label>
|
||||
<div id="deptCheckboxes" style="max-height:200px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('createModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="createUser()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户弹窗 -->
|
||||
<div id="editModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">编辑用户</span><button class="modal-close" onclick="closeModal('editModal')">×</button></div>
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group"><label class="form-label">显示姓名</label><input class="form-input" id="editDisplayName"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">所属部门及角色</label>
|
||||
<div id="editDeptCheckboxes" style="max-height:200px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('editModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doEditUser()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<div id="resetPwdModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><span class="modal-title">重置密码</span><button class="modal-close" onclick="closeModal('resetPwdModal')">×</button></div>
|
||||
<input type="hidden" id="resetPwdUserId">
|
||||
<div class="form-group"><label class="form-label">新密码</label><input class="form-input" id="newPassword" type="password" placeholder="至少6位"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal('resetPwdModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="doResetPwd()">确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
let users=[],depts=[];
|
||||
async function load(){
|
||||
const me = await api('/auth/me');
|
||||
document.getElementById('currentUser').textContent = me.display_name;
|
||||
[users,depts]=await Promise.all([api('/users'),api('/departments')]);
|
||||
renderDepts();
|
||||
render();
|
||||
}
|
||||
function renderDepts(){
|
||||
document.getElementById('deptCheckboxes').innerHTML=depts.map(d=>`
|
||||
<label style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px;color:var(--text2);cursor:pointer">
|
||||
<input type="checkbox" value="${d.id}" data-dept="${d.name}">
|
||||
<span>${d.name}</span>
|
||||
</label>`).join('');
|
||||
}
|
||||
function render(){
|
||||
if(!users.length){
|
||||
document.getElementById('userTable').innerHTML='<tr><td colspan="6" class="table-empty">暂无用户</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('userTable').innerHTML=users.map(u=>`
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><code>${u.username}</code></td>
|
||||
<td>${u.display_name}</td>
|
||||
<td>${u.departments.map(d=>`<span class="badge badge-info">${d.department_name||d.department_id} ${d.role_in_dept}</span>`).join(' ')}</td>
|
||||
<td>${formatDate(u.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openEditUser(${u.id})">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openResetPwd(${u.id})">重置密码</button>
|
||||
${u.id !== 1 ? '<button class="btn btn-danger btn-sm" onclick="deleteUser('+u.id+',\''+u.display_name+'\')">删除</button>' : ''}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
function openCreateModal(){document.getElementById('createModal').classList.add('active');}
|
||||
function openResetPwd(id){document.getElementById('resetPwdUserId').value=id;document.getElementById('resetPwdModal').classList.add('active');}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('active');}
|
||||
async function openEditUser(id){
|
||||
const user = users.find(u => u.id === id);
|
||||
if(!user) return;
|
||||
document.getElementById('editUserId').value = id;
|
||||
document.getElementById('editDisplayName').value = user.display_name;
|
||||
// 渲染部门勾选(当前用户的部门已勾选)
|
||||
document.getElementById('editDeptCheckboxes').innerHTML = depts.map(d => {
|
||||
const checked = user.departments && user.departments.some(ud => ud.department_id === d.id) ? 'checked' : '';
|
||||
return `<label style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px;color:var(--text2);cursor:pointer">
|
||||
<input type="checkbox" value="${d.id}" data-dept="${d.name}" ${checked}>
|
||||
<span>${d.name}</span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
}
|
||||
async function doEditUser(){
|
||||
const id = document.getElementById('editUserId').value;
|
||||
const display_name = document.getElementById('editDisplayName').value.trim();
|
||||
if(!display_name){toast('请填写显示姓名','error');return;}
|
||||
const checkboxes = document.querySelectorAll('#editDeptCheckboxes input:checked');
|
||||
const departments = Array.from(checkboxes).map(cb => ({
|
||||
department_id: parseInt(cb.value),
|
||||
role_in_dept: 'member'
|
||||
}));
|
||||
try{
|
||||
await api(`/users/${id}`,{method:'PUT',body:JSON.stringify({display_name, departments})});
|
||||
toast('用户信息已保存');
|
||||
closeModal('editModal');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function createUser(){
|
||||
const display_name=document.getElementById('userDisplayName').value.trim();
|
||||
const password=document.getElementById('userPassword').value;
|
||||
if(!display_name||!password){toast('请填写完整','error');return;}
|
||||
if(password.length<6){toast('密码至少6位','error');return;}
|
||||
const checkboxes=document.querySelectorAll('#deptCheckboxes input:checked');
|
||||
if(!checkboxes.length){toast('请选择至少一个部门','error');return;}
|
||||
const departments=Array.from(checkboxes).map(cb=>({department_id:parseInt(cb.value),role_in_dept:'member'}));
|
||||
try{
|
||||
await api('/users',{method:'POST',body:JSON.stringify({display_name,password,departments})});
|
||||
toast('用户【' + display_name + '】创建成功,已添加到列表');
|
||||
closeModal('createModal');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function doResetPwd(){
|
||||
const id=document.getElementById('resetPwdUserId').value;
|
||||
const np=document.getElementById('newPassword').value;
|
||||
if(np.length<6){toast('密码至少6位','error');return;}
|
||||
try{
|
||||
await api(`/users/${id}/reset-password`,{method:'POST',body:JSON.stringify({new_password:np})});
|
||||
toast('密码重置成功');
|
||||
closeModal('resetPwdModal');
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function deleteUser(id,name){
|
||||
const pwd=prompt(`删除用户【${name}】需确认,请输入管理员密码:`);
|
||||
if(!pwd) return;
|
||||
try{
|
||||
await api(`/users/${id}`,{method:'DELETE',body:JSON.stringify({password:pwd})});
|
||||
toast('删除成功');
|
||||
load();
|
||||
}catch(e){toast(e.message,'error');}
|
||||
}
|
||||
async function doImport(fileEl){
|
||||
const file=fileEl.files[0];
|
||||
if(!file) return;
|
||||
const form=new FormData();
|
||||
form.append('file',file);
|
||||
const token=getToken();
|
||||
try{
|
||||
const resp=await fetch('/api/users/import',{method:'POST',headers:{Authorization:`Bearer ${token}`},body:form});
|
||||
const data=await resp.json();
|
||||
if(resp.ok){
|
||||
toast(`导入完成:成功${data.success_count},失败${data.fail_count}`);
|
||||
load();
|
||||
}else{toast(data.detail||'导入失败','error');}
|
||||
}catch(e){toast(e.message,'error');}
|
||||
fileEl.value='';
|
||||
}
|
||||
function logout(){localStorage.removeItem('smb_token');location.href='/';}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
"""用户管理路由"""
|
||||
import os
|
||||
import json
|
||||
import openpyxl
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import User, Department, UserDepartment, OperationLog
|
||||
from app.schemas import UserCreate, UserUpdate, UserOut, ImportResult
|
||||
from app.utils.pinyin import cn_to_pinyin
|
||||
from app.utils.password_hash import hash_password, verify_password
|
||||
from app.services.linux_service import create_linux_user, delete_linux_user, add_user_to_group
|
||||
from app.services.samba_service import add_samba_user, delete_samba_user
|
||||
from app.routers.departments import _reload_samba
|
||||
|
||||
router = APIRouter()
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||||
|
||||
|
||||
def generate_username(display_name: str) -> str:
|
||||
"""生成全拼用户名(查重)"""
|
||||
base = cn_to_pinyin(display_name)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
dept_id: int = None,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from sqlalchemy.orm import selectinload
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.is_deleted == False)
|
||||
.options(selectinload(User.departments).selectinload(UserDepartment.department))
|
||||
)
|
||||
if dept_id:
|
||||
query = query.join(UserDepartment).where(UserDepartment.department_id == dept_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for user in users:
|
||||
depts = []
|
||||
for ud in user.departments:
|
||||
depts.append({
|
||||
"department_id": ud.department_id,
|
||||
"department_name": ud.department.name if ud.department else "",
|
||||
"role_in_dept": ud.role_in_dept
|
||||
})
|
||||
out.append(UserOut(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
is_deleted=user.is_deleted,
|
||||
departments=depts,
|
||||
created_at=user.created_at
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
username = generate_username(body.display_name)
|
||||
|
||||
# 查重:如果用户名已存在,追加数字后缀
|
||||
counter = 1
|
||||
original_username = username
|
||||
while True:
|
||||
existing = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
break
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
# 检查所有部门都存在
|
||||
for dept_info in body.departments:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_info["department_id"])
|
||||
)
|
||||
if not dept_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"部门 {dept_info['department_id']} 不存在")
|
||||
|
||||
# 创建 Linux 系统用户
|
||||
linux_result = create_linux_user(username, body.password)
|
||||
if not linux_result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=f"创建系统用户失败: {linux_result.get('error')}")
|
||||
|
||||
# 创建 SMB 用户
|
||||
if not add_samba_user(username, body.password):
|
||||
# Samba用户失败不影响主流程
|
||||
pass
|
||||
|
||||
# 创建数据库用户
|
||||
password_hash = hash_password(body.password)
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=body.display_name,
|
||||
password_hash=password_hash
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
# 创建用户-部门关联和文件夹
|
||||
for dept_info in body.departments:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_info["department_id"])
|
||||
)
|
||||
dept = dept_result.scalar_one()
|
||||
|
||||
role = dept_info.get("role_in_dept", "member")
|
||||
|
||||
# 加入部门关联
|
||||
ud = UserDepartment(
|
||||
user_id=user.id,
|
||||
department_id=dept.id,
|
||||
role_in_dept=role
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
# 创建个人文件夹
|
||||
folder_path = os.path.join(DATA_DIR, f"dept-{dept.name}", username)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
# 设置权限:用户加入部门组,组可访问父目录
|
||||
try:
|
||||
import pwd, grp, subprocess
|
||||
uid = pwd.getpwnam(username).pw_uid
|
||||
# 部门组名用拼音(Linux不支持中文组名)
|
||||
group_name = f"group_{cn_to_pinyin(dept.name)}"
|
||||
try:
|
||||
grp.getgrnam(group_name)
|
||||
except KeyError:
|
||||
subprocess.run(["groupadd", "-f", group_name], check=False, capture_output=True)
|
||||
subprocess.run(["usermod", "-aG", group_name, username], check=False, capture_output=True)
|
||||
gid = grp.getgrnam(group_name).gr_gid
|
||||
# 父目录设 SGID,新文件继承组
|
||||
dept_path = os.path.dirname(folder_path)
|
||||
os.chown(dept_path, -1, gid)
|
||||
os.chmod(dept_path, 0o2770) # SGID + group rwx
|
||||
# 用户文件夹
|
||||
os.chown(folder_path, uid, gid)
|
||||
os.chmod(folder_path, 0o2770)
|
||||
except Exception as ex:
|
||||
os.chmod(folder_path, 0o700)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# 更新 Samba 配置
|
||||
await _reload_samba(session)
|
||||
|
||||
# 记录日志
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="CREATE",
|
||||
target_type="USER",
|
||||
target_name=body.display_name,
|
||||
details=json.dumps({"username": username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}")
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if body.display_name:
|
||||
user.display_name = body.display_name
|
||||
|
||||
# 更新部门及角色
|
||||
if body.departments is not None:
|
||||
# 删除旧关联
|
||||
old_uds = await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
for ud in old_uds.scalars().all():
|
||||
await session.delete(ud)
|
||||
# 重建关联
|
||||
for dept_info in body.departments:
|
||||
dept_id = dept_info.get("department_id")
|
||||
role = dept_info.get("role_in_dept", "member")
|
||||
if dept_id:
|
||||
from app.models import Department
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.id == dept_id, Department.is_deleted == False)
|
||||
)
|
||||
dept = dept_result.scalar_one_or_none()
|
||||
if dept:
|
||||
ud = UserDepartment(
|
||||
user_id=user_id,
|
||||
department_id=dept_id,
|
||||
role_in_dept=role
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
# 禁止删除 admin 用户
|
||||
if user_id == 1:
|
||||
raise HTTPException(status_code=403, detail="禁止删除系统管理员")
|
||||
|
||||
if not verify_password(body.get("password", ""), current_user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="密码错误")
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 软删除
|
||||
user.is_deleted = True
|
||||
|
||||
# 从 Linux 系统删除用户
|
||||
delete_linux_user(user.username)
|
||||
|
||||
# 从 Samba 删除用户
|
||||
delete_samba_user(user.username)
|
||||
|
||||
# 删除所有部门关联
|
||||
await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
ud_result = await session.execute(
|
||||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||||
)
|
||||
for ud in ud_result.scalars().all():
|
||||
await session.delete(ud)
|
||||
|
||||
await session.commit()
|
||||
await _reload_samba(session)
|
||||
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="DELETE",
|
||||
target_type="USER",
|
||||
target_name=user.display_name,
|
||||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Deleted"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
body: dict,
|
||||
request: Request,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
new_password = body.get("new_password", "")
|
||||
if len(new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="密码至少6位")
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
await session.commit()
|
||||
|
||||
# 更新 Samba 密码
|
||||
add_samba_user(user.username, new_password)
|
||||
|
||||
log = OperationLog(
|
||||
operator_username=current_user.username,
|
||||
action="RESET_PASSWORD",
|
||||
target_type="USER",
|
||||
target_name=user.display_name,
|
||||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||||
ip_address=request.client.host if request.client else "unknown"
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Password reset"}
|
||||
|
||||
|
||||
@router.get("/import-template")
|
||||
async def download_import_template():
|
||||
"""下载用户批量导入模板(Excel .xlsx)"""
|
||||
import io
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "用户导入"
|
||||
# 表头
|
||||
headers = ["姓名*", "密码*", "部门1", "部门2", "部门3", "部门1角色", "部门2角色", "部门3角色"]
|
||||
ws.append(headers)
|
||||
# 示例行
|
||||
ws.append(["张三", "123456", "销售部", "", "", "member", "", ""])
|
||||
ws.append(["李四", "123456", "技术部", "", "", "admin", "", ""])
|
||||
# 设置列宽
|
||||
ws.column_dimensions["A"].width = 12
|
||||
ws.column_dimensions["B"].width = 10
|
||||
ws.column_dimensions["C"].width = 10
|
||||
ws.column_dimensions["D"].width = 10
|
||||
ws.column_dimensions["E"].width = 10
|
||||
ws.column_dimensions["F"].width = 10
|
||||
ws.column_dimensions["G"].width = 10
|
||||
ws.column_dimensions["H"].width = 10
|
||||
# 加粗表头
|
||||
for cell in ws[1]:
|
||||
cell.font = openpyxl.styles.Font(bold=True)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=smb_user_import_template.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_users(
|
||||
file: UploadFile = File(...),
|
||||
request: Request = None,
|
||||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
contents = await file.read()
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
failed_rows = []
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(contents)
|
||||
ws = wb.active
|
||||
|
||||
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
||||
display_name, password = row[0], row[1]
|
||||
if not display_name or not password:
|
||||
failed_rows.append({"row": i, "error": "缺少姓名或密码"})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
username = generate_username(str(display_name))
|
||||
counter = 1
|
||||
original_username = username
|
||||
while True:
|
||||
existing = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
break
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=str(display_name),
|
||||
password_hash=hash_password(str(password))
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
# 处理部门
|
||||
for col_idx, role_field in [(2, "role1"), (3, "role2"), (4, "role3")]:
|
||||
dept_name = row[col_idx] if len(row) > col_idx else None
|
||||
role = row[col_idx + 3] if len(row) > col_idx + 3 else "member"
|
||||
|
||||
if dept_name:
|
||||
dept_result = await session.execute(
|
||||
select(Department).where(Department.name == str(dept_name).strip())
|
||||
)
|
||||
dept = dept_result.scalar_one_or_none()
|
||||
if dept:
|
||||
ud = UserDepartment(
|
||||
user_id=user.id,
|
||||
department_id=dept.id,
|
||||
role_in_dept=role or "member"
|
||||
)
|
||||
session.add(ud)
|
||||
|
||||
# 创建 Linux 和 Samba 用户
|
||||
create_linux_user(username, str(password))
|
||||
add_samba_user(username, str(password))
|
||||
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
failed_rows.append({"row": i, "error": str(e)})
|
||||
fail_count += 1
|
||||
|
||||
await session.commit()
|
||||
await _reload_samba(session)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Excel解析失败: {str(e)}")
|
||||
|
||||
return ImportResult(
|
||||
success_count=success_count,
|
||||
fail_count=fail_count,
|
||||
failed_rows=failed_rows
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# utils __init__.py
|
||||
@@ -0,0 +1,30 @@
|
||||
"""辅助函数"""
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
def run_cmd(cmd: list, timeout: int = 30, input_str: str = None) -> tuple:
|
||||
"""执行系统命令,返回 (returncode, stdout, stderr)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
input=input_str
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", "Command timeout"
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def ensure_dir(path: str, mode: int = 0o755) -> bool:
|
||||
"""确保目录存在"""
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
os.chmod(path, mode)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,12 @@
|
||||
"""密码哈希"""
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""中文转拼音"""
|
||||
from pypinyin import lazy_pinyin
|
||||
|
||||
|
||||
def cn_to_pinyin(name: str) -> str:
|
||||
"""中文姓名转全拼,无声调"""
|
||||
return "".join(lazy_pinyin(name))
|
||||
@@ -0,0 +1,11 @@
|
||||
# requirements.txt
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.35
|
||||
aiosqlite==0.20.0
|
||||
pydantic==2.9.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pypinyin==0.53.0
|
||||
openpyxl==3.1.5
|
||||
python-multipart==0.0.9
|
||||
Reference in New Issue
Block a user