feat: SMB文件服务器管理后台 - 完整版本

- 用户管理:增删改、批量导入(Excel模板)、admin保护
- 部门管理:增删改(含回收站恢复机制)、删除保护(有用户不可删)
- 权限管理:部门内角色(admin/readwrite/readonly/none)
- 监控页面:CPU/内存/磁盘实时监控
- 日志页面:操作日志查询
- SMB权限:用户文件夹700(member)/750(admin)、ACL继承
- admin跨目录访问:通过setfacl ACL实现
This commit is contained in:
bi
2026-07-13 23:03:03 +08:00
parent c8054a2169
commit c790e43cdf
40 changed files with 3974 additions and 2 deletions
-2
View File
@@ -1,3 +1 @@
# Smb-Server
ubuntu docker 共享文件服务器
+1
View File
@@ -0,0 +1 @@
# SMB File Server API
+25
View File
@@ -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
+115
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item active" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128101; 部门管理</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()">&#128260; 刷新</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')">&times;</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>
+225
View File
@@ -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
View File
@@ -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")))
+70
View File
@@ -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)
+41
View File
@@ -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
)
+112
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item active" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128274; 权限管理</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)">&#128274; 权限说明</strong></p>
<p style="margin:0 0 4px 0">本系统的「权限」指 <strong>SMB 文件共享权限</strong>。每个部门对应一个共享文件夹,部门内的用户可以访问该文件夹。</p>
<p style="margin:0 0 4px 0">&#128279; <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">&#128279; <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">&#128101; 部门 - 用户权限树</div>
<button class="btn btn-ghost" onclick="load()">&#128260; 刷新</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">&#128101; <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">
&#128279; 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">&#128282; 部门管理员</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">&#128100; 部门成员</span>
<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:6px">${dept.members.map(u =>
'<span class="tree-user">&#128100; ' + 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>
+78
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
# routers __init__.py
+92
View File
@@ -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"}
+234
View File
@@ -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)
+82
View File
@@ -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"}
)
+40
View File
@@ -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
)
+78
View File
@@ -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"}
+464
View File
@@ -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
)
+94
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# services __init__.py
+71
View File
@@ -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
+112
View File
@@ -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
+95
View File
@@ -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)
+121
View File
@@ -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)}
+4
View File
@@ -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}
+55
View File
@@ -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');
});
}
+122
View File
@@ -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">&#128193;</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>
+117
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item active" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#127968; 仪表盘</h1></div>
<div class="content">
<div class="view-header">
<div class="view-title">&#128200; 系统总览</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">&#128190; 磁盘使用总览</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">&#128193; 各部门用量</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>
+113
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item active" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128101; 部门管理</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()">&#128260; 刷新</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')">&times;</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>
+94
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item active" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128203; 操作日志</h1></div>
<div class="content">
<div class="view-header">
<div class="view-title"></div>
<div class="toolbar">
<button class="btn btn-ghost" onclick="exportLogs()">&#128229; 导出CSV</button>
<button class="btn btn-ghost" onclick="load()">&#128260; 刷新</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>
+76
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item active" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128200; 磁盘监控</h1></div>
<div class="content">
<div class="view-header">
<div class="view-title"></div>
<button class="btn btn-primary" onclick="load()">&#128260; 刷新</button>
</div>
<div class="card">
<div class="card-header"><span class="card-title">&#128190; 总览</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">&#128193; 各部门用量</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>
+69
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item" href="/users">&#128100; 用户管理</a>
<a class="nav-item active" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128274; 权限管理</h1></div>
<div class="content">
<div class="view-header">
<div class="view-title">部门-用户权限树</div>
<button class="btn btn-ghost" onclick="load()">&#128260; 刷新</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">&#128101; ${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}">&#128282; ${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}">&#128100; ${u.display_name}</span>`).join('')}
</div>
</div>
</div>`).join('');
}
function logout(){localStorage.removeItem('smb_token');location.href='/';}
load();
</script>
</body>
</html>
+232
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item active" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128100; 用户管理</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">&#128196; 下载模板</a>
<button class="btn btn-ghost" onclick="document.getElementById('importFile').click()">&#128229; 批量导入</button>
<input type="file" id="importFile" accept=".xlsx" style="display:none" onchange="doImport(this)">
<button class="btn btn-ghost" onclick="load()">&#128260; 刷新</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')">&times;</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')">&times;</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')">&times;</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
View File
@@ -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>&#128193;</span><div><h2>SMB 文件服务器</h2><small>v1.0</small></div></div>
<nav class="nav">
<a class="nav-item" href="/dashboard">&#127968; 仪表盘</a>
<a class="nav-item" href="/departments">&#128101; 部门管理</a>
<a class="nav-item active" href="/users">&#128100; 用户管理</a>
<a class="nav-item" href="/permissions">&#128274; 权限管理</a>
<a class="nav-item" href="/monitor">&#128200; 磁盘监控</a>
<a class="nav-item" href="/logs">&#128203; 操作日志</a>
</nav>
<div class="sidebar-user">
<strong id="currentUser">-</strong>
<a href="#" onclick="logout()" style="color:var(--text3);font-size:11px;text-decoration:none">&#128682; 退出登录</a>
</div>
</div>
<div class="main">
<div class="topbar"><h1>&#128100; 用户管理</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">&#128196; 下载模板</a>
<button class="btn btn-ghost" onclick="document.getElementById('importFile').click()">&#128229; 批量导入</button>
<input type="file" id="importFile" accept=".xlsx" style="display:none" onchange="doImport(this)">
<button class="btn btn-ghost" onclick="load()">&#128260; 刷新</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')">&times;</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')">&times;</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')">&times;</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
View File
@@ -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
)
+1
View File
@@ -0,0 +1 @@
# utils __init__.py
+30
View File
@@ -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
+12
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""中文转拼音"""
from pypinyin import lazy_pinyin
def cn_to_pinyin(name: str) -> str:
"""中文姓名转全拼,无声调"""
return "".join(lazy_pinyin(name))
+11
View File
@@ -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